From d1be5f07101cf55768f7605f53a11bd61c70a061 Mon Sep 17 00:00:00 2001 From: Belle <63379322+missabundance9@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:23:28 +1000 Subject: [PATCH 1/3] feat: activate bounded Chroma snapshot detection Implement Phase 0 WP7D for #24 with aggregate-only detection, atomic report finalization, exact activation gates, adversarial tests, CI evidence, and bounded public claims. --- .github/workflows/wp7d-snapshot-chroma.yml | 235 +++++ CONTRIBUTING.md | 42 +- README.md | 70 +- README.zh-TW.md | 62 +- ROADMAP.md | 5 +- SECURITY.md | 32 +- docs/ARCHITECTURE.md | 106 +- docs/MONITOR_STATE.md | 11 +- docs/RELEASE_PROCESS.md | 45 +- docs/THREAT_MODEL.md | 36 +- pyproject.toml | 3 +- src/ragleakguard/_chroma_snapshot.py | 641 +++++++++++- src/ragleakguard/cli.py | 118 ++- src/ragleakguard/connectors.py | 209 +++- src/ragleakguard/report.py | 296 +++++- tests/test_chroma_disabled.py | 20 +- tests/test_fail_closed.py | 53 +- tests/test_snapshot_confinement.py | 31 +- tests/test_wp7a_claims.py | 64 +- tests/test_wp7d_snapshot_activation.py | 1023 ++++++++++++++++++++ 20 files changed, 2853 insertions(+), 249 deletions(-) create mode 100644 .github/workflows/wp7d-snapshot-chroma.yml create mode 100644 tests/test_wp7d_snapshot_activation.py diff --git a/.github/workflows/wp7d-snapshot-chroma.yml b/.github/workflows/wp7d-snapshot-chroma.yml new file mode 100644 index 0000000..0d0b6b6 --- /dev/null +++ b/.github/workflows/wp7d-snapshot-chroma.yml @@ -0,0 +1,235 @@ +name: WP7D operator-snapshot Chroma activation + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + wp7d-snapshot-activation: + name: WP7D / ${{ matrix.platform }} / Python ${{ matrix.python }} / Chroma ${{ matrix.chroma }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: Linux-ext4 + python: "3.10" + chroma: "1.5.9" + - os: ubuntu-latest + platform: Linux-ext4 + python: "3.11" + chroma: "1.5.9" + - os: ubuntu-latest + platform: Linux-ext4 + python: "3.12" + chroma: "1.5.9" + - os: macos-15 + platform: macOS15-APFS + python: "3.12" + chroma: "1.5.9" + - os: windows-latest + platform: Windows-NTFS + python: "3.12" + chroma: "1.5.9" + + steps: + - uses: actions/checkout@v4 + + - name: Set up exact Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Install exact public candidate and evidence inputs + run: | + python -m pip install --upgrade pip + python -m pip install ".[chroma-snapshot,detect,dev]" "chromadb==${{ matrix.chroma }}" + python -m spacy download en_core_web_sm + python -m pip check + python -m pip list --format=freeze --disable-pip-version-check + python -c "import importlib.metadata,sys; assert sys.version_info[:2] == tuple(map(int, '${{ matrix.python }}'.split('.'))); assert importlib.metadata.version('chromadb') == '${{ matrix.chroma }}'" + + - name: Prepare explicitly mounted native ext4 volume + if: runner.os == 'Linux' + shell: bash + run: | + sudo useradd --system --user-group --no-create-home --shell /usr/sbin/nologin rlgwp7d + test_user=rlgwp7d + test_uid="$(id -u "${test_user}")" + test "${test_uid}" != "$(id -u)" + echo "RLG_WP7D_TEST_USER=${test_user}" >> "${GITHUB_ENV}" + echo "RLG_WP7D_TEST_UID=${test_uid}" >> "${GITHUB_ENV}" + truncate -s 2G "${RUNNER_TEMP}/rlg-wp7d-ext4.img" + mkfs.ext4 -q -F "${RUNNER_TEMP}/rlg-wp7d-ext4.img" + sudo mkdir -p /mnt/rlg-wp7d-ext4 + sudo mount -o loop "${RUNNER_TEMP}/rlg-wp7d-ext4.img" /mnt/rlg-wp7d-ext4 + sudo mkdir /mnt/rlg-wp7d-ext4/home + sudo mkdir /mnt/rlg-wp7d-ext4/repository + git archive --format=tar HEAD | sudo tar -xf - -C /mnt/rlg-wp7d-ext4/repository + sudo chown -R "${test_user}:${test_user}" /mnt/rlg-wp7d-ext4 + sudo -u "${test_user}" test -r /mnt/rlg-wp7d-ext4/repository/pyproject.toml + test "$(findmnt -n -o FSTYPE /mnt/rlg-wp7d-ext4)" = ext4 + + - name: Assert native APFS + if: runner.os == 'macOS' + shell: bash + run: | + test "$(sw_vers -productVersion | cut -d. -f1)" = 15 + device="$(df "${RUNNER_TEMP}" | awk 'END {print $1}')" + test -n "${device}" + diskutil info "${device}" | grep -Eiq 'File System Personality:.*APFS' + + - name: Assert native NTFS + if: runner.os == 'Windows' + shell: pwsh + run: | + $drive = [IO.Path]::GetPathRoot($env:RUNNER_TEMP).Substring(0, 1) + $volume = Get-Volume -DriveLetter $drive + if ($volume.FileSystem -ne 'NTFS') { throw 'Required NTFS evidence is absent.' } + + - name: Deny outbound traffic for the test identity + if: runner.os == 'Linux' + shell: bash + run: | + test -n "${RLG_WP7D_TEST_UID}" + sudo iptables -I OUTPUT 1 -m owner --uid-owner "${RLG_WP7D_TEST_UID}" -j REJECT + sudo iptables -I OUTPUT 1 -o lo -j ACCEPT + sudo iptables -C OUTPUT -o lo -j ACCEPT + sudo iptables -C OUTPUT -m owner --uid-owner "${RLG_WP7D_TEST_UID}" -j REJECT + + - name: Deny outbound traffic for the Python worker + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $rule = "RLG-WP7D-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT" + $python = (Get-Command python).Source + New-NetFirewallRule -DisplayName $rule -Direction Outbound -Program $python -Action Block -Profile Any | Out-Null + "RLG_WP7D_FIREWALL_RULE=$rule" >> $env:GITHUB_ENV + $observed = Get-NetFirewallRule -DisplayName $rule + if ($observed.Enabled.ToString() -ne 'True' -or $observed.Action.ToString() -ne 'Block') { + throw 'Required Windows outbound-denial evidence is absent.' + } + + - name: Run focused WP7D evidence on native ext4 with OS egress denial + if: runner.os == 'Linux' + shell: bash + env: + ANONYMIZED_TELEMETRY: "False" + PYTHONWARNINGS: ignore + RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" + RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7D_ACTIVATION: "1" + RLG_WP7D_MANDATORY: "1" + RLG_WP7D_OS_EGRESS_DENIED: "1" + TMPDIR: /mnt/rlg-wp7d-ext4 + run: | + python_path="$(command -v python)" + cd /mnt/rlg-wp7d-ext4/repository + sudo --preserve-env=ANONYMIZED_TELEMETRY,PYTHONWARNINGS,RLG_REQUIRE_NATIVE_SNAPSHOT_FS,RLG_WP7C_COMPATIBILITY,RLG_WP7D_ACTIVATION,RLG_WP7D_MANDATORY,RLG_WP7D_OS_EGRESS_DENIED,TMPDIR \ + -u "${RLG_WP7D_TEST_USER}" env HOME=/mnt/rlg-wp7d-ext4/home \ + "${python_path}" -m pytest tests/test_wp7d_snapshot_activation.py tests/test_chroma_snapshot_private.py -q --basetemp /mnt/rlg-wp7d-ext4/focused + + - name: Run complete suite on native ext4 with OS egress denial + if: runner.os == 'Linux' + shell: bash + env: + ANONYMIZED_TELEMETRY: "False" + PYTHONWARNINGS: ignore + RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" + RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7D_ACTIVATION: "1" + RLG_WP7D_MANDATORY: "1" + RLG_WP7D_OS_EGRESS_DENIED: "1" + TMPDIR: /mnt/rlg-wp7d-ext4 + run: | + python_path="$(command -v python)" + cd /mnt/rlg-wp7d-ext4/repository + sudo --preserve-env=ANONYMIZED_TELEMETRY,PYTHONWARNINGS,RLG_REQUIRE_NATIVE_SNAPSHOT_FS,RLG_WP7C_COMPATIBILITY,RLG_WP7D_ACTIVATION,RLG_WP7D_MANDATORY,RLG_WP7D_OS_EGRESS_DENIED,TMPDIR \ + -u "${RLG_WP7D_TEST_USER}" env HOME=/mnt/rlg-wp7d-ext4/home \ + "${python_path}" -m pytest -q --basetemp /mnt/rlg-wp7d-ext4/complete + + - name: Run focused WP7D evidence on APFS with OS egress denial + if: runner.os == 'macOS' + shell: bash + env: + ANONYMIZED_TELEMETRY: "False" + PYTHONWARNINGS: ignore + RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" + RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7D_ACTIVATION: "1" + RLG_WP7D_MANDATORY: "1" + RLG_WP7D_OS_EGRESS_DENIED: "1" + run: sandbox-exec -p '(version 1) (allow default) (deny network*)' python -m pytest tests/test_wp7d_snapshot_activation.py tests/test_chroma_snapshot_private.py -q --basetemp "${RUNNER_TEMP}/rlg-wp7d-focused" + + - name: Run complete suite on APFS with OS egress denial + if: runner.os == 'macOS' + shell: bash + env: + ANONYMIZED_TELEMETRY: "False" + PYTHONWARNINGS: ignore + RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" + RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7D_ACTIVATION: "1" + RLG_WP7D_MANDATORY: "1" + RLG_WP7D_OS_EGRESS_DENIED: "1" + run: sandbox-exec -p '(version 1) (allow default) (deny network*)' python -m pytest -q --basetemp "${RUNNER_TEMP}/rlg-wp7d-complete" + + - name: Run focused WP7D evidence on NTFS with OS egress denial + if: runner.os == 'Windows' + shell: pwsh + env: + ANONYMIZED_TELEMETRY: "False" + PYTHONWARNINGS: ignore + RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" + RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7D_ACTIVATION: "1" + RLG_WP7D_MANDATORY: "1" + RLG_WP7D_OS_EGRESS_DENIED: "1" + run: python -m pytest tests/test_wp7d_snapshot_activation.py tests/test_chroma_snapshot_private.py -q --basetemp "$env:RUNNER_TEMP/rlg-wp7d-focused" + + - name: Run complete suite on NTFS with OS egress denial + if: runner.os == 'Windows' + shell: pwsh + env: + ANONYMIZED_TELEMETRY: "False" + PYTHONWARNINGS: ignore + RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" + RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7D_ACTIVATION: "1" + RLG_WP7D_MANDATORY: "1" + RLG_WP7D_OS_EGRESS_DENIED: "1" + run: python -m pytest -q --basetemp "$env:RUNNER_TEMP/rlg-wp7d-complete" + + - name: Remove Windows outbound-denial rule + if: always() && runner.os == 'Windows' + shell: pwsh + run: | + if ($env:RLG_WP7D_FIREWALL_RULE) { + Remove-NetFirewallRule -DisplayName $env:RLG_WP7D_FIREWALL_RULE + } + + - name: Remove Linux outbound-denial rule + if: always() && runner.os == 'Linux' + shell: bash + run: | + sudo iptables -D OUTPUT -o lo -j ACCEPT || true + if [ -n "${RLG_WP7D_TEST_UID:-}" ]; then + sudo iptables -D OUTPUT -m owner --uid-owner "${RLG_WP7D_TEST_UID}" -j REJECT || true + fi + + - name: Unmount and remove native ext4 volume + if: always() && runner.os == 'Linux' + shell: bash + run: | + sudo umount /mnt/rlg-wp7d-ext4 || true + sudo rmdir /mnt/rlg-wp7d-ext4 || true + rm -f "${RUNNER_TEMP}/rlg-wp7d-ext4.img" + if [ -n "${RLG_WP7D_TEST_USER:-}" ]; then + sudo userdel "${RLG_WP7D_TEST_USER}" || true + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c71ac4..c20fbb0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,26 +64,30 @@ A locale contribution must: ## Connectors and integrations -**Implemented now:** no source-scanning connector is available. Direct local Chroma entry points fail closed before Chroma import or source access. [Issue #15](https://github.com/Agenvana/RAGLeakGuard/issues/15) was deferred as `not planned`, not completed. Executable endpoint evidence established durable mutation with ChromaDB 1.5.0 and 1.5.9; other versions have not established an acceptable read-only boundary. - -Snapshot-backed Chroma scanning remains unavailable. WP7B contains the private, bounded -filesystem-confinement foundation for a complete snapshot that the operator created separately. -WP7C privately evaluates exact candidate dependencies inside a held WP7B work copy and returns only -an opaque counter receipt after bounded two-pass enumeration, teardown, semantic revalidation, and -effect classification. It exposes no source rows or detector input and is not detector or scan -completion. Neither layer proves quiescence or atomic multi-file consistency or exposes a public -function, CLI, connector, package extra, report, monitor, or webhook path. - -Do not activate or extend these private layers into a scanning surface, claim a supported Chroma -range, or commit to future support without a separate WP7D issue, executable evidence, exact-commit -independent review, and human authorization. PyPI 0.1.0 contains the unsafe direct Chroma path and -must not be used for Chroma scanning. +**Implemented now:** one aggregate-only operator-snapshot Chroma connector is available for exact +ChromaDB 1.5.9 on Linux/ext4 Python 3.10–3.12, macOS 15/APFS Python 3.12, and Windows/NTFS Python +3.12. Direct/live Chroma entry points remain disabled and fail closed before Chroma import or source access. +[Issue #15](https://github.com/Agenvana/RAGLeakGuard/issues/15) was deferred as `not planned`, not +completed. Executable endpoint evidence established durable mutation with ChromaDB 1.5.0 and 1.5.9; +1.5.0 remains private evidence only and every other version is rejected publicly. + +WP7D consumes a held WP7B work copy through WP7C's bounded two-pass enumeration and runs detection +inside the isolated worker. It exposes bounded connector counters and detector entity-type counts +only after exact equality, teardown, semantic/capability revalidation, proven cleanup, and atomic +aggregate-report finalization. The operator—not RAGLeakGuard—must create a complete, +quiescent/full-filesystem snapshot separately. The implementation does not prove provenance, +quiescence, completeness, or atomic multi-file consistency. Monitor new scans remain unavailable. + +Do not widen the version/environment matrix, limits, IPC/report surface, retry policy, or connector +scope without a separate issue, executable evidence, exact-commit independent review, and human authorization. +PyPI 0.1.0 contains the unsafe direct Chroma path and must not be used for Chroma +scanning; no corrective release has been published. The private foundation passed independent review at exact implementation head `128decb3e0d78825e884f6dce019898b568c6ba2` and was merged through [PR #20](https://github.com/Agenvana/RAGLeakGuard/pull/20) as merge commit -`5db765689d35eec8ba918f0f616d5fea34e56955`. That review record does not authorize a public -snapshot consumer, direct Chroma access, a release, or WP7D. +`5db765689d35eec8ba918f0f616d5fea34e56955`. That review record does not authorize direct/live +Chroma access, a release, or any expansion beyond the finite WP7D boundary. Changes to the private snapshot lifecycle must preserve its hard maxima, no-follow regular-object allowlist, same-device containment, observed-stability checks, restrictive work permissions, @@ -97,6 +101,12 @@ pagination and canonicalization, keyed bounded manifests, worker termination bef static privacy-safe failures, zero child output, OS egress evidence, and explicit classification of all work-copy effects. Run its isolated candidate matrix as well as the complete no-Chroma suite. +Changes to the public WP7D surface must preserve aggregate-only results, first-pass-only detection, +exact connector/detector count equality, pre-source acknowledgement/locale/source-ID/runtime/host +gates, exact ChromaDB 1.5.9 activation, report atomicity, cleanup-before-result ordering, recursive +privacy canaries, and all five mandatory native-filesystem cells. Keep the ten-cell WP7C private +matrix intact. + Any future connector change requires an independently reviewed read-only boundary and must test application and dependency effects, bounds, completeness, malformed input, cancellation, concurrent mutation, filesystem mutation, and outbound network behavior. An incomplete or inconsistent scan must never report success. Integrations must not emit raw detected values. Any metadata egress needs a documented allowlist and threat-model update. diff --git a/README.md b/README.md index 7c4ab8e..1df8004 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,12 @@ RAGLeakGuard is an early-development security project for diagnosing sensitive data at rest. Its detection, risk-policy, monitor-state, and authenticated webhook components remain in the -repository, but **no source-scanning connector is currently available**. +repository. A bounded aggregate-only Chroma connector is implemented for complete operator-created +offline snapshots. Direct/live source-store scanning remains disabled. ## Chroma safety notice -Direct local Chroma scanning is disabled. Executable endpoint evidence established that +Direct/live local Chroma scanning is disabled. Executable endpoint evidence established that ChromaDB 1.5.0 and 1.5.9 may modify durable store files during client construction or reads. Other Chroma versions have not established an acceptable read-only boundary. This is the exact tested scope; it is not a claim about every Chroma version. @@ -24,14 +25,16 @@ it was not completed. WP7B's private, bounded operator-snapshot confinement foun independent review at exact implementation head `128decb3e0d78825e884f6dce019898b568c6ba2` and was merged through [PR #20](https://github.com/Agenvana/RAGLeakGuard/pull/20) as merge commit -`5db765689d35eec8ba918f0f616d5fea34e56955`. That foundation only confines a complete filesystem -snapshot created separately by the operator; it does not import or construct Chroma and exposes no -public function, CLI, connector, report, monitor, or webhook path. +`5db765689d35eec8ba918f0f616d5fea34e56955`. WP7D now consumes that private work copy through the +reviewed WP7C two-pass enumerator and performs detection inside the isolated worker. Public +activation is limited to exact ChromaDB 1.5.9 on Linux/ext4 with Python 3.10–3.12, macOS 15/APFS +with Python 3.12, and Windows/NTFS with Python 3.12. ChromaDB 1.5.0 remains private evidence only +and is rejected by the public activation path. -Snapshot-backed public scanning remains unavailable and requires separate feasibility, security, -activation, and exact-commit review. Direct Chroma access remains disabled. No supported Chroma -version range, future availability, connector completeness, read-only source access, or -production-safety claim is made. +The operator—not RAGLeakGuard—must create a complete, quiescent/full-filesystem snapshot before +invocation. RAGLeakGuard does not prove its provenance, quiescence, completeness, or transactional +atomic consistency. The snapshot is potentially hostile and sensitive. No general Chroma support, +read-only live-source access, connector completeness, or production-safety claim is made. The PyPI `0.1.0` package contains the unsafe direct Chroma path and **must not be used for Chroma scanning**. Yanking that package and publishing a corrective release require separate human @@ -39,10 +42,13 @@ maintainer authorization and have not been performed by this repository change. ## Current command behavior -A syntactically valid `scan --source chroma` request exits with code 6 and the static disabled-path -message before Chroma import, detector initialization, source access, report work, or success output. -`read_chroma()` raises the public `ChromaConnectorUnavailableError` synchronously without inspecting -its argument. +`scan --source chroma` accepts only `--snapshot`, `--work-parent`, a narrow pseudonymous +`--source-id`, the explicit `--acknowledge-offline-complete-snapshot`, optional `--locale`, and +`--report`. Legacy `--path` is rejected before source access. A success line is emitted only after +bounded copy preparation, two-pass enumeration, complete detection, exact aggregate equality, +worker termination, final capability validation, cleanup, and atomic report finalization. +`read_chroma()` still raises `ChromaConnectorUnavailableError` synchronously without inspecting its +arguments. `monitor` continues to authenticate its key and state first. If authenticated state contains a pending WP6 alert, the existing configuration, backoff, retry, transport, ambiguous-delivery, and @@ -50,18 +56,31 @@ atomic-clear recovery workflow runs without a new scan. If no alert is pending a otherwise start, `monitor` exits 6 without changing state or creating a report, alert, or webhook. Ordinary missing-option, unsupported-source, and malformed/unsupported-locale validation still exits -2. Monitor key/state failures exit 4; pending-alert and webhook failures retain exit 5. +2. Scan/report uncertainty exits 1, detection-runtime failure exits 3, and an unavailable exact +candidate or activation environment exits 6. Monitor key/state failures exit 4; pending-alert and +webhook failures retain exit 5. -```text -Local Chroma scanning is disabled because executable endpoint evidence proved that ChromaDB 1.5.0 and 1.5.9 may modify durable store files during client construction or reads, while other versions have not established an acceptable read-only boundary. No report, monitor state, or webhook was created or replaced. +Install the exact optional Chroma candidate and the existing detector stack, then point the command +only at a separately created offline snapshot: + +```bash +python -m pip install ".[chroma-snapshot,detect]" +python -m spacy download en_core_web_sm +ragleakguard scan --source chroma \ + --snapshot /private/offline-snapshot \ + --work-parent /private/ragleakguard-work \ + --source-id source-1 \ + --acknowledge-offline-complete-snapshot \ + --report /private/reports/source-1.md ``` -There is intentionally no Chroma scan or monitor quickstart while direct access is disabled. +The paths above are placeholders and are never included in normal console output or the report. +There is intentionally no Chroma monitor quickstart because monitor new scans remain unavailable. ## Development setup -The Chroma runtime extra has been removed. The following installs the package, detection stack, and -test tools; it does not provide a source-scanning connector. +Chroma remains outside base dependencies. The following installs the exact snapshot candidate, +detection stack, and test tools: ```bash git clone https://github.com/Agenvana/RAGLeakGuard.git @@ -69,7 +88,7 @@ cd RAGLeakGuard python -m venv .venv # Activate the environment for your platform. python -m pip install --upgrade pip -python -m pip install -e ".[detect,dev]" +python -m pip install -e ".[chroma-snapshot,detect,dev]" python -m spacy download en_core_web_sm python -m pytest -q ``` @@ -84,9 +103,8 @@ against real, production, customer, or otherwise sensitive stores. - **Locale packs (`--locale`):** `au` is the only implemented opt-in country pack. Detection is best-effort. A result from the detector library is not proof that data is safe, -compliant, or free of sensitive information. When the required model is absent, Presidio may try to -acquire it during initialization; runtime acquisition control and exact model pinning remain residual -hardening work. Disabled CLI new-scan paths do not initialize this runtime. +compliant, or free of sensitive information. The required spaCy model must already be installed; +the isolated worker denies model acquisition, network egress, and nested processes. ## Monitor recovery @@ -118,9 +136,9 @@ or safety. See [benchmark reproducibility](docs/BENCHMARK_REPRODUCIBILITY.md). ## Roadmap and non-claims -See [ROADMAP.md](ROADMAP.md). Snapshot-backed public scanning, planned connectors, Prevent/Fix, -Prove, Control Plane, erasure, compliance, certification, and assurance surfaces are not -implemented. The completed private WP7B confinement foundation is not a connector. +See [ROADMAP.md](ROADMAP.md). The finite operator-snapshot connector described above is implemented. +Additional connectors, direct/live scanning, monitor new scans, Prevent/Fix, Prove, Control Plane, +erasure, compliance, certification, and assurance surfaces are not implemented. ## License diff --git a/README.zh-TW.md b/README.zh-TW.md index be96d1d..2960588 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -9,11 +9,12 @@ [English](README.md) | **繁體中文** RAGLeakGuard 是早期開發中的靜態資料敏感資訊診斷安全專案。儲存庫仍包含偵測、風險政策、 -監控狀態及已驗證 webhook 元件,但**目前沒有任何可用的來源掃描連接器**。 +監控狀態及已驗證 webhook 元件。目前已實作一個有界限、僅回傳聚合結果的 Chroma 操作員快照 +連接器;直接/即時來源 store 掃描仍停用。 ## Chroma 安全通知 -直接掃描本機 Chroma 已停用。可執行的端點證據證實:ChromaDB 1.5.0 與 1.5.9 在建立 +直接/即時 Chroma 掃描仍停用。可執行的端點證據證實:ChromaDB 1.5.0 與 1.5.9 在建立 client 或讀取時,可能修改持久化 store 檔案。其他 Chroma 版本尚未建立可接受的唯讀邊界。 這是確切的測試範圍,不代表所有 Chroma 版本都已測試。 @@ -21,40 +22,54 @@ client 或讀取時,可能修改持久化 store 檔案。其他 Chroma 版本 並未完成。WP7B 的私有、有界限 operator-snapshot confinement 基礎已在確切 implementation head `128decb3e0d78825e884f6dce019898b568c6ba2` 通過獨立審查,並透過 [PR #20](https://github.com/Agenvana/RAGLeakGuard/pull/20) 以 merge commit -`5db765689d35eec8ba918f0f616d5fea34e56955` 合併。這項基礎只限制由 operator 另行建立的完整 -filesystem snapshot;它不會 import 或建立 Chroma,也沒有公開 function、CLI、connector、 -report、monitor 或 webhook 路徑。 +`5db765689d35eec8ba918f0f616d5fea34e56955` 合併。WP7D 現在只透過該私有 work copy 使用 +WP7C 的雙次列舉,並在隔離 worker 內執行偵測。公開啟用僅限 ChromaDB 1.5.9:Linux/ext4 +Python 3.10–3.12、macOS 15/APFS Python 3.12,以及 Windows/NTFS Python 3.12。 +ChromaDB 1.5.0 僅保留為私有證據,公開路徑會拒絕它。 -以 snapshot 為基礎的公開掃描目前未實作且不可用,仍需另行進行可行性、安全、啟用與確切 -commit 審查。直接存取 Chroma 仍維持停用。本專案不宣稱 Chroma 支援版本範圍、未來可用性、 -連接器完整性、來源唯讀或正式環境安全性。 +操作員(不是 RAGLeakGuard)必須先建立完整且靜止的 full-filesystem snapshot。 +RAGLeakGuard 不會證明快照的來源、靜止狀態、完整性或原子一致性。快照仍是可能惡意且敏感的 +輸入。本專案不宣稱一般 Chroma 支援、即時來源唯讀、連接器完整性或正式環境安全性。 PyPI `0.1.0` 套件包含不安全的直接 Chroma 路徑,**不得用於 Chroma 掃描**。撤下該套件 與發布修正版都需要人類維護者另行授權;此儲存庫變更沒有執行這些動作。 ## 目前命令行為 -語法有效的 `scan --source chroma` 會以 exit code 6 和固定訊息停止;停止發生在 Chroma -import、偵測器初始化、來源存取、報告處理及成功輸出之前。`read_chroma()` 不會檢查傳入 -物件,呼叫時會立即同步拋出公開的 `ChromaConnectorUnavailableError`。 +`scan --source chroma` 只接受 `--snapshot`、`--work-parent`、窄範圍的假名 +`--source-id`、明確的 `--acknowledge-offline-complete-snapshot`、選用 `--locale` 與 +`--report`。舊的 `--path` 會在來源存取前被拒絕。只有在有界限複製、雙次列舉、完整偵測、 +聚合計數完全相等、worker 終止、最終 capability 驗證、cleanup 與原子報告完成後才會輸出成功。 +`read_chroma()` 仍不會檢查傳入物件,並立即同步拋出 `ChromaConnectorUnavailableError`。 `monitor` 會先驗證 key 與 state。若已驗證的狀態含有 WP6 pending alert,既有的設定、 backoff、retry、transport、ambiguous-delivery 及原子清除復原流程會在不開始新掃描的情況下 執行。若沒有 pending alert,且原本將開始新掃描,`monitor` 會以 exit code 6 停止,不修改 狀態,也不建立報告、alert 或 webhook。 -一般缺少選項、不支援的來源、格式錯誤或不支援的 locale 仍以 exit code 2 結束。監控 key/ -state 錯誤維持 exit code 4;pending alert 與 webhook 錯誤維持 exit code 5。 +一般缺少選項、不支援的來源、格式錯誤或不支援的 locale 仍以 exit code 2 結束;掃描/報告 +不確定性使用 exit code 1,偵測 runtime 問題使用 exit code 3,候選版本或啟用環境不可用使用 +exit code 6。監控 key/state 錯誤維持 exit code 4;pending alert 與 webhook 錯誤維持 exit code 5。 -```text -Local Chroma scanning is disabled because executable endpoint evidence proved that ChromaDB 1.5.0 and 1.5.9 may modify durable store files during client construction or reads, while other versions have not established an acceptable read-only boundary. No report, monitor state, or webhook was created or replaced. +只可對另行建立的離線操作員快照執行: + +```bash +python -m pip install ".[chroma-snapshot,detect]" +python -m spacy download en_core_web_sm +ragleakguard scan --source chroma \ + --snapshot /private/offline-snapshot \ + --work-parent /private/ragleakguard-work \ + --source-id source-1 \ + --acknowledge-offline-complete-snapshot \ + --report /private/reports/source-1.md ``` -直接存取停用期間,文件刻意不提供可執行的 Chroma scan 或 monitor quickstart。 +上述路徑只是 placeholder,不會出現在一般 console 輸出或報告中。monitor 新掃描仍不可用, +所以文件不提供 Chroma monitor quickstart。 ## 開發環境 -Chroma runtime extra 已移除。以下只安裝套件、偵測堆疊與測試工具,不會提供來源掃描連接器。 +Chroma 不在 base dependency 中。以下安裝精確的 snapshot 候選版本、偵測堆疊與測試工具。 ```bash git clone https://github.com/Agenvana/RAGLeakGuard.git @@ -62,7 +77,7 @@ cd RAGLeakGuard python -m venv .venv # 依作業系統啟用環境。 python -m pip install --upgrade pip -python -m pip install -e ".[detect,dev]" +python -m pip install -e ".[chroma-snapshot,detect,dev]" python -m spacy download en_core_web_sm python -m pytest -q ``` @@ -75,9 +90,8 @@ python -m pytest -q - **預設函式庫設定:**全域與美國 Presidio recognizer。 - **在地包(`--locale`):**`au` 是目前唯一已實作、可選用的國家在地包。 -偵測是 best-effort。偵測函式庫的結果不能證明資料安全、合規或不含敏感資訊。缺少模型時, -Presidio 可能在初始化期間嘗試取得模型;runtime 下載控制與精確模型鎖定仍是待加強事項。 -停用的新掃描 CLI 路徑不會初始化此 runtime。 +偵測是 best-effort。偵測函式庫的結果不能證明資料安全、合規或不含敏感資訊。必要的 spaCy +model 必須預先安裝;隔離 worker 會拒絕 model 下載、網路 egress 與額外 process。 ## Monitor 復原 @@ -107,9 +121,9 @@ preparation、transport 或 response 失敗以 exit 5 結束。Slack 與 Discord ## 路線圖與非保證事項 -詳見 [ROADMAP.md](ROADMAP.md)。以 snapshot 為基礎的公開掃描、規劃中的連接器、Prevent/Fix、 -Prove、Control Plane、刪除證明、合規、認證及 assurance 介面都尚未實作。已完成的私有 -WP7B confinement 基礎並不是 connector。 +詳見 [ROADMAP.md](ROADMAP.md)。上述有限的操作員快照 connector 已實作。其他 connector、 +直接/即時掃描、monitor 新掃描、Prevent/Fix、Prove、Control Plane、刪除證明、合規、認證 +及 assurance 介面都尚未實作。 ## 授權條款 diff --git a/ROADMAP.md b/ROADMAP.md index a07229e..49bb74f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,10 +18,11 @@ Early development (the **Diagnose** stage). Checkboxes are intent, not commitmen - [ ] **MRN / record-identifier recogniser** (from Report #1 benchmark, 2026-07-04): `MRN-482913`-style chart numbers are invisible to every engine tested (0–40% recall, and then only via mislabels). Context-gated pattern (`MRN-`, "chart", "record"). Directly feeds the Sep clinic/voice-agent report. ## Connectors -No source-scanning connector is currently available. +A bounded aggregate-only operator-snapshot Chroma connector is implemented in source. Direct/live +source-store access remains disabled. - [x] **WP7B private bounded operator-snapshot confinement foundation** — the private filesystem lifecycle passed independent review at exact implementation head `128decb3e0d78825e884f6dce019898b568c6ba2` and was merged through [PR #20](https://github.com/Agenvana/RAGLeakGuard/pull/20) as merge commit `5db765689d35eec8ba918f0f616d5fea34e56955`. It confines a complete snapshot created separately by the operator. It does not import or construct Chroma, expose a public scanning surface, or prove source quiescence, provenance, completeness, or atomic multi-file consistency. -- [ ] **Snapshot-backed public Chroma scanning** — unavailable and not implemented. Any activation requires a separate issue, feasibility and security evidence, exact-commit independent review, and human authorization; future availability is not a commitment. Direct local Chroma access remains disabled after executable endpoint evidence established durable mutation for ChromaDB 1.5.0 and 1.5.9. Other versions have not established an acceptable read-only boundary. [Issue #15](https://github.com/Agenvana/RAGLeakGuard/issues/15) was deferred, not completed. +- [x] **WP7D bounded operator-snapshot Chroma activation** — exact ChromaDB 1.5.9 only, with five native-filesystem/Python cells: Linux/ext4 3.10–3.12, macOS 15/APFS 3.12, and Windows/NTFS 3.12. It runs detection inside the isolated WP7C worker and returns bounded counters and entity-type counts only after two-pass equality, termination, revalidation, cleanup, and atomic aggregate-report finalization. The operator must create a complete quiescent/full-filesystem snapshot separately; RAGLeakGuard does not prove provenance, quiescence, completeness, or atomic consistency. Direct/live access and monitor new scans remain disabled. ChromaDB 1.5.0 remains private evidence only. [Issue #15](https://github.com/Agenvana/RAGLeakGuard/issues/15) was deferred, not completed. - [ ] Pinecone - [ ] pgvector (Postgres) - [ ] Qdrant, Weaviate diff --git a/SECURITY.md b/SECURITY.md index 4289a7e..cb2745a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,7 +14,9 @@ RAGLeakGuard is an early-development security scanner. Detection is best-effort, This is a pre-1.0 project. Compatibility and the supported runtime matrix may narrow as evidence improves. CI now exercises the WP7B filesystem evidence on ext4/Python 3.9, APFS/Python 3.9, NTFS/Python 3.9, and NTFS/Python 3.12. That finite evidence matrix is not a documented supported -release matrix; see the [release process](docs/RELEASE_PROCESS.md). +release matrix. WP7D separately activates exact ChromaDB 1.5.9 only on Linux/ext4 Python +3.10–3.12, macOS 15/APFS Python 3.12, and Windows/NTFS Python 3.12; see the +[release process](docs/RELEASE_PROCESS.md). ## Reporting a vulnerability @@ -50,12 +52,16 @@ independent review at exact implementation head `5db765689d35eec8ba918f0f616d5fea34e56955`. It accepts a complete filesystem snapshot created separately by the operator, copies it under hard bounds into a restrictive owned workspace, checks observed source/copy stability, and authenticates ownership and lease controls for cleanup and -recovery. It is private and has no Chroma or public scanning consumer. - -Every direct local Chroma new-scan path still fails closed, and no source-scanning connector is -available. `read_chroma()` fails synchronously without inspecting its argument, importing Chroma, -touching the filesystem, initializing detection, or constructing a client. Valid disabled CLI paths -exit 6 without creating or replacing a report, state, temporary artifact, new alert, or webhook. +recovery. WP7D implements one aggregate-only consumer for exact ChromaDB 1.5.9 on the finite +activation matrix. Detection runs inside the isolated WP7C worker; raw source fields never cross +IPC, and completion is returned only after connector/detector equality, termination, revalidation, +cleanup, and atomic aggregate-report finalization. + +Every direct/live local Chroma new-scan path remains disabled and fails closed. `read_chroma()` fails synchronously +without inspecting its arguments, importing Chroma, touching the filesystem, initializing +detection, or constructing a client. Legacy CLI `--path` requests exit 2 before source access. +Monitor new scans remain unavailable and cannot create a report, state transition, new alert, or +webhook. Presidio/spaCy detection, the opt-in Australian locale pack, versioned aggregate risk-policy/report helpers, explicit monitor keys, authenticated privacy-minimal version-3 state, and the reviewed protocol-v2 one-entry outbox remain in the repository. An existing pending alert retains precedence @@ -63,8 +69,8 @@ and may perform the established retry transition or approved atomic clear after without a new source scan. See the [architecture](docs/ARCHITECTURE.md), [monitor state contract](docs/MONITOR_STATE.md), and [webhook protocol](docs/WEBHOOK_PROTOCOL.md). -**Known limitations:** the private WP7B foundation relies on the operator to provide a complete, -quiescent filesystem snapshot; its observations do not prove provenance, completeness, source +**Known limitations:** the operator—not RAGLeakGuard—must provide a complete, quiescent/full-filesystem +snapshot; the implementation's observations do not prove provenance, completeness, source quiescence, or transactionally atomic multi-file consistency. Work-copy data remains visible to the running account and administrators until cleanup, cleanup is not certified erasure, and crashes or ambiguous ownership can leave residue for manual investigation. ChromaDB 1.5.0 and 1.5.9 showed @@ -77,11 +83,9 @@ only acceptable response headers. Detector completeness, exactly-once delivery, at-least-once delivery, downstream processing, human notification, and historical v1/v2 alert recovery are not proved. -**Planned or under review:** snapshot-backed public Chroma scanning, outbox -administration/multiple destinations, and reproducible release provenance. Public snapshot-backed -scanning is unavailable and not implemented; the completed private confinement foundation is not a -connector, and no activation or future-support commitment is made. Direct Chroma access remains -disabled. +**Planned or under review:** outbox administration/multiple destinations and reproducible release +provenance. General Chroma support, other versions or environments, direct/live scanning, and +monitor new scans are not implemented. No expansion or future-support commitment is made. The Prevent/Fix layer, erasure proof, Control Plane, multi-tenancy, vault/KMS, compliance certification, and assurance profile are not implemented and are outside the current supported surface. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c363703..1dac0f0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,29 +3,33 @@ **Baseline:** WP7B's private bounded operator-snapshot confinement foundation passed independent review at exact implementation head `128decb3e0d78825e884f6dce019898b568c6ba2` and was merged through [PR #20](https://github.com/Agenvana/RAGLeakGuard/pull/20) as merge commit -`5db765689d35eec8ba918f0f616d5fea34e56955` on 2026-08-13. This post-review documentation -baseline starts from that merge. This is an alpha architecture description, not a stability, +`5db765689d35eec8ba918f0f616d5fea34e56955` on 2026-08-13. WP7D adds a bounded public consumer +for exact ChromaDB 1.5.9 and requires independent review of its immutable head. This is an alpha architecture description, not a stability, production-readiness, connector-completeness, or compliance guarantee. ## Implemented now -RAGLeakGuard is a local Python package and CLI. No source-scanning connector is currently -available. The exposed Chroma entry points are explicit fail-closed boundaries: +RAGLeakGuard is a local Python package and CLI. One aggregate-only Chroma connector accepts a +complete offline snapshot created separately by the operator. Direct/live Chroma and monitor new +scans remain explicit fail-closed boundaries: ```mermaid flowchart LR - A["scan: valid Chroma request"] --> B["Static exit 6"] - C["monitor: authenticated state"] --> D{"Pending alert?"} - D -->|"No"| B - D -->|"Yes"| E["WP6 recovery/delivery"] - E --> F["Approved atomic outbox clear after accepted delivery"] - G["read_chroma(object)"] --> H["Synchronous static exception"] + A["scan: operator snapshot request"] --> B["Pre-source gates"] + B --> C["WP7B copy + WP7C two-pass detection worker"] + C --> D["Equality + termination + revalidation + cleanup"] + D --> E["Atomic aggregate report, then success"] + F["monitor: authenticated state"] --> G{"Pending alert?"} + G -->|"No"| H["Static exit 6; no new scan"] + G -->|"Yes"| I["WP6 recovery/delivery"] + I --> J["Approved atomic outbox clear after accepted delivery"] + K["read_chroma(object)"] --> L["Synchronous static exception"] ``` Detection, risk-policy, report construction, authenticated monitor-state, and protocol-v2 webhook modules remain importable. A separately versioned stable Python SDK contract is not defined. -### Disabled Chroma boundary +### Direct/live Chroma boundary [connectors.py](../src/ragleakguard/connectors.py) defines the public `ChromaConnectorUnavailableError`, the one static disabled message, and a non-generator @@ -33,9 +37,10 @@ modules remain importable. A separately versioned stable Python SDK contract is inspection, filesystem access, detector initialization, or client construction. It returns no iterator or scan session. -For `scan`, Typer performs ordinary option parsing, and RAGLeakGuard preserves source/path and locale -usage validation. A valid Chroma new-scan request then exits 6 before detection, source access, -report construction or replacement, and success output. +For `scan`, legacy `--path` is rejected before source access. The only active route requires +`--snapshot`, `--work-parent`, a narrow pseudonymous `--source-id`, and explicit offline/complete +acknowledgement. Locale syntax, detector runtime, exact ChromaDB 1.5.9, and platform/Python gates +also precede source access. The filesystem gate is then revalidated on the WP7B work copy. For `monitor`, source/path and locale usage validation is followed by existing webhook configuration, monitor-key, state, and scope authentication. A valid pending alert retains WP6 precedence and is @@ -44,7 +49,8 @@ Chroma import, source access, scan-derived state changes, pending-alert creation preparation, or success output. This preserves existing report and state bytes on disabled paths, leaves absent artifacts absent, -and creates no temporary artifact. The Chroma runtime dependency is not present in package metadata. +and creates no temporary artifact. Chroma remains outside base dependencies and is available only +through the exact `chroma-snapshot = ["chromadb==1.5.9"]` optional extra. ### Evidence and decision boundary @@ -54,9 +60,9 @@ validation failure, and successful reads could change hashes of opaque durable s versions have not established an acceptable read-only boundary. [Issue #15](https://github.com/Agenvana/RAGLeakGuard/issues/15) was deferred as `not planned`; it -was not completed. Snapshot-backed public scanning is unavailable and not implemented. Its -feasibility, security, and any activation require work separate from the completed private WP7B -foundation. No supported Chroma range or future activation is implied. +was not completed. WP7D activates only exact 1.5.9 on Linux/ext4 Python 3.10–3.12, macOS 15/APFS +Python 3.12, and Windows/NTFS Python 3.12. ChromaDB 1.5.0 remains private WP7C evidence and is +publicly rejected. No broader supported range or future activation is implied. ### Private snapshot-confinement foundation @@ -92,10 +98,9 @@ only when its ownership documents authenticate and its exclusive lease can be ac errors and redacted representations omit source/work paths, file names, contents, and underlying exception text. -These primitives have no public export, CLI option, connector hook, package extra, report path, -monitor transition, or webhook behavior. `read_chroma()`, `scan`, and new-scan `monitor` remain at -the WP7A disabled boundary. A separate issue, evidence set, exact-commit independent review, and -human authorization are required before any public or detector consumer may use WP7B. +These primitives have no public export. WP7D uses them internally for the active operator-snapshot +connector; `read_chroma()` and new-scan `monitor` remain at the direct/live disabled boundary. +No caller receives the work-copy capability or path. ### Private Chroma candidate enumerator @@ -115,9 +120,9 @@ derives a lease-scoped key from the WP7B authentication key with the identity, canonical content, and collision witnesses. Neither derived evidence nor session material is persisted, logged, returned, or sent through IPC. Python cannot guarantee immediate zeroization. -The only evaluation candidates are exact ChromaDB 1.5.0 and 1.5.9 on the explicitly tested private -matrix. This is not a package dependency, supported-version range, connector-availability claim, or -public compatibility promise. Complete migration manifests, explicit local settings, read-only +The private evaluation candidates remain exact ChromaDB 1.5.0 and 1.5.9 on the ten-cell WP7C +matrix. Only 1.5.9 is publicly activated through WP7D's narrower five-cell matrix. Complete +migration manifests, explicit local settings, read-only SQLite preflight, deterministic metadata framing, keyed in-run consistency tokens, pagination, deadlines, IPC ceilings, environment sanitization, egress denial, and post-exit effect classification all fail closed. Known dependency writes may occur only in the disposable copy; @@ -131,9 +136,19 @@ record-queue, metadata, full-text, sequence, configuration, and maintenance evid before and after. This describes containment and classification inside the disposable copy, not source immutability or public compatibility. -The opaque result contains four bounded counters only. It contains no source rows and is not -detector completion. No detector, report, state, webhook, CLI success, public connector, package -extra, or release path consumes it. That activation boundary belongs to separately reviewed WP7D. +The original private WP7C result remains an opaque four-counter receipt. WP7D extends the same +isolated worker with first-pass-only detection for canonical document and metadata segments; the +second pass repeats keyed identity/content/completeness verification without duplicating findings. +Raw documents, metadata, collection names, record IDs, detected values, and paths never cross IPC. +The public result contains only bounded connector counters plus records/segments/UTF-8 bytes, +records with findings, total findings, and validated entity-type counts. Exact counter equality is +required before completion. + +WP7D narrows the inherited WP7B/WP7C ceilings to 1,000 collections, 10,000 records, 100,000 +canonical detector segments, 268,435,456 detector UTF-8 bytes, 65,536 bytes per segment, 4,096 +findings per segment, 1,000,000 total findings, and 64 distinct entity types. Detector aggregate IPC +is at most 16,384 bytes; the final report is at most 1,048,576 bytes; report finalization is bounded +to 30 seconds; the existing worker maximum remains 1,200 seconds; and automatic retries are zero. ### Detection and risk reports @@ -143,9 +158,11 @@ detected text in process, so importing callers are responsible for protecting it do not initialize detection. [risk_policy.py](../src/ragleakguard/risk_policy.py) implements `RLG-ID-RISK@1.0.0`. -[report.py](../src/ragleakguard/report.py) can build deterministic aggregate Markdown reports, but -disabled new-scan CLI paths never invoke or write one. Historical reports remain unversioned when -they lack explicit policy attribution. +[report.py](../src/ragleakguard/report.py) builds deterministic aggregate Markdown reports. WP7D +records only `chroma-snapshot` and the escaped pseudonymous source ID, then uses a restrictive +same-directory temporary file, bounded write, file `fsync`, atomic replacement, and directory +durability where supported. Success follows report finalization. Historical reports remain +unversioned when they lack explicit policy attribution. ### Monitor state and pending-alert recovery @@ -161,21 +178,21 @@ duplicate. Recovery does not access the source or construct any new scan result. ### CLI exits -- `0`: credential generation succeeded, or an existing pending alert was accepted and durably - cleared without a scan. -- `2`: ordinary CLI source/path/locale or option-pair usage failure. +- `0`: a WP7D aggregate report was finalized, credential generation succeeded, or an existing + pending alert was accepted and durably cleared without a scan. +- `1`: snapshot scan, cleanup, or aggregate-report finalization failed. +- `2`: ordinary CLI source/path/locale, acknowledgement, source-ID, or option-pair usage failure. +- `3`: the detection runtime is unavailable. - `4`: monitor key/state, retry-metadata, or accepted-but-not-cleared failure. - `5`: pending configuration/backoff/retry or webhook configuration/preparation/transport/response failure. -- `6`: a valid direct local Chroma new-scan path was reached and disabled. - -Exit codes 1 and 3 remain reserved by historical behavior but are not emitted by disabled new-scan -paths because no scan or detector initialization begins. +- `6`: monitor reached the disabled new-scan boundary, or the exact snapshot candidate/activation + environment is unavailable. ## Trust boundaries -- A supplied source object/path is sensitive input and must not be inspected on the library disabled - path or accessed as a filesystem path on CLI disabled new-scan paths. +- A supplied snapshot/work/report path is sensitive input and must not appear in ordinary output, + reports, IPC, state, webhooks, or static failures. The direct library path remains argument-opaque. - Existing report and monitor-state files are sensitive local artifacts and must remain byte-identical when a new scan is disabled. - Monitor keys and webhook secrets remain explicit local secret inputs. Their storage, backup, @@ -186,12 +203,13 @@ paths because no scan or detector initialization begins. boundaries. - The operator who creates a source snapshot, the local account and administrators that can alter it, filesystem semantics, free-space accounting, and process/power-loss behavior are boundaries - for the private WP7B lifecycle. + for the WP7B/WP7D lifecycle. The operator—not RAGLeakGuard—must create a complete, + quiescent/full-filesystem snapshot; the lifecycle does not prove provenance, completeness, + quiescence, or atomic multi-file consistency. ## Planned or unavailable -Snapshot-backed public Chroma scanning remains unavailable and requires separate feasibility, -security, activation, and exact-commit review; the private WP7B lifecycle is not a connector. -Direct Chroma access remains disabled. Additional connectors, Prevent/Fix, Prove, Control Plane, +General Chroma support, other dependency/platform tuples, direct/live Chroma access, and monitor new +scans remain unavailable. Additional connectors, Prevent/Fix, Prove, Control Plane, certification, and hosted services are not implemented. PyPI 0.1.0 contains the unsafe direct path and must not be used for Chroma scanning. diff --git a/docs/MONITOR_STATE.md b/docs/MONITOR_STATE.md index 51b3f69..0b1de83 100644 --- a/docs/MONITOR_STATE.md +++ b/docs/MONITOR_STATE.md @@ -1,6 +1,6 @@ # Monitor key, state, and durable outbox contract -This document describes the implemented version-3 local monitor checkpoint and its one-entry authenticated webhook outbox. Direct local Chroma new scans are disabled and no source-scanning connector is currently available. A completed pending-alert recovery transition is not proof of detector or connector completeness, production safety, compliance, webhook delivery under every failure, downstream processing, or human notification. +This document describes the implemented version-3 local monitor checkpoint and its one-entry authenticated webhook outbox. Direct/live Chroma and monitor new scans are disabled. The separate exact-1.5.9 operator-snapshot CLI requires the operator to create a complete, quiescent/full-filesystem snapshot; monitor does not use it. A completed pending-alert recovery transition is not proof of detector or connector completeness, production safety, compliance, webhook delivery under every failure, downstream processing, or human notification. ## Operator workflow @@ -136,10 +136,11 @@ Exit behavior is: ## Residual risks and non-claims -- No source-scanning connector is available. ChromaDB 1.5.0 and 1.5.9 exhibited durable mutation; - other versions have not established an acceptable read-only boundary. WP7B's completed private - operator-snapshot confinement foundation is not used by monitor. Snapshot-backed public scanning - remains unavailable and not implemented, and direct Chroma access remains disabled. +- ChromaDB 1.5.0 and 1.5.9 exhibited durable mutation; other versions have not established an + acceptable read-only boundary. The bounded operator-snapshot connector activates exact 1.5.9 on + its finite matrix, but is not used by monitor. RAGLeakGuard does not prove snapshot provenance, + quiescence, completeness, or atomic consistency. Direct/live access and monitor new scans remain + disabled. - Exact path spelling binds scope. Key compromise, insecure backup, rollback to an older valid state, overlapping writers, local runtime compromise, Windows DACL configuration, and host filesystem behavior remain external risks. - One pending alert and one destination are supported. Receiver outage intentionally blocks all newer scans, potentially forever. - A crash during or after network transmission can be ambiguous. A clear failure can cause duplicate delivery. diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index 59c990c..e3ecd07 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -4,8 +4,8 @@ RAGLeakGuard releases require explicit human approval. CI, an agent, or a merged ## Current baseline -Repository and release-system facts verified for the WP7B post-review baseline starting from merge -commit `5db765689d35eec8ba918f0f616d5fea34e56955`: +Repository and release-system facts carried forward from the WP7B review record and updated for the +unreleased WP7D source implementation: - PyPI reports `ragleakguard` version `0.1.0` and Python `>=3.9`. - `pyproject.toml` declares version `0.1.0`, while `ragleakguard.__version__` is `0.0.1`. This mismatch must be resolved before the next release; this documentation-only change does not alter either value. @@ -13,6 +13,9 @@ commit `5db765689d35eec8ba918f0f616d5fea34e56955`: - CI runs the suite for pull requests and pushes to `main` on ext4/Python 3.9, APFS/Python 3.9, NTFS/Python 3.9, and NTFS/Python 3.12. This is the finite WP7B evidence matrix, not a documented supported release matrix. +- WP7D adds five mandatory exact ChromaDB 1.5.9 cells: Linux/ext4 Python 3.10–3.12, macOS 15/APFS + Python 3.12, and Windows/NTFS Python 3.12. These are source-commit activation evidence, not a + published package support claim. - CI installs ranged dependencies and downloads `en_core_web_sm`; inputs are not fully locked. - There is no package-build, artifact-install, provenance, checksum, secret/dependency-scan, or PyPI publication workflow in this repository. @@ -28,8 +31,10 @@ WP7B review and merge record: implementation head `128decb3e0d78825e884f6dce019898b568c6ba2` and was merged through [PR #20](https://github.com/Agenvana/RAGLeakGuard/pull/20) as merge commit `5db765689d35eec8ba918f0f616d5fea34e56955`. -- That private foundation is not a connector. Snapshot-backed public scanning remains unavailable - and not implemented, direct Chroma access remains disabled, and no release has been published. +- WP7D adds a bounded aggregate-only consumer of that foundation for complete operator-created + offline snapshots. Direct/live Chroma and monitor new scans remain disabled. The operator must + create a complete, quiescent/full-filesystem snapshot; RAGLeakGuard does not prove provenance, + quiescence, completeness, or atomic consistency. No corrective release has been published. Do not describe the current workflow as a reproducible release pipeline. @@ -56,10 +61,9 @@ All applicable gates must pass on the exact release commit. - Reconcile README, roadmap, architecture, threat model, security policy, CLI help, and package metadata with implemented behavior. - Re-check every present-tense security/compliance/production claim against evidence. - Confirm that planned connectors, locales, Prevent/Fix, Prove, Control Plane, certification, and assurance behavior are labeled planned. -- Confirm that no source-scanning connector is advertised; the completed private WP7B confinement - foundation is distinguished from unavailable snapshot-backed public scanning; direct Chroma - access remains disabled; and no Chroma supported-version or guaranteed-future-support claim - appears. +- Confirm that only the finite exact-1.5.9 operator-snapshot connector is advertised; direct/live + Chroma and monitor new scans remain disabled; operator snapshot duties and non-proofs are explicit; + and no broader Chroma range or guaranteed-future-support claim appears. - Review logs, errors, reports, state, webhooks, fixtures, and built artifacts for secrets, PII canaries, paths, and tenant/record identifiers. - Complete coordinated disclosure for any vulnerability that should not be exposed by release notes. @@ -68,7 +72,8 @@ All applicable gates must pass on the exact release commit. - Use one authoritative version and assert equality across package metadata, runtime `__version__`, built wheel/sdist metadata, CLI output if exposed, tag, and release notes. - Document schema/state and CLI-exit migrations. Provide an explicit upgrade path or fail closed on incompatible persisted state. - Define the finite supported Python/platform matrix before release. The current WP7B CI jobs are - evidence for their exact Python/filesystem environments only, not a supported release matrix. + evidence for their exact Python/filesystem environments only. The five WP7D cells likewise prove + only the immutable source head and are not a published release matrix. ### 4. Tests and documentation @@ -89,7 +94,11 @@ python -m twine check dist/* - Build wheel and sdist once from the reviewed commit in a clean environment. - Record builder OS/architecture, Python version, build frontend/backend versions, lock/material inputs, source SHA, and timestamp. - Inspect both archives for unexpected files, secrets, stores, state, reports, credentials, or private material. -- Install each artifact into a fresh environment without Chroma and exercise imports, CLI help, synchronous `read_chroma()` failure, disabled scan/monitor exit 6, and pending-alert recovery without repository-relative imports. +- Install each artifact into a fresh environment without Chroma and exercise imports, CLI help, + synchronous `read_chroma()` failure, legacy direct-path rejection, disabled monitor exit 6, and + pending-alert recovery without repository-relative imports. Separately install the exact + `chroma-snapshot` and detection extras on every claimed activation tuple and exercise a synthetic + complete operator snapshot, cleanup, and aggregate report finalization. - Generate SHA-256 checksums and provenance for the final artifacts. Do not rebuild after approval; publish the reviewed bytes. ### 6. Supply chain @@ -115,14 +124,14 @@ Python package indexes are append-only: never replace files for an existing vers 5. Add an advisory/release note, upgrade guidance, affected-version range, and evidence after coordinated disclosure approval. 6. Review whether reports or public claims relied on the affected behavior. Amend them separately; never rewrite released artifacts. -For the WP7A corrective release, the release note must say that direct local Chroma scanning is -disabled; no source-scanning connector is currently available; Issue #15 was deferred, not -completed; ChromaDB 1.5.0 and 1.5.9 exhibited durable mutation; other versions have not established -an acceptable read-only boundary; the completed private WP7B confinement foundation is not a -connector; snapshot-backed public scanning is unavailable and not implemented; direct Chroma -access remains disabled; and PyPI 0.1.0 must not be used for Chroma scanning. This repository has no unreleased release-note -mechanism, so do not invent a version or changelog file; carry the exact proposed wording in the -reviewed pull request until a human authorizes release preparation. +For any future corrective release, the release note must say that direct/live Chroma and monitor new +scans are disabled; Issue #15 was deferred, not completed; ChromaDB 1.5.0 and 1.5.9 exhibited durable +mutation; only exact 1.5.9 operator snapshots on the five reviewed tuples are activated; the +operator must create a complete quiescent/full-filesystem snapshot; provenance, quiescence, +completeness, and atomic consistency are not proved; detection is best-effort; and PyPI 0.1.0 must +not be used for Chroma scanning. This repository has no unreleased release-note mechanism, so do not +invent a version or changelog file; carry proposed wording in the reviewed pull request until a +human authorizes release preparation. ## Planned automation diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index bf2d769..0939686 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -3,9 +3,9 @@ **Baseline:** WP7B's private bounded operator-snapshot confinement foundation passed independent review at exact implementation head `128decb3e0d78825e884f6dce019898b568c6ba2` and was merged through [PR #20](https://github.com/Agenvana/RAGLeakGuard/pull/20) as merge commit -`5db765689d35eec8ba918f0f616d5fea34e56955` on 2026-08-13. This post-review documentation -baseline starts from that merge. RAGLeakGuard is an alpha security project. -No source-scanning connector is currently available. +`5db765689d35eec8ba918f0f616d5fea34e56955` on 2026-08-13. WP7D adds a bounded aggregate-only +operator-snapshot consumer for exact ChromaDB 1.5.9. RAGLeakGuard is an alpha security project; +direct/live source-store access remains disabled. ## Scope @@ -16,6 +16,8 @@ In scope: filesystem snapshot; - private exact-candidate Chroma migration validation, bounded two-pass enumeration, worker isolation/teardown, effect classification, and counter receipt inside a held work copy; +- public first-pass-only detection, aggregate equality, cleanup-gated completion, and atomic report + finalization for the five-cell exact 1.5.9 operator-snapshot matrix; - `scan` and `monitor` CLI validation and precedence; - preservation of reports, authenticated monitor state, and the WP6 pending-alert outbox; - detection, risk-policy, report, console, package, documentation, and release claim boundaries; @@ -23,8 +25,8 @@ In scope: Out of scope because it is unavailable or not implemented: -- direct Chroma scanning, snapshot-backed public scanning, detector consumption, or any public use - of the private WP7B/WP7C lifecycle; +- direct/live Chroma scanning, monitor new scans, and Chroma versions or environment tuples outside + the finite WP7D matrix; - every other source connector; - public connector pagination, metadata expansion, detector completion, and version support; - Prevent/Fix, erasure proof, Control Plane, certification, or compliance guarantees. @@ -35,8 +37,9 @@ Executable endpoint tests established durable store mutation for ChromaDB 1.5.0 client construction or reads. Other Chroma versions have not established an acceptable read-only boundary. Therefore, direct local Chroma entry points fail closed without importing Chroma or accessing the source. [Issue #15](https://github.com/Agenvana/RAGLeakGuard/issues/15) was deferred, -not completed. Snapshot-backed public scanning remains unavailable and not implemented; its review -and activation are separate from the completed private WP7B confinement foundation. +not completed. Snapshot-backed scanning is activated only for exact 1.5.9 on Linux/ext4 Python +3.10–3.12, macOS 15/APFS Python 3.12, and Windows/NTFS Python 3.12. ChromaDB 1.5.0 remains private +evidence and is rejected publicly. PyPI 0.1.0 contains the unsafe direct path and must not be used for Chroma scanning. @@ -44,17 +47,18 @@ PyPI 0.1.0 contains the unsafe direct path and must not be used for Chroma scann | Asset | Security objective | |---|---| -| Supplied source object/path | Do not inspect it in `read_chroma()` and do not access it as a filesystem path on disabled CLI new-scan paths. | +| Supplied source object/path | Do not inspect it in `read_chroma()`; reject legacy CLI `--path` before source access. | | Operator production store | Never pass it to Chroma; direct paths fail before import, construction, filesystem access, embeddings, or network access. | | Existing report/state | Preserve bytes exactly; leave absent artifacts absent; create no temporary artifact. | -| Operator-facing result | Exit 6 with one static message; emit no clean, scan, baseline, report, change, or delivery success signal. | +| Operator-facing result | Return only bounded connector counters and detector aggregates after every completion gate; otherwise emit no clean, scan, baseline, report, change, or delivery success signal. | | Monitor key and authenticated state | Preserve validation precedence, static failures, checkpoint integrity, and scope binding. | | Existing pending alert | Recover without a new scan; only an accepted delivery may authorize the existing atomic clear transition. | -| Package and public claims | Do not install or advertise a Chroma runtime connector or claim read-only, supported-version, snapshot, completeness, or production safety. | +| Package and public claims | Keep Chroma optional and exact; describe only the finite operator-snapshot matrix and never claim live read-only, snapshot completeness, or production safety. | | Operator-provided snapshot | Treat it as hostile and privacy-sensitive; never claim that WP7B proves its quiescence, completeness, provenance, or atomic multi-file consistency. | | RAGLeakGuard work copy | Bound files, directories, depth, bytes, chunks, time, and free-space preflight; use restrictive permissions and do not return an incomplete copy. | | Ownership controls and lease | Authenticate privacy-minimal control documents, hold an exclusive native lock while the copy is usable, and clean only positively proved direct descendants. | | Private enumerator | Accept only the live held capability; enumerate completely within hard limits; expose counters only after child exit, semantic/effect agreement, and final capability validation. | +| Public detector aggregate | Permit only record/segment/UTF-8-byte completion, records with findings, total findings, and validated entity-type counts; require connector equality. | ## Actors and assumptions @@ -70,23 +74,23 @@ PyPI 0.1.0 contains the unsafe direct path and must not be used for Chroma scann | Threat | Current control | Residual risk / limitation | |---|---|---| -| Chroma mutates a production source during inspection | Every direct new-scan entry point is disabled before import, client construction, filesystem access, embeddings, or socket activity. | Snapshot-backed public scanning feasibility is unresolved and no connector is available. | +| Chroma mutates a production source during inspection | Every direct/live entry point is disabled; the active route copies a separately created operator snapshot before Chroma construction. | RAGLeakGuard does not prove snapshot provenance, quiescence, completeness, or transactional atomic consistency. | | Traversal, link, reparse, mount, ADS, sparse-file, or replacement tricks escape confinement | WP7B rejects symlinked roots/parents, path overlap, cross-device entries, non-regular objects, hard links, sparse files, reparse points, and Windows named streams; it uses no-follow opens plus pre/post object identity checks. | A same-account administrator, kernel/filesystem compromise, or unexercised filesystem semantic can defeat process-level checks. | | Mutable source yields a torn or incomplete work copy | Three source inventories, two work-copy inventories, source/copy content hashes, file/directory identities, count/size/depth ceilings, deadline checks, cancellation, and static failure prevent an observed inconsistency from returning a lease. | These checks narrow observable races; they do not create or prove transactionally atomic multi-file snapshot isolation, and a mutation can always occur after the final observation. | | Attacker causes unbounded allocation or work | Hard maxima are 20,000 source files, 10,000 source directories, depth 16, 16 GiB per file, 64 GiB source bytes, 21,000 work files, 72 GiB work bytes, 1 MiB chunks, 1,800 seconds preparation, and 600 seconds cleanup/recovery; public values may only narrow them. | Free-space checks are time-of-check/time-of-use observations. A blocking kernel/filesystem call cannot be preempted by the cooperative monotonic deadline. | | Cleanup deletes an operator path or an active work copy | Random exclusive workspaces, authenticated owner/snapshot/lease documents, resolved direct-child containment, filesystem-object identity, same-device no-follow recursive deletion, and an exclusive native lease are required before removal. | A crash can leave residue. Recovery deliberately stops on corrupt, forged, ambiguous, or actively leased candidates, so manual investigation may be required. Cleanup is deletion, not certified erasure. | -| Snapshot data or paths leak through the lifecycle | Control documents contain random identifiers only; failures are static, representations are redacted, and the private lifecycle produces no logs, console output, report, state, webhook, or network request. | The complete work copy necessarily contains the operator-provided bytes and is visible to the running account and administrators until cleanup. | +| Snapshot data or paths leak through the lifecycle | Control documents contain random identifiers only; raw fields stay inside the worker; IPC and public results are allowlisted aggregates; failures and success output are path-free; reports contain only `chroma-snapshot` plus escaped pseudonymous source ID. | The complete work copy necessarily contains the operator-provided bytes and is visible to the running account and administrators until cleanup. | | Chroma observes or mutates the production source through the private layer | WP7C accepts only a re-authenticated live WP7B capability and starts the exact local client with the internally derived disposable payload as its working and persistence directory. Parent and child validate the held lease; the parent revalidates after worker exit and before the receipt. | The operator snapshot and its work copy remain sensitive. In-process malicious code, administrators, kernel compromise, and unproved platform behavior are outside this private process boundary. | | Enumeration is incomplete, inconsistent, oversized, or mutates logical data | An authenticated ready-copy inventory precedes the worker. Exact migration/schema/catalog and record-bearing-table gates precede import; two explicitly paginated passes compare keyed collection, record, and canonical-content manifests plus counts. Every size, retained-entry, time, wait, IPC, and effect inventory has a hard ceiling. Post-exit semantic evidence must equal preflight evidence through a second final check immediately before capability validation and receipt creation. | Native calls can block below Python. Exact candidate evidence is environment-specific and can regress with transitive dependencies or runner changes. | | Worker leaks data, starts another process, or attempts egress | The work path is supplied only as the controlled child working directory, not argv, environment, or IPC. The request contains fixed nonsensitive controls; the receipt contains counters only. Child stdout/stderr, nested processes, sockets, DNS, telemetry export, proxies, credentials, and embedding/model acquisition are denied; matrix jobs add OS-level outbound denial. | Python interception is not a general sandbox. The child inherits process permissions, and OS-level evidence proves only the exact tested environment. | -| Private confinement or enumeration is mistaken for connector support | Both modules and callable names are private, `__all__` is empty, and no CLI, connector, detector, package extra, report, monitor, or webhook surface consumes them. WP7A disabled behavior is regression tested. | A future activation requires a separate WP7D issue, evidence, review, and human authorization. A counter receipt is not detector or scan completion, and private candidate evidence is not a support claim. | +| Private candidate evidence is mistaken for broad connector support | Private modules remain unexported; the public wrapper accepts only exact 1.5.9 and the five named native tuples, while the ten-cell WP7C matrix remains private. | Transitive dependencies and runner images can regress; each immutable head still requires independent review. | | Hostile path leaks through evaluation or errors | `read_chroma()` is a non-generator and raises one static public exception without coercion, formatting, attribute access, comparison, iteration, hashing, `str`, or `repr`. CLI output is also static. | Monitor scope authentication must process the operator-provided path string before pending recovery; this is not filesystem source access. | -| Disabled scan corrupts an artifact or creates false evidence | Exit 6 precedes report work and scan-derived state transitions. Byte-preservation and absent-artifact tests cover reports, state, and temporary files. | Host filesystem compromise remains outside the process contract. | +| Failed snapshot scan corrupts an artifact or creates false evidence | No result survives detector, count, termination, revalidation, or cleanup uncertainty. Report build/write/fsync/replace/directory-sync failures preserve existing bytes or absence in tested recoverable paths and suppress success. | Power loss during namespace replacement/rollback and hostile storage semantics remain external. | | Disabled monitor masks key/state or pending-alert failure | Webhook configuration and authenticated key/state validation retain precedence. A pending alert retains WP6 configuration, backoff, retry, transport, ambiguous-clear, and recovery semantics. | A permanently pending alert can block new scans indefinitely; new scans are independently disabled. | | Pending recovery begins a source scan or creates a new alert | Recovery terminates after one due attempt or one established failure branch. It may only update retry metadata or atomically clear the existing pending entry. | Network-send and clear crashes remain ambiguous and may duplicate delivery. | -| Chroma re-enters through packaging | The Chroma optional dependency is removed; wheel/sdist metadata and clean no-Chroma installation are tested. | PyPI 0.1.0 remains unsafe for Chroma scanning until a separately authorized human action changes public package state. | +| Chroma re-enters through packaging | Base dependencies remain Chroma-free; the optional extra pins `chromadb==1.5.9`, and public runtime gates repeat that exact check. | Transitive dependencies are not fully locked. PyPI 0.1.0 remains unsafe for Chroma scanning until a separately authorized human action changes public package state. | | Public prose overstates capability | English, Traditional Chinese, CLI help, architecture, threat model, security, contribution, release, and package claims are regression tested. | Historical artifacts require context and must not be read as current behavior. | -| Detector false negatives mistaken for absence | Disabled CLI paths produce no clean report. Library detection remains explicitly best-effort. | No detector is complete; importing callers remain responsible for raw in-memory findings. | +| Detector false negatives mistaken for absence | Reports state that detection is best-effort and absence is not proof of safety; findings are counted once on first-pass canonical segments and second-pass equality is independent. | No detector is complete; locale/model behavior and false negatives remain. | | Alert replay or duplicate delivery | Existing HMAC framing, freshness, nonce cache, stable delivery ID, and durable atomic receiver interface remain unchanged. | Exactly-once, unconditional at-least-once, downstream processing, and human notification are not proved. | WP7C classifies durable effects only inside the disposable work copy. Existing `chroma.sqlite3` diff --git a/pyproject.toml b/pyproject.toml index fde6c5d..50c9e47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ragleakguard" version = "0.1.0" -description = "Security toolkit with direct source-store scanning disabled." +description = "Security toolkit with bounded operator-snapshot Chroma detection." readme = "README.md" requires-python = ">=3.9" license = { text = "Apache-2.0" } @@ -25,6 +25,7 @@ dependencies = [ [project.optional-dependencies] pinecone = ["pinecone-client>=4"] +chroma-snapshot = ["chromadb==1.5.9"] # Python 3.9: pin spaCy<3.8 + numpy<2 so prebuilt wheels exist (newer spaCy needs a source build) detect = ["presidio-analyzer>=2.2", "presidio-anonymizer>=2.2", "spacy>=3.7,<3.8", "numpy<2"] dev = ["pytest>=8", "faker>=25"] diff --git a/src/ragleakguard/_chroma_snapshot.py b/src/ragleakguard/_chroma_snapshot.py index 3cf833c..f8eb5d8 100644 --- a/src/ragleakguard/_chroma_snapshot.py +++ b/src/ragleakguard/_chroma_snapshot.py @@ -36,6 +36,14 @@ __all__ = () _CANDIDATES = frozenset({"1.5.0", "1.5.9"}) +_PUBLIC_ACTIVATION_VERSION = "1.5.9" +_PUBLIC_ACTIVATION_ENVIRONMENTS = { + ("Linux", (3, 10)), + ("Linux", (3, 11)), + ("Linux", (3, 12)), + ("Darwin", (3, 12)), + ("Windows", (3, 12)), +} _GLOBAL_SECONDS = 1_200.0 _USEFUL_SECONDS = 1_170.0 _GRACEFUL_SECONDS = 10.0 @@ -46,6 +54,7 @@ _MAX_WAIT_POLLS = 12_000 _MAX_IPC_PAYLOAD = 262_144 _MAX_RECEIPT_PAYLOAD = 512 +_MAX_DETECTOR_RESPONSE_PAYLOAD = 16_384 _MAX_ERROR_PAYLOAD = 256 _MAX_EFFECT_PATHS = 4_096 _AUTOMATIC_RETRIES = 0 @@ -60,6 +69,7 @@ ) _MD5_RE = re.compile(r"^[0-9a-f]{32}$") _SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_ENTITY_TYPE_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,63}$") _MIGRATION_DIRS = ("embeddings_queue", "metadb", "sysdb") _TELEMETRY_CLASS = "ragleakguard._chroma_snapshot._LocalTelemetry" _COLLECTION_DOMAIN = b"RLG/WP7C/chroma/collection/v1" @@ -193,6 +203,47 @@ def __post_init__(self) -> None: _DEFAULT_CHROMA_SCAN_LIMITS = _ChromaScanLimits() +# WP7D narrows the reviewed WP7C ceilings. It never widens the private +# candidate path, so the ten-cell WP7C evidence remains independently usable. +_PUBLIC_CHROMA_SCAN_LIMITS = _ChromaScanLimits( + collections=1_000, + records=10_000, + source_segments=100_000, + source_utf8_bytes=268_435_456, + document_bytes=65_536, + manifest_entries=11_000, +) + + +@dataclass(frozen=True) +class _DetectorLimits: + records: int = 10_000 + source_segments: int = 100_000 + source_utf8_bytes: int = 268_435_456 + segment_bytes: int = 65_536 + findings_per_segment: int = 4_096 + total_findings: int = 1_000_000 + entity_types: int = 64 + + def __post_init__(self) -> None: + maxima = ( + (self.records, 10_000), + (self.source_segments, 100_000), + (self.source_utf8_bytes, 268_435_456), + (self.segment_bytes, 65_536), + (self.findings_per_segment, 4_096), + (self.total_findings, 1_000_000), + (self.entity_types, 64), + ) + if any( + type(value) is not int or value <= 0 or value > maximum + for value, maximum in maxima + ): + raise _ChromaScanError() + + +_DEFAULT_DETECTOR_LIMITS = _DetectorLimits() + class _ChromaCompletionReceipt: __slots__ = ( @@ -384,6 +435,29 @@ def _candidate_version() -> str: return raw +def _public_activation_gate() -> str: + """Reject every non-WP7D dependency or host tuple before source access.""" + version = _candidate_version() + system = platform.system() + python = (sys.version_info.major, sys.version_info.minor) + machine = platform.machine().lower() + if version != _PUBLIC_ACTIVATION_VERSION: + raise _ChromaScanError() + if (system, python) not in _PUBLIC_ACTIVATION_ENVIRONMENTS: + raise _ChromaScanError() + if system in {"Linux", "Windows"} and machine not in {"x86_64", "amd64"}: + raise _ChromaScanError() + if system == "Darwin": + if machine not in {"arm64", "aarch64", "x86_64"}: + raise _ChromaScanError() + try: + if int(platform.mac_ver()[0].split(".", 1)[0]) != 15: + raise _ChromaScanError() + except (ValueError, IndexError): + raise _ChromaScanError() from None + return version + + def _filesystem_type(path: Path) -> str: if type(path) is not _PATH_TYPE: raise _ChromaScanError() @@ -1371,6 +1445,30 @@ def _borrow_request( } +def _detection_request( + version: str, + algorithm: str, + limits: _ChromaScanLimits, + detector_limits: _DetectorLimits, + locale: Optional[str], + useful_seconds: float, +) -> dict: + if locale is not None and type(locale) is not str: + raise _ChromaScanError() + return { + "algorithm": algorithm, + "detector_limits": { + name: getattr(detector_limits, name) + for name in detector_limits.__dataclass_fields__ + }, + "limits": {name: getattr(limits, name) for name in limits.__dataclass_fields__}, + "locale": locale, + "mode": "detect", + "useful_seconds": useful_seconds, + "version": version, + } + + def _json_object(pairs: Iterable[tuple]) -> dict: result = {} for key, value in pairs: @@ -1443,6 +1541,157 @@ def _canonical_scalar(value: object, limits: _ChromaScanLimits) -> Tuple[bytes, raise _ChromaScanError() +class _DetectorAccumulator: + """Validate findings in-worker and retain privacy-minimal counters only.""" + + __slots__ = ( + "_allowed_types", + "_by_type", + "_current_findings", + "_detect", + "_in_record", + "_limits", + "_locale", + "_records", + "_records_with_findings", + "_segments", + "_source_bytes", + "_total_findings", + ) + + def __init__( + self, + locale: Optional[str], + limits: _DetectorLimits, + detect_function, + allowed_types: frozenset, + ) -> None: + if ( + locale is not None and type(locale) is not str + ) or type(limits) is not _DetectorLimits or type(allowed_types) is not frozenset: + raise _ChromaScanError() + if ( + not allowed_types + or len(allowed_types) > limits.entity_types + or any( + type(value) is not str or _ENTITY_TYPE_RE.fullmatch(value) is None + for value in allowed_types + ) + ): + raise _ChromaScanError() + self._locale = locale + self._limits = limits + self._detect = detect_function + self._allowed_types = allowed_types + self._records = 0 + self._records_with_findings = 0 + self._segments = 0 + self._source_bytes = 0 + self._total_findings = 0 + self._by_type: Dict[str, int] = {} + self._in_record = False + self._current_findings = 0 + + def start_record(self) -> None: + if self._in_record or self._records >= self._limits.records: + raise _ChromaScanError() + self._in_record = True + self._current_findings = 0 + + def consume(self, text: object, utf8_bytes: object) -> None: + if not self._in_record or type(utf8_bytes) is not int or utf8_bytes < 0: + raise _ChromaScanError() + observed_bytes = _utf8_length(text, self._limits.segment_bytes) + if observed_bytes != utf8_bytes: + raise _ChromaScanError() + next_segments = self._segments + 1 + next_bytes = self._source_bytes + utf8_bytes + if ( + next_segments > self._limits.source_segments + or next_bytes > self._limits.source_utf8_bytes + ): + raise _ChromaScanError() + try: + findings = self._detect(text, locale=self._locale) + except BaseException: + raise _ChromaScanError() from None + if type(findings) is not list or len(findings) > self._limits.findings_per_segment: + raise _ChromaScanError() + counts: Dict[str, int] = {} + for finding in findings: + if type(finding) is not dict or set(finding) != { + "type", + "start", + "end", + "score", + "text", + }: + raise _ChromaScanError() + entity_type = finding.get("type") + start = finding.get("start") + end = finding.get("end") + score = finding.get("score") + detected_text = finding.get("text") + if ( + type(entity_type) is not str + or entity_type not in self._allowed_types + or _ENTITY_TYPE_RE.fullmatch(entity_type) is None + or type(start) is not int + or type(end) is not int + or start < 0 + or end <= start + or end > len(text) + or isinstance(score, bool) + or not isinstance(score, (int, float)) + or not math.isfinite(score) + or score < 0 + or score > 1 + or type(detected_text) is not str + or detected_text != text[start:end] + ): + raise _ChromaScanError() + counts[entity_type] = counts.get(entity_type, 0) + 1 + + next_total = self._total_findings + len(findings) + if next_total > self._limits.total_findings: + raise _ChromaScanError() + for entity_type, count in counts.items(): + self._by_type[entity_type] = self._by_type.get(entity_type, 0) + count + if len(self._by_type) > self._limits.entity_types: + raise _ChromaScanError() + self._segments = next_segments + self._source_bytes = next_bytes + self._total_findings = next_total + self._current_findings += len(findings) + + def finish_record(self) -> None: + if not self._in_record: + raise _ChromaScanError() + self._records += 1 + if self._current_findings: + self._records_with_findings += 1 + self._current_findings = 0 + self._in_record = False + + def result(self) -> dict: + if self._in_record or sum(self._by_type.values()) != self._total_findings: + raise _ChromaScanError() + if ( + self._records_with_findings > self._records + or self._records_with_findings > self._total_findings + or (self._total_findings == 0) != (not self._by_type) + ): + raise _ChromaScanError() + return { + "finding_counts_by_type": dict(sorted(self._by_type.items())), + "records_completed": self._records, + "records_with_findings": self._records_with_findings, + "source_segments_completed": self._segments, + "source_utf8_bytes_completed": self._source_bytes, + "total_findings": self._total_findings, + } + + def _canonical_content( key: bytes, document: object, @@ -1451,6 +1700,7 @@ def _canonical_content( deadline: Optional[float] = None, clock: Callable[[], float] = time.monotonic, cancelled: Optional[Callable[[], bool]] = None, + segment_consumer=None, ) -> Tuple[bytes, bytes, int, int]: def check_control() -> None: if deadline is not None: @@ -1464,6 +1714,8 @@ def check_control() -> None: frames.append((b"document-none", b"")) else: encoded_document = _encoded(document, limits.document_bytes) + if segment_consumer is not None: + segment_consumer(document, len(encoded_document)) frames.append((b"document", encoded_document)) segments += 1 source_bytes += len(encoded_document) @@ -1481,9 +1733,11 @@ def check_control() -> None: ordered.sort(key=lambda item: item[0]) metadata_bytes = 0 leaves = 0 - for key_encoded, _, value in ordered: + for key_encoded, metadata_key, value in ordered: check_control() frames.append((b"metadata-key", key_encoded)) + if segment_consumer is not None: + segment_consumer(metadata_key, len(key_encoded)) segments += 1 source_bytes += len(key_encoded) metadata_bytes += len(key_encoded) @@ -1501,6 +1755,8 @@ def check_control() -> None: for item in value: check_control() tag, encoded = _canonical_scalar(item, limits) + if segment_consumer is not None: + segment_consumer(encoded.decode("utf-8"), len(encoded)) leaves += 1 segments += 1 source_bytes += len(encoded) @@ -1511,6 +1767,8 @@ def check_control() -> None: check_control() else: tag, encoded = _canonical_scalar(value, limits) + if segment_consumer is not None: + segment_consumer(encoded.decode("utf-8"), len(encoded)) leaves += 1 segments += 1 source_bytes += len(encoded) @@ -1582,7 +1840,13 @@ def _record_page(result: object, expected: int) -> Tuple[list, list, list]: return ids, documents, metadatas -def _enumeration_pass(client, key: bytes, limits: _ChromaScanLimits, deadline: float): +def _enumeration_pass( + client, + key: bytes, + limits: _ChromaScanLimits, + deadline: float, + detector: Optional[_DetectorAccumulator] = None, +): collection_count = _call(client.count_collections, deadline, None) collection_count = _exact_int(collection_count, limits.collections) collection_manifest = [] @@ -1662,9 +1926,18 @@ def _enumeration_pass(client, key: bytes, limits: _ChromaScanLimits, deadline: f ) record_token = _token(key, _RECORD_DOMAIN, record_frames) record_witness = _witness(key, _RECORD_DOMAIN, record_frames) + if detector is not None: + detector.start_record() content, content_witness, segments, source_bytes = _canonical_content( - key, document, metadata, limits, deadline + key, + document, + metadata, + limits, + deadline, + segment_consumer=(detector.consume if detector is not None else None), ) + if detector is not None: + detector.finish_record() records.append( record_token + record_witness + content + content_witness ) @@ -1951,6 +2224,15 @@ def __new__(cls, *args, **kwargs): _deny_attempt() +class _UnavailableIPv6Probe: + """Fail urllib3's import-time local IPv6 bind probe without a real socket.""" + + def __new__(cls, *args, **kwargs): + if args == (socket.AF_INET6,) and not kwargs: + raise OSError + _deny_attempt() + + class _DeniedPopen(subprocess.Popen): def __new__(cls, *args, **kwargs): _deny_attempt() @@ -2057,17 +2339,27 @@ def _audit_settings(settings, expected: Mapping[str, object]) -> None: def _worker_scan(request: dict) -> dict: - if type(request) is not dict or set(request) != { + private_keys = { "algorithm", "limits", "useful_seconds", "version", - }: + } + detection_keys = private_keys | {"detector_limits", "locale", "mode"} + if type(request) is not dict: + raise _ChromaScanError() + request_keys = frozenset(request) + if request_keys not in {frozenset(private_keys), frozenset(detection_keys)}: + raise _ChromaScanError() + detection_mode = request_keys == frozenset(detection_keys) + if detection_mode and request.get("mode") != "detect": raise _ChromaScanError() version = request.get("version") algorithm = request.get("algorithm") if version not in _CANDIDATES or algorithm not in {"md5", "sha256"}: raise _ChromaScanError() + if detection_mode and version != _PUBLIC_ACTIVATION_VERSION: + raise _ChromaScanError() useful_seconds = request.get("useful_seconds") if ( isinstance(useful_seconds, bool) @@ -2081,9 +2373,20 @@ def _worker_scan(request: dict) -> dict: raise _ChromaScanError() try: limits = _ChromaScanLimits(**request["limits"]) + detector_limits = ( + _DetectorLimits(**request["detector_limits"]) + if detection_mode and type(request.get("detector_limits")) is dict + else None + ) key = secrets.token_bytes(limits.token_bytes) except (TypeError, ValueError, OSError): raise _ChromaScanError() from None + if detection_mode and ( + limits != _PUBLIC_CHROMA_SCAN_LIMITS + or detector_limits != _DEFAULT_DETECTOR_LIMITS + or (request.get("locale") is not None and type(request.get("locale")) is not str) + ): + raise _ChromaScanError() if type(key) is not bytes or len(key) != limits.token_bytes: raise _ChromaScanError() deadline = time.monotonic() + float(useful_seconds) @@ -2096,6 +2399,46 @@ def _worker_scan(request: dict) -> dict: if before.algorithm != algorithm: raise _ChromaScanError() _sanitize_worker() + detector = None + if detection_mode: + denied_socket = socket.socket + try: + socket.socket = _UnavailableIPv6Probe + import tldextract + + offline_tldextract = tldextract.TLDExtract( + cache_dir=None, + suffix_list_urls=(), + ) + except BaseException: + raise _ChromaScanError() from None + finally: + socket.socket = denied_socket + try: + tldextract.extract = offline_tldextract + from ragleakguard.detect import ( + DEFAULT_ENTITIES, + LOCALE_PACKS, + detect, + validate_detection_runtime, + ) + + locale = validate_detection_runtime(request.get("locale")) + if locale != request.get("locale"): + raise _ChromaScanError() + allowed_types = set(DEFAULT_ENTITIES) + if locale is not None: + allowed_types.update(LOCALE_PACKS[locale]) + detector = _DetectorAccumulator( + locale, + detector_limits, + detect, + frozenset(allowed_types), + ) + except _ChromaScanError: + raise + except BaseException: + raise _ChromaScanError() from None try: import chromadb from chromadb.config import DEFAULT_DATABASE, DEFAULT_TENANT, Settings @@ -2166,7 +2509,9 @@ def _capture(self, event) -> None: if not before.same_as(constructed): raise _ChromaScanError() _worker_capability() - first_manifest, first_counts = _enumeration_pass(client, key, limits, deadline) + first_manifest, first_counts = _enumeration_pass( + client, key, limits, deadline, detector + ) _worker_capability() second_manifest, second_counts = _enumeration_pass(client, key, limits, deadline) if first_manifest != second_manifest or first_counts != second_counts: @@ -2178,13 +2523,23 @@ def _capture(self, event) -> None: if not before.same_as(final) or _ATTEMPTED_EGRESS_OR_PROCESS: raise _ChromaScanError() _check_control(deadline, time.monotonic, None) - return { + response = { "collections": first_counts[0], "ok": True, "records": first_counts[1], "segments": first_counts[2], "utf8_bytes": first_counts[3], } + if detector is not None: + detector_result = detector.result() + if ( + detector_result["records_completed"] != first_counts[1] + or detector_result["source_segments_completed"] != first_counts[2] + or detector_result["source_utf8_bytes_completed"] != first_counts[3] + ): + raise _ChromaScanError() + response["detector"] = detector_result + return response def _read_worker_request(handle) -> dict: @@ -2210,7 +2565,12 @@ def _worker_main() -> None: try: request = _read_worker_request(sys.stdin.buffer) document = _worker_scan(request) - encoded = _encode_frame(document, _MAX_RECEIPT_PAYLOAD) + maximum = ( + _MAX_DETECTOR_RESPONSE_PAYLOAD + if "detector" in document + else _MAX_RECEIPT_PAYLOAD + ) + encoded = _encode_frame(document, maximum) exit_code = 0 except BaseException: encoded = _encode_frame({"code": _ERROR_CODE, "ok": False}, _MAX_ERROR_PAYLOAD) @@ -2370,14 +2730,15 @@ def _terminate_process( return _wait_process(process, kill_end, deadline, clock, None, cancellation_fails=False) -def _run_worker( +def _run_worker_document( request: dict, data: Path, deadline: float, useful_cutoff: float, clock: Callable[[], float], cancelled: Optional[Callable[[], bool]], -) -> Tuple[int, int, int, int]: + response_maximum: int, +) -> dict: frame = _encode_frame(request, _MAX_IPC_PAYLOAD) creationflags = int(getattr(subprocess, "CREATE_NO_WINDOW", 0)) if os.name == "nt" else 0 try: @@ -2400,7 +2761,7 @@ def _run_worker( except BaseException: pass raise _ChromaScanError() - stdout_capture = _PipeCapture(process.stdout, _FRAME_PREFIX_BYTES + _MAX_RECEIPT_PAYLOAD) + stdout_capture = _PipeCapture(process.stdout, _FRAME_PREFIX_BYTES + response_maximum) stderr_capture = _PipeCapture(process.stderr, _MAX_CHILD_STDERR) writer = _PipeWriter(process.stdin, frame) threads = [ @@ -2437,12 +2798,31 @@ def _run_worker( or stdout_capture.failed or stderr_capture.failed or stderr_capture.total != 0 - or stdout_capture.total > _FRAME_PREFIX_BYTES + _MAX_RECEIPT_PAYLOAD + or stdout_capture.total > _FRAME_PREFIX_BYTES + response_maximum or process.returncode != 0 or not completed_in_time ): raise _ChromaScanError() - response = _decode_frame(bytes(stdout_capture.data), _MAX_RECEIPT_PAYLOAD) + return _decode_frame(bytes(stdout_capture.data), response_maximum) + + +def _run_worker( + request: dict, + data: Path, + deadline: float, + useful_cutoff: float, + clock: Callable[[], float], + cancelled: Optional[Callable[[], bool]], +) -> Tuple[int, int, int, int]: + response = _run_worker_document( + request, + data, + deadline, + useful_cutoff, + clock, + cancelled, + _MAX_RECEIPT_PAYLOAD, + ) if set(response) != {"collections", "ok", "records", "segments", "utf8_bytes"} or response["ok"] is not True: raise _ChromaScanError() limits = request["limits"] @@ -2454,6 +2834,130 @@ def _run_worker( ) +def _validate_detector_response(response: dict, request: dict): + if set(response) != { + "collections", + "detector", + "ok", + "records", + "segments", + "utf8_bytes", + } or response.get("ok") is not True: + raise _ChromaScanError() + limits = request.get("limits") + detector_limits = request.get("detector_limits") + if type(limits) is not dict or type(detector_limits) is not dict: + raise _ChromaScanError() + counts = ( + _exact_int(response.get("collections"), limits["collections"]), + _exact_int(response.get("records"), limits["records"]), + _exact_int(response.get("segments"), limits["source_segments"]), + _exact_int(response.get("utf8_bytes"), limits["source_utf8_bytes"]), + ) + detector = response.get("detector") + if type(detector) is not dict or set(detector) != { + "finding_counts_by_type", + "records_completed", + "records_with_findings", + "source_segments_completed", + "source_utf8_bytes_completed", + "total_findings", + }: + raise _ChromaScanError() + detector_records = _exact_int( + detector.get("records_completed"), detector_limits["records"] + ) + detector_segments = _exact_int( + detector.get("source_segments_completed"), + detector_limits["source_segments"], + ) + detector_bytes = _exact_int( + detector.get("source_utf8_bytes_completed"), + detector_limits["source_utf8_bytes"], + ) + flagged = _exact_int( + detector.get("records_with_findings"), detector_limits["records"] + ) + total = _exact_int( + detector.get("total_findings"), detector_limits["total_findings"] + ) + by_type = detector.get("finding_counts_by_type") + if type(by_type) is not dict or len(by_type) > detector_limits["entity_types"]: + raise _ChromaScanError() + try: + from ragleakguard.detect import DEFAULT_ENTITIES, LOCALE_PACKS + + allowed_types = set(DEFAULT_ENTITIES) + locale = request.get("locale") + if locale is not None: + allowed_types.update(LOCALE_PACKS[locale]) + except BaseException: + raise _ChromaScanError() from None + if ( + not allowed_types + or len(allowed_types) > detector_limits["entity_types"] + or any( + type(entity_type) is not str + or _ENTITY_TYPE_RE.fullmatch(entity_type) is None + for entity_type in allowed_types + ) + ): + raise _ChromaScanError() + validated: Dict[str, int] = {} + for entity_type, count in by_type.items(): + if ( + type(entity_type) is not str + or entity_type not in allowed_types + or _ENTITY_TYPE_RE.fullmatch(entity_type) is None + or type(count) is not int + or count <= 0 + or count > detector_limits["total_findings"] + ): + raise _ChromaScanError() + validated[entity_type] = count + if ( + counts[1:] != (detector_records, detector_segments, detector_bytes) + or (counts[0] == 0 and counts[1] != 0) + or flagged > detector_records + or flagged > total + or (total > 0 and flagged == 0) + or (detector_records == 0 and (detector_segments != 0 or detector_bytes != 0)) + or detector_bytes > detector_segments * detector_limits["segment_bytes"] + or total > detector_segments * detector_limits["findings_per_segment"] + or sum(validated.values()) != total + or (total == 0) != (not validated) + ): + raise _ChromaScanError() + return counts, { + "finding_counts_by_type": dict(sorted(validated.items())), + "records_completed": detector_records, + "records_with_findings": flagged, + "source_segments_completed": detector_segments, + "source_utf8_bytes_completed": detector_bytes, + "total_findings": total, + } + + +def _run_detection_worker( + request: dict, + data: Path, + deadline: float, + useful_cutoff: float, + clock: Callable[[], float], + cancelled: Optional[Callable[[], bool]], +): + response = _run_worker_document( + request, + data, + deadline, + useful_cutoff, + clock, + cancelled, + _MAX_DETECTOR_RESPONSE_PAYLOAD, + ) + return _validate_detector_response(response, request) + + def _same_borrow(left: _snapshot._BorrowedSnapshot, right: _snapshot._BorrowedSnapshot) -> bool: return ( left.data == right.data @@ -2598,6 +3102,117 @@ def _scan_prepared_chroma( raise _scrub(failure) +def _scan_prepared_chroma_with_detection( + prepared: _snapshot._PreparedSnapshot, + *, + locale: Optional[str], + limits: _ChromaScanLimits = _PUBLIC_CHROMA_SCAN_LIMITS, + detector_limits: _DetectorLimits = _DEFAULT_DETECTOR_LIMITS, + cancelled: Optional[Callable[[], bool]] = None, + clock: Callable[[], float] = time.monotonic, +): + """Return aggregate-only WP7D evidence after every WP7C gate succeeds.""" + failure = None + try: + borrow = _snapshot._borrow_prepared_snapshot(prepared) + if ( + type(limits) is not _ChromaScanLimits + or limits != _PUBLIC_CHROMA_SCAN_LIMITS + or type(detector_limits) is not _DetectorLimits + or detector_limits != _DEFAULT_DETECTOR_LIMITS + or (locale is not None and type(locale) is not str) + ): + raise _ChromaScanError() + start = _safe_clock(clock) + deadline = start + _GLOBAL_SECONDS + useful_cutoff = start + _USEFUL_SECONDS + if not math.isfinite(deadline) or not math.isfinite(useful_cutoff): + raise _ChromaScanError() + _check_control(deadline, clock, cancelled) + version = _candidate_version() + if version != _PUBLIC_ACTIVATION_VERSION: + raise _ChromaScanError() + environment = _environment_gate(version, borrow.data) + evidence_key = _token( + borrow.key, + b"RLG/WP7D/parent/store-evidence-key/v1", + ( + (b"workspace", borrow.workspace_id.encode("ascii")), + (b"snapshot", borrow.snapshot_id.encode("ascii")), + (b"lease", borrow.lease_id.encode("ascii")), + ), + ) + before_inventory = _inventory_files(borrow.data, deadline, clock, cancelled) + if not hmac.compare_digest( + _inventory_evidence(borrow.key, before_inventory), borrow.data_evidence + ): + raise _ChromaScanError() + before_store = _store_preflight( + borrow.data, version, evidence_key, limits, deadline, clock, cancelled + ) + renewed = _snapshot._borrow_prepared_snapshot(prepared) + if not _same_borrow(borrow, renewed): + raise _ChromaScanError() + remaining_useful = min(_USEFUL_SECONDS, useful_cutoff - _safe_clock(clock)) + if remaining_useful <= 0: + raise _ChromaScanError() + request = _detection_request( + version, + before_store.algorithm, + limits, + detector_limits, + locale, + remaining_useful, + ) + counts, detector = _run_detection_worker( + request, renewed.data, deadline, useful_cutoff, clock, cancelled + ) + renewed = _snapshot._borrow_prepared_snapshot(prepared) + if not _same_borrow(borrow, renewed): + raise _ChromaScanError() + after_store = _store_preflight( + borrow.data, version, evidence_key, limits, deadline, clock, cancelled + ) + after_inventory = _inventory_files(borrow.data, deadline, clock, cancelled) + if not before_store.same_as(after_store): + raise _ChromaScanError() + _classify_effects( + before_inventory, + after_inventory, + version, + environment, + before_store.vector_ids, + ) + final_borrow = _snapshot._borrow_prepared_snapshot(prepared) + if not _same_borrow(borrow, final_borrow): + raise _ChromaScanError() + final_store = _store_preflight( + borrow.data, version, evidence_key, limits, deadline, clock, cancelled + ) + final_inventory = _inventory_files(borrow.data, deadline, clock, cancelled) + if not before_store.same_as(final_store): + raise _ChromaScanError() + _classify_effects( + before_inventory, + final_inventory, + version, + environment, + before_store.vector_ids, + ) + receipt_borrow = _snapshot._borrow_prepared_snapshot(prepared) + if not _same_borrow(borrow, receipt_borrow): + raise _ChromaScanError() + _check_control(deadline, clock, cancelled) + return _ChromaCompletionReceipt(*counts), detector + except (KeyboardInterrupt, SystemExit): + raise + except _ChromaScanError as error: + failure = error + except BaseException: + failure = _ChromaScanError() + raise _scrub(failure) + + if __name__ == "__main__": sys.modules["ragleakguard._chroma_snapshot"] = sys.modules[__name__] if sys.argv == [sys.argv[0], "--worker"]: diff --git a/src/ragleakguard/cli.py b/src/ragleakguard/cli.py index 486a69d..a7fdbe6 100644 --- a/src/ragleakguard/cli.py +++ b/src/ragleakguard/cli.py @@ -17,6 +17,7 @@ ) +EXIT_SCAN_FAILURE = 1 EXIT_USAGE = 2 EXIT_DETECTION_RUNTIME = 3 EXIT_MONITOR_STATE = 4 @@ -26,7 +27,10 @@ app = typer.Typer( add_completion=False, no_args_is_help=True, - help="Direct source-store scanning is disabled; credential helpers remain available.", + help=( + "Bounded operator-snapshot Chroma scanning is available; direct/live " + "source-store scanning remains disabled." + ), ) @@ -225,13 +229,35 @@ def generate_webhook_secret( @app.command() def scan( source: str = typer.Option( - ..., "--source", help="Source type: chroma (direct scanning is disabled)" + ..., "--source", help="Source type: chroma (operator snapshots only)" ), - path: str = typer.Option( - None, "--path", help="Required store-scope value; the source is not accessed" + path: Optional[str] = typer.Option( + None, + "--path", + help="Rejected legacy direct/live store path", + ), + snapshot: Optional[str] = typer.Option( + None, + "--snapshot", + help="Offline, complete, operator-created Chroma snapshot directory", + ), + work_parent: Optional[str] = typer.Option( + None, + "--work-parent", + help="Existing private directory for the disposable validated copy", + ), + source_id: Optional[str] = typer.Option( + None, + "--source-id", + help="Pseudonymous ASCII source identifier for aggregate reporting", + ), + acknowledge_offline_complete_snapshot: bool = typer.Option( + False, + "--acknowledge-offline-complete-snapshot", + help="Confirm the supplied directory is an offline, complete snapshot", ), report: str = typer.Option( - "report.md", "--report", help="Report path; not accessed while scanning is disabled" + "report.md", "--report", help="Atomically finalized aggregate Markdown report" ), locale: str = typer.Option( None, @@ -239,20 +265,82 @@ def scan( help="Locale pack: au (case-insensitive; surrounding whitespace ignored)", ), ): - """Validate a Chroma scan request, then fail closed before source access. + """Detect sensitive-data types in one bounded operator snapshot. - Exit codes: 2 = usage/locale error · 6 = direct Chroma scanning disabled. + Direct/live Chroma access and legacy --path requests remain disabled. + + Exit codes: 0 = aggregate report durably finalized; 1 = scan/report failure; + 2 = usage/locale error; 3 = detection runtime unavailable; 6 = candidate + dependency or activation environment unavailable. """ source = source.lower() - if source == "chroma": - if not path: - print("[red]--path is required for chroma (the store directory).[/]") - raise typer.Exit(EXIT_USAGE) - _validated_locale_syntax(locale) - _abort_chroma_unavailable() - else: + if source != "chroma": print(f"[red]Source '{source}' isn't supported.[/] No source connector is available.") raise typer.Exit(EXIT_USAGE) + if path is not None: + print( + "[red]Legacy --path is rejected.[/] Direct/live Chroma access remains " + "disabled; supply an operator-created snapshot." + ) + raise typer.Exit(EXIT_USAGE) + if snapshot is None or work_parent is None or source_id is None: + print( + "[red]Snapshot scan arguments are incomplete.[/] Provide --snapshot, " + "--work-parent, and --source-id." + ) + raise typer.Exit(EXIT_USAGE) + if not acknowledge_offline_complete_snapshot: + print( + "[red]Snapshot acknowledgement is required.[/] Confirm an offline, " + "complete operator-created snapshot." + ) + raise typer.Exit(EXIT_USAGE) + + from ragleakguard import connectors + from ragleakguard import report as reporting + + try: + result = connectors.scan_chroma_snapshot( + snapshot, + work_parent, + source_id=source_id, + acknowledge_offline_complete_snapshot=True, + locale=locale, + ) + except DetectionError as error: + _abort_detection(error) + except connectors.InvalidChromaSnapshotRequest: + print("[red]Snapshot scan request is invalid.[/]") + raise typer.Exit(EXIT_USAGE) + except connectors.ChromaSnapshotUnavailableError: + print( + "[red]Snapshot-backed Chroma scanning is unavailable in this environment.[/]" + ) + raise typer.Exit(EXIT_CONNECTOR_UNAVAILABLE) + except connectors.ChromaSnapshotScanError: + print( + "[red]Snapshot-backed Chroma scan failed closed.[/] No report was replaced." + ) + raise typer.Exit(EXIT_SCAN_FAILURE) + + try: + aggregate_report = reporting.build_report( + dict(result.detector.finding_counts_by_type), + result.records_completed, + result.detector.records_with_findings, + source="chroma-snapshot", + path=source_id, + ) + reporting._finalize_report(aggregate_report, report) + except BaseException: + print( + "[red]Aggregate report finalization failed.[/] No success was reported." + ) + raise typer.Exit(EXIT_SCAN_FAILURE) + print( + "[green]Snapshot-backed Chroma scan completed; aggregate report finalized.[/]" + ) + raise typer.Exit(0) def _deliver_pending_alert( @@ -368,7 +456,7 @@ def monitor( ): """Recover an existing pending alert; fail closed before every new scan. - Every run requires --key-file. New baselines and scans are unavailable. + Every run requires --key-file. New scans are disabled; new baselines are unavailable. Exit codes: 0 = pending alert accepted and cleared; 2 = usage/locale error; 4 = monitor key/state failure; 5 = webhook pending/configuration/preparation/ diff --git a/src/ragleakguard/connectors.py b/src/ragleakguard/connectors.py index 27bab27..b845a3d 100644 --- a/src/ragleakguard/connectors.py +++ b/src/ragleakguard/connectors.py @@ -1,9 +1,20 @@ -"""Connector entry points. +"""Public connector boundaries. -No source-scanning connector is currently available. Direct local Chroma access is -disabled because reviewed endpoint evidence showed durable source-store mutation. +Direct/live Chroma access remains disabled. WP7D exposes only one aggregate result +after a separately created operator snapshot has been copied, scanned, and cleaned. """ -from typing import Any, Dict, Iterator +import re +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Callable, Dict, Iterator, Mapping, Optional + +from ragleakguard import _chroma_snapshot, _snapshot +from ragleakguard.detect import ( + MissingDetectionModelError, + UnsupportedLocaleError, + normalize_locale, + validate_detection_runtime, +) CHROMA_DISABLED_MESSAGE = ( @@ -12,6 +23,15 @@ "construction or reads, while other versions have not established an acceptable " "read-only boundary. No report, monitor state, or webhook was created or replaced." ) +CHROMA_SNAPSHOT_INVALID_MESSAGE = "Snapshot-backed Chroma scan request is invalid." +CHROMA_SNAPSHOT_UNAVAILABLE_MESSAGE = ( + "Snapshot-backed Chroma scanning is unavailable for this dependency or runtime." +) +CHROMA_SNAPSHOT_FAILURE_MESSAGE = ( + "Snapshot-backed Chroma scanning failed closed; no completion result was produced." +) +_SOURCE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") +_ENTITY_TYPE_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,63}$") class ChromaConnectorUnavailableError(RuntimeError): @@ -21,11 +41,192 @@ def __init__(self) -> None: super().__init__(CHROMA_DISABLED_MESSAGE) +class InvalidChromaSnapshotRequest(ValueError): + """Static failure for acknowledgement or pseudonymous-ID validation.""" + + def __init__(self) -> None: + super().__init__(CHROMA_SNAPSHOT_INVALID_MESSAGE) + + +class ChromaSnapshotUnavailableError(RuntimeError): + """Static failure for an unlisted dependency or activation environment.""" + + def __init__(self) -> None: + super().__init__(CHROMA_SNAPSHOT_UNAVAILABLE_MESSAGE) + + +class ChromaSnapshotScanError(RuntimeError): + """Static aggregate-only failure with no dependency or path details.""" + + def __init__(self) -> None: + super().__init__(CHROMA_SNAPSHOT_FAILURE_MESSAGE) + + +def _bounded_integer(value: object, maximum: int) -> int: + if type(value) is not int or value < 0 or value > maximum: + raise ValueError("Aggregate counter is outside the WP7D contract.") + return value + + +@dataclass(frozen=True) +class DetectorAggregate: + """The complete privacy-minimal detector aggregate returned by WP7D.""" + + records_completed: int + source_segments_completed: int + source_utf8_bytes_completed: int + records_with_findings: int + total_findings: int + finding_counts_by_type: Mapping[str, int] + + def __post_init__(self) -> None: + records = _bounded_integer(self.records_completed, 10_000) + segments = _bounded_integer(self.source_segments_completed, 100_000) + source_bytes = _bounded_integer( + self.source_utf8_bytes_completed, 268_435_456 + ) + flagged = _bounded_integer(self.records_with_findings, 10_000) + total = _bounded_integer(self.total_findings, 1_000_000) + if not isinstance(self.finding_counts_by_type, Mapping): + raise ValueError("Finding counts must be a mapping.") + counts = {} + for entity_type, count in self.finding_counts_by_type.items(): + if ( + type(entity_type) is not str + or _ENTITY_TYPE_RE.fullmatch(entity_type) is None + or type(count) is not int + or count <= 0 + or count > 1_000_000 + ): + raise ValueError("Finding aggregate is invalid.") + counts[entity_type] = count + if ( + len(counts) > 64 + or sum(counts.values()) != total + or flagged > records + or flagged > total + or (total == 0) != (not counts) + or (total == 0 and flagged != 0) + or (total > 0 and flagged == 0) + or (records == 0 and (segments != 0 or source_bytes != 0)) + or source_bytes > segments * 65_536 + or total > segments * 4_096 + ): + raise ValueError("Detector aggregate arithmetic is inconsistent.") + object.__setattr__( + self, "finding_counts_by_type", MappingProxyType(dict(sorted(counts.items()))) + ) + + +@dataclass(frozen=True) +class ChromaSnapshotScanResult: + """Aggregate-only connector counters plus an equal detector aggregate.""" + + collections_completed: int + records_completed: int + source_segments_completed: int + source_utf8_bytes_completed: int + detector: DetectorAggregate + + def __post_init__(self) -> None: + collections = _bounded_integer(self.collections_completed, 1_000) + records = _bounded_integer(self.records_completed, 10_000) + segments = _bounded_integer(self.source_segments_completed, 100_000) + source_bytes = _bounded_integer( + self.source_utf8_bytes_completed, 268_435_456 + ) + if type(self.detector) is not DetectorAggregate: + raise ValueError("Detector aggregate type is invalid.") + if ( + (collections == 0 and records != 0) + or (records == 0 and (segments != 0 or source_bytes != 0)) + or source_bytes > segments * 65_536 + or records != self.detector.records_completed + or segments != self.detector.source_segments_completed + or source_bytes != self.detector.source_utf8_bytes_completed + ): + raise ValueError("Connector and detector aggregates do not agree.") + + +def validate_chroma_source_id(value: object) -> str: + """Validate a pseudonymous stable identifier without coercing hostile input.""" + if type(value) is not str or _SOURCE_ID_RE.fullmatch(value) is None: + raise InvalidChromaSnapshotRequest() from None + return value + + def read_chroma(path: object, collection: object = None) -> None: """Fail synchronously without evaluating either supplied object.""" raise ChromaConnectorUnavailableError() from None +def scan_chroma_snapshot( + snapshot: object, + work_parent: object, + *, + source_id: object, + acknowledge_offline_complete_snapshot: object, + locale: Optional[str] = None, + cancelled: Optional[Callable[[], bool]] = None, +) -> ChromaSnapshotScanResult: + """Scan one operator-created snapshot and return aggregates after cleanup. + + The acknowledgement records operator intent only. It does not prove snapshot + provenance, quiescence, completeness, or atomic multi-file consistency. + """ + if acknowledge_offline_complete_snapshot is not True: + raise InvalidChromaSnapshotRequest() from None + validate_chroma_source_id(source_id) + normalized_locale = normalize_locale(locale) + normalized_locale = validate_detection_runtime(normalized_locale) + try: + _chroma_snapshot._public_activation_gate() + except BaseException: + raise ChromaSnapshotUnavailableError() from None + + try: + prepared = _snapshot._prepare_snapshot( + snapshot, + work_parent, + cancelled=cancelled, + ) + except (KeyboardInterrupt, SystemExit): + raise + except BaseException: + raise ChromaSnapshotScanError() from None + + receipt = None + detector_document = None + failed = False + try: + receipt, detector_document = ( + _chroma_snapshot._scan_prepared_chroma_with_detection( + prepared, + locale=normalized_locale, + cancelled=cancelled, + ) + ) + except BaseException: + failed = True + try: + prepared.cleanup() + except BaseException: + raise ChromaSnapshotScanError() from None + if failed or receipt is None or type(detector_document) is not dict: + raise ChromaSnapshotScanError() from None + try: + detector = DetectorAggregate(**detector_document) + return ChromaSnapshotScanResult( + collections_completed=receipt.collections_enumerated, + records_completed=receipt.records_enumerated, + source_segments_completed=receipt.source_segments_enumerated, + source_utf8_bytes_completed=receipt.source_utf8_bytes_enumerated, + detector=detector, + ) + except BaseException: + raise ChromaSnapshotScanError() from None + + def read_pinecone(index: str) -> Iterator[Dict[str, Any]]: """Read items from a Pinecone index. TODO (Week 2).""" raise NotImplementedError("Pinecone connector — Week 2") diff --git a/src/ragleakguard/report.py b/src/ragleakguard/report.py index 3acfacf..b3c4a29 100644 --- a/src/ragleakguard/report.py +++ b/src/ragleakguard/report.py @@ -3,11 +3,19 @@ Severity weighting + regulatory framing + remediation = the security judgment that makes a scan trustworthy, rather than a noisy entity dump. """ +import errno +import math +import os import re +import secrets +import stat +import time from html import escape -from typing import Dict, Optional +from pathlib import Path +from typing import Callable, Dict, Optional from unicodedata import category +from ragleakguard import _snapshot from ragleakguard.risk_policy import ( IDENTIFIER_SEVERITY, POLICY_ID, @@ -28,6 +36,41 @@ ) _PRESENTATION_CONTROL_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"}) _VISIBLE_CONTROL_ESCAPES = {"\t": "\\t", "\n": "\\n", "\r": "\\r"} +_MAX_FINAL_REPORT_BYTES = 1_048_576 +_REPORT_FINALIZATION_SECONDS = 30.0 +_REPORT_TEMP_PREFIX = ".rlg-report-" +_REPORT_TEMP_SUFFIX = ".tmp" +_REPORT_FAILURE = "Report finalization failed; no completed report is available." + + +class ReportFinalizationError(RuntimeError): + """Static, path-free failure for bounded atomic report replacement.""" + + def __init__(self) -> None: + super().__init__(_REPORT_FAILURE) + + +def _scrub_report_error() -> ReportFinalizationError: + error = ReportFinalizationError() + error.__cause__ = None + error.__context__ = None + error.__suppress_context__ = True + return error + + +def _report_now(clock: Callable[[], float]) -> float: + try: + value = clock() + except BaseException: + raise ReportFinalizationError() from None + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ReportFinalizationError() + return float(value) + + +def _report_check(deadline: float, clock: Callable[[], float]) -> None: + if _report_now(clock) > deadline: + raise ReportFinalizationError() def recorded_policy_version(markdown: str) -> Optional[str]: @@ -68,6 +111,252 @@ def _markdown_table_cell(value: str) -> str: return escape(visible, quote=True).replace("|", "|") +def _markdown_inline_code(value: str) -> str: + visible = "".join(_visible_presentation_character(char) for char in value) + return escape(visible, quote=True).replace("`", "`") + + +def _report_identity(path: Path): + raw = os.lstat(path) + identity = _snapshot._identity(raw) + if ( + not stat.S_ISREG(identity.mode) + or stat.S_ISLNK(identity.mode) + or _snapshot._is_reparse(identity) + or identity.links != 1 + or identity.size > _MAX_FINAL_REPORT_BYTES + or _snapshot._windows_has_named_streams(path) + ): + raise ReportFinalizationError() + return identity + + +def _read_existing_report(path: Path): + try: + identity = _report_identity(path) + flags = os.O_RDONLY | int(getattr(os, "O_BINARY", 0)) + flags |= int(getattr(os, "O_NOFOLLOW", 0)) + descriptor = os.open(path, flags) + try: + if not _snapshot._same_path_handle_identity( + identity, _snapshot._identity(os.fstat(descriptor)) + ): + raise ReportFinalizationError() + chunks = [] + total = 0 + while total <= _MAX_FINAL_REPORT_BYTES: + chunk = os.read(descriptor, min(65_536, _MAX_FINAL_REPORT_BYTES + 1 - total)) + if not chunk: + break + chunks.append(chunk) + total += len(chunk) + if total > _MAX_FINAL_REPORT_BYTES: + raise ReportFinalizationError() + return identity, b"".join(chunks) + finally: + os.close(descriptor) + except ReportFinalizationError: + raise + except BaseException: + raise ReportFinalizationError() from None + + +def _write_all(descriptor: int, encoded: bytes) -> None: + offset = 0 + while offset < len(encoded): + written = os.write(descriptor, encoded[offset:offset + 65_536]) + if type(written) is not int or written <= 0: + raise OSError + offset += written + + +def _new_report_temp( + parent: Path, + encoded: bytes, + deadline: float, + clock: Callable[[], float], + token_source: Callable[[int], bytes], +): + _report_check(deadline, clock) + try: + token = token_source(16) + except BaseException: + raise ReportFinalizationError() from None + if type(token) is not bytes or len(token) != 16: + raise ReportFinalizationError() + path = parent / (_REPORT_TEMP_PREFIX + token.hex() + _REPORT_TEMP_SUFFIX) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | int(getattr(os, "O_BINARY", 0)) + flags |= int(getattr(os, "O_NOFOLLOW", 0)) + descriptor = None + owned_identity = None + failed = True + try: + descriptor = os.open(path, flags, 0o600) + _snapshot._harden(path, False) + owned_identity = _snapshot._identity(os.fstat(descriptor)) + _write_all(descriptor, encoded) + _report_check(deadline, clock) + os.fsync(descriptor) + _report_check(deadline, clock) + identity = _snapshot._identity(os.fstat(descriptor)) + if identity.size != len(encoded): + raise ReportFinalizationError() + failed = False + return path, identity + except ReportFinalizationError: + raise + except BaseException: + raise ReportFinalizationError() from None + finally: + if descriptor is not None: + try: + os.close(descriptor) + except BaseException: + pass + if failed and owned_identity is not None: + try: + observed = _report_identity(path) + if _snapshot._same_object(observed, owned_identity): + os.unlink(path) + except BaseException: + pass + + +def _remove_owned_temp(path: Optional[Path], identity) -> None: + if path is None or identity is None: + return + try: + if _snapshot._same_object(_report_identity(path), identity): + os.unlink(path) + except BaseException: + pass + + +def _sync_report_directory(parent: Path) -> bool: + if os.name == "nt": + return False + flags = os.O_RDONLY | int(getattr(os, "O_DIRECTORY", 0)) + descriptor = None + try: + descriptor = os.open(parent, flags) + os.fsync(descriptor) + return True + except OSError as error: + if error.errno in {errno.EINVAL, errno.ENOTSUP, errno.EBADF}: + return False + raise + finally: + if descriptor is not None: + os.close(descriptor) + + +def _finalize_report( + markdown: str, + target: object, + *, + clock: Callable[[], float] = time.monotonic, + token_source: Callable[[int], bytes] = secrets.token_bytes, +) -> None: + """Restrictively and atomically replace one bounded same-directory report.""" + temporary = None + temporary_identity = None + replaced = False + existing_identity = None + existing_bytes = None + target_path = None + try: + if type(markdown) is not str: + raise ReportFinalizationError() + encoded = markdown.encode("utf-8", errors="strict") + if len(encoded) > _MAX_FINAL_REPORT_BYTES: + raise ReportFinalizationError() + start = _report_now(clock) + deadline = start + _REPORT_FINALIZATION_SECONDS + if not math.isfinite(deadline): + raise ReportFinalizationError() + target_path = Path(target) + if not target_path.is_absolute(): + target_path = Path.cwd() / target_path + if os.name == "nt" and ":" in target_path.name: + raise ReportFinalizationError() + parent = target_path.parent + parent_raw = os.lstat(parent) + parent_identity = _snapshot._identity(parent_raw) + if ( + not stat.S_ISDIR(parent_identity.mode) + or stat.S_ISLNK(parent_identity.mode) + or _snapshot._is_reparse(parent_identity) + or _snapshot._windows_has_named_streams(parent) + or parent.resolve(strict=True) != parent + ): + raise ReportFinalizationError() + if os.path.lexists(target_path): + existing_identity, existing_bytes = _read_existing_report(target_path) + _report_check(deadline, clock) + temporary, temporary_identity = _new_report_temp( + parent, encoded, deadline, clock, token_source + ) + if not _snapshot._same_object( + parent_identity, _snapshot._identity(os.lstat(parent)) + ): + raise ReportFinalizationError() + if existing_identity is None: + if os.path.lexists(target_path): + raise ReportFinalizationError() + elif not _snapshot._same_object(_report_identity(target_path), existing_identity): + raise ReportFinalizationError() + if not _snapshot._same_path_handle_identity( + _report_identity(temporary), temporary_identity + ): + raise ReportFinalizationError() + os.replace(temporary, target_path) + temporary = None + replaced = True + final_identity = _report_identity(target_path) + if not _snapshot._same_object(final_identity, temporary_identity): + raise ReportFinalizationError() + _snapshot._assert_restrictive(target_path, False) + _sync_report_directory(parent) + _report_check(deadline, clock) + if ( + not _snapshot._same_object( + parent_identity, _snapshot._identity(os.lstat(parent)) + ) + or not _snapshot._same_object( + _report_identity(target_path), temporary_identity + ) + ): + raise ReportFinalizationError() + _snapshot._assert_restrictive(target_path, False) + except BaseException: + if replaced and target_path is not None: + try: + if not _snapshot._same_object( + _report_identity(target_path), temporary_identity + ): + raise ReportFinalizationError() + if existing_identity is None: + os.unlink(target_path) + else: + rollback, rollback_identity = _new_report_temp( + target_path.parent, + existing_bytes, + float("inf"), + lambda: 0.0, + token_source, + ) + try: + os.replace(rollback, target_path) + rollback = None + finally: + _remove_owned_temp(rollback, rollback_identity) + _sync_report_directory(target_path.parent) + except BaseException: + pass + _remove_owned_temp(temporary, temporary_identity) + raise _scrub_report_error() + + def _risk_level( by_type: Dict[str, int], n_flagged: int, @@ -102,11 +391,14 @@ def build_report( ) total = sum(aggregates.values()) pct = f"{(n_flagged / n_records * 100):.0f}%" if n_records else "0%" + source_line = f"- **Source:** `{_markdown_inline_code(source)}`" + if path: + source_line += f" `{_markdown_inline_code(path)}`" lines = [ "# RAGLeakGuard — Sensitive Data Report", "", - f"- **Source:** `{source}` {path}".rstrip(), + source_line, f"- **Records scanned:** {n_records}", f"- **Records with sensitive data:** {n_flagged} ({pct})", f"- **Total findings:** {total}", diff --git a/tests/test_chroma_disabled.py b/tests/test_chroma_disabled.py index 1d6da5b..81c5d56 100644 --- a/tests/test_chroma_disabled.py +++ b/tests/test_chroma_disabled.py @@ -113,6 +113,14 @@ def _assert_disabled_output(result): assert not any(signal in result.output for signal in SUCCESS_SIGNALS) +def _assert_legacy_path_rejected(result): + assert result.exit_code == cli.EXIT_USAGE + assert "Legacy --path is rejected" in result.output + assert SOURCE_CANARY not in result.output + assert PRIVACY_CANARY not in result.output + assert not any(signal in result.output for signal in SUCCESS_SIGNALS) + + @pytest.mark.parametrize("existing_report", [False, True]) def test_scan_disabled_path_has_no_import_detector_source_report_or_network_side_effect( monkeypatch, tmp_path, existing_report @@ -165,7 +173,7 @@ def test_scan_disabled_path_has_no_import_detector_source_report_or_network_side ], ) - _assert_disabled_output(result) + _assert_legacy_path_rejected(result) assert source_calls == [] assert import_calls == [] assert network_calls == [] @@ -201,7 +209,7 @@ def test_scan_never_constructs_a_chroma_client_or_embedding_function( ], ) - _assert_disabled_output(result) + _assert_legacy_path_rejected(result) assert calls == [] @@ -345,11 +353,15 @@ def test_monitor_initialize_disabled_path_leaves_absent_state_absent( @pytest.mark.parametrize( ("args", "exit_code", "message"), [ - (["scan", "--source", "chroma"], cli.EXIT_USAGE, "--path is required"), + ( + ["scan", "--source", "chroma"], + cli.EXIT_USAGE, + "Snapshot scan arguments are incomplete", + ), ( ["scan", "--source", "chroma", "--path", "synthetic", "--locale", "uk"], cli.EXIT_USAGE, - "Unsupported locale", + "Legacy --path is rejected", ), ( ["scan", "--source", "pinecone", "--path", "synthetic"], diff --git a/tests/test_fail_closed.py b/tests/test_fail_closed.py index 75522e1..ce1285b 100644 --- a/tests/test_fail_closed.py +++ b/tests/test_fail_closed.py @@ -97,12 +97,24 @@ def read_chroma(path): def _command_args(command, tmp_path, locale=None): store_path = tmp_path / PATH_CANARY - args = [command, "--source", "chroma", "--path", str(store_path)] + args = [command, "--source", "chroma"] artifact = tmp_path / ("report.md" if command == "scan" else "state.json") if command == "scan": - args += ["--report", str(artifact)] + args += [ + "--snapshot", + str(store_path), + "--work-parent", + str(tmp_path / "private-work-path-canary"), + "--source-id", + "synthetic-source", + "--acknowledge-offline-complete-snapshot", + "--report", + str(artifact), + ] else: args += [ + "--path", + str(store_path), "--state", str(artifact), "--key-file", @@ -144,7 +156,7 @@ def test_cli_locale_failure_on_zero_item_source_creates_no_artifact_or_alert( _assert_private_failure(result) -def test_scan_disabled_path_precedes_detection_runtime_and_preserves_report( +def test_scan_legacy_path_precedes_detection_runtime_and_preserves_report( monkeypatch, tmp_path ): detector_calls = [] @@ -161,17 +173,24 @@ def test_scan_disabled_path_precedes_detection_runtime_and_preserves_report( "detect", lambda *args, **kwargs: detector_calls.append("detect"), ) - args, report = _command_args("scan", tmp_path) + args = [ + "scan", + "--source", + "chroma", + "--path", + str(tmp_path / PATH_CANARY), + ] + report = tmp_path / "report.md" report.write_bytes(b"existing-report-sentinel") before = report.read_bytes() result = CliRunner().invoke(cli.app, args) - assert result.exit_code == cli.EXIT_CONNECTOR_UNAVAILABLE + assert result.exit_code == cli.EXIT_USAGE assert report.read_bytes() == before assert not source_calls assert not detector_calls - assert "Local Chroma scanning is disabled" in result.output + assert "Legacy --path is rejected" in result.output _assert_private_failure(result) @@ -183,13 +202,25 @@ def test_scan_supported_locale_forms_remain_compatible( monkeypatch, tmp_path, locale_arg, normalized ): normalized_locales = [] + prepared = [] real_normalize = detection.normalize_locale monkeypatch.setattr( - cli, + connectors, "normalize_locale", lambda value: normalized_locales.append(real_normalize(value)) or normalized_locales[-1], ) + monkeypatch.setattr(connectors, "validate_detection_runtime", lambda value: value) + monkeypatch.setattr( + connectors._chroma_snapshot, + "_public_activation_gate", + lambda: (_ for _ in ()).throw(RuntimeError()), + ) + monkeypatch.setattr( + connectors._snapshot, + "_prepare_snapshot", + lambda *args, **kwargs: prepared.append((args, kwargs)), + ) source_calls = _patch_source(monkeypatch, [SYNTHETIC_ITEM]) args, report = _command_args("scan", tmp_path, locale=locale_arg) @@ -197,9 +228,10 @@ def test_scan_supported_locale_forms_remain_compatible( assert result.exit_code == cli.EXIT_CONNECTOR_UNAVAILABLE assert normalized_locales == [normalized] + assert prepared == [] assert not source_calls assert not report.exists() - assert "Local Chroma scanning is disabled" in result.output + assert "unavailable" in result.output def test_monitor_new_baseline_is_disabled_without_creating_state(monkeypatch, tmp_path): @@ -232,7 +264,10 @@ def test_cli_help_advertises_only_implemented_locale_and_failure_exit(command): assert "Locale pack: au" in normalized_output assert not any(locale in normalized_output for locale in ("uk |", "sg |", "in (")) assert "2 = usage/locale error" in normalized_output - assert "6 = direct Chroma scanning disabled" in normalized_output + if command == "scan": + assert "6 = candidate dependency or activation environment unavailable" in normalized_output + else: + assert "6 = direct Chroma scanning disabled" in normalized_output if command == "monitor": assert "4 = monitor key/state failure" in normalized_output assert "--key-file" in normalized_output diff --git a/tests/test_snapshot_confinement.py b/tests/test_snapshot_confinement.py index 864eacd..3d348ef 100644 --- a/tests/test_snapshot_confinement.py +++ b/tests/test_snapshot_confinement.py @@ -4,6 +4,7 @@ import inspect import os import plistlib +import shutil import socket import stat import subprocess @@ -720,6 +721,8 @@ def test_windows_work_copy_dacl_is_protected_and_identity_allowlisted(tmp_path): """ script_path = tmp_path / "inspect-dacl.ps1" script_path.write_text(script, encoding="utf-8") + powershell = shutil.which("pwsh") or shutil.which("powershell") + assert powershell is not None for path in ( lease._workspace, lease._snapshot, @@ -728,7 +731,7 @@ def test_windows_work_copy_dacl_is_protected_and_identity_allowlisted(tmp_path): ): result = subprocess.run( [ - "pwsh", + powershell, "-NoLogo", "-NoProfile", "-NonInteractive", @@ -805,7 +808,7 @@ def fail(*args, **kwargs): _assert_chain_excludes(caught.value, canary, "operator-exception-text-canary") -def test_no_public_connector_cli_or_package_surface_uses_snapshot_primitives(tmp_path): +def test_public_scan_uses_only_aggregate_snapshot_api_while_monitor_stays_disabled(tmp_path): import ragleakguard assert snap.__all__ == () @@ -816,12 +819,18 @@ def test_no_public_connector_cli_or_package_surface_uses_snapshot_primitives(tmp assert connectors.read_chroma.__module__ == "ragleakguard.connectors" with pytest.raises(connectors.ChromaConnectorUnavailableError): connectors.read_chroma(object()) - for command in ("scan", "monitor"): - result = CliRunner().invoke(cli.app, [command, "--help"]) - output = " ".join(unstyle(result.output).split()).lower() - assert result.exit_code == 0 - assert "snapshot" not in output - assert "6 = direct chroma scanning disabled" in output + assert connectors.scan_chroma_snapshot.__module__ == "ragleakguard.connectors" + scan_help = CliRunner().invoke(cli.app, ["scan", "--help"]) + scan_output = " ".join(unstyle(scan_help.output).split()).lower() + assert scan_help.exit_code == 0 + assert "operator snapshot" in scan_output + assert "--snapshot" in scan_output + assert "--work-parent" in scan_output + monitor_help = CliRunner().invoke(cli.app, ["monitor", "--help"]) + monitor_output = " ".join(unstyle(monitor_help.output).split()).lower() + assert monitor_help.exit_code == 0 + assert "--snapshot" not in monitor_output + assert "6 = direct chroma scanning disabled" in monitor_output def test_private_module_exports_no_public_callable_or_class(): @@ -853,7 +862,7 @@ def test_wp7b_docs_preserve_activation_gate_bounds_and_nonclaims(): "1 MiB", "1,800 seconds", "600 seconds", - "No source-scanning connector is currently available", + "aggregate-only operator-snapshot", "does not import or construct Chroma", "do not create or prove transactionally atomic multi-file snapshot isolation", "Cleanup is deletion, not certified erasure", @@ -862,8 +871,8 @@ def test_wp7b_docs_preserve_activation_gate_bounds_and_nonclaims(): "human authorization", ): assert exact in combined - assert "private WP7B lifecycle is not a connector" in architecture - assert "Snapshot-backed Chroma scanning remains unavailable" in contributing + assert "WP7D uses them internally" in architecture + assert "Direct/live Chroma entry points remain disabled" in contributing def test_ci_requires_current_native_ext4_apfs_ntfs_matrix_without_chroma_extra(): diff --git a/tests/test_wp7a_claims.py b/tests/test_wp7a_claims.py index bde9be7..21ed746 100644 --- a/tests/test_wp7a_claims.py +++ b/tests/test_wp7a_claims.py @@ -22,8 +22,13 @@ ) -def test_package_metadata_has_no_chroma_runtime_dependency_or_capability_claim(): +def test_package_metadata_keeps_chroma_optional_and_exact_for_snapshot_activation(): document = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + base_dependencies = re.search( + r"^dependencies\s*=\s*\[(.*?)\]", + document, + re.MULTILINE | re.DOTALL, + ).group(1) optional_dependencies = re.search( r"^\[project\.optional-dependencies\]\s*(.*?)(?=^\[)", document, @@ -44,9 +49,9 @@ def test_package_metadata_has_no_chroma_runtime_dependency_or_capability_claim() r'"([^"]+)"', re.search(r"exclude\s*=\s*\[(.*?)\]", sdist, re.DOTALL).group(1) ) - assert "chroma" not in optional_dependencies.lower() - assert "chromadb" not in document.lower() - assert "chroma" not in description.lower() + assert "chromadb" not in base_dependencies.lower() + assert 'chroma-snapshot = ["chromadb==1.5.9"]' in optional_dependencies + assert "operator-snapshot chroma" in description.lower() assert "scan your ai's vector database" not in description.lower() assert re.search(r'^requires-python\s*=\s*">=3\.9"$', document, re.MULTILINE) @@ -66,16 +71,14 @@ def test_package_metadata_has_no_chroma_runtime_dependency_or_capability_claim() @pytest.mark.parametrize("path", ENGLISH_CURRENT, ids=lambda path: path.name) -def test_current_english_claim_surfaces_record_wp7a_safety_boundary(path): +def test_current_english_claim_surfaces_record_wp7d_safety_boundary(path): text = path.read_text(encoding="utf-8") lowered = text.lower() - assert "1.5.0" in text and "1.5.9" in text - assert "other" in lowered and "read-only boundary" in lowered - assert "no source-scanning connector" in lowered or "no source-scanning connector is available" in lowered - assert "snapshot" in lowered and ( - "unavailable" in lowered or "not implemented" in lowered - ) + assert "1.5.9" in text + assert "operator" in lowered and "snapshot" in lowered + assert "direct/live" in lowered and "disabled" in lowered + assert "complete" in lowered and "quiescent" in lowered @pytest.mark.parametrize( @@ -98,14 +101,21 @@ def test_public_claim_surfaces_warn_against_pypi_010_chroma_scanning(path): assert "must not be used" in text or "不得用於" in text -def test_english_and_traditional_chinese_readmes_have_no_working_chroma_quickstart(): +def test_english_and_traditional_chinese_readmes_offer_only_snapshot_quickstart(): for name in ("README.md", "README.zh-TW.md"): text = (ROOT / name).read_text(encoding="utf-8") lowered = text.lower() - assert "ragleakguard scan --source chroma" not in lowered + assert "ragleakguard scan --source chroma" in lowered + for option in ( + "--snapshot", + "--work-parent", + "--source-id", + "--acknowledge-offline-complete-snapshot", + "--report", + ): + assert option in lowered assert "ragleakguard monitor --source chroma" not in lowered - assert "[chroma" not in lowered - assert ",chroma" not in lowered + assert "chroma-snapshot" in lowered assert "safe to run against production" not in lowered assert "read-only; safe" not in lowered @@ -114,14 +124,11 @@ def test_traditional_chinese_readme_states_all_required_current_facts(): text = (ROOT / "README.zh-TW.md").read_text(encoding="utf-8") for phrase in ( - "目前沒有任何可用的來源掃描連接器", - "直接掃描本機 Chroma 已停用", - "1.5.0", + "操作員快照", + "直接/即時 Chroma 掃描仍停用", "1.5.9", - "尚未建立可接受的唯讀邊界", - "Issue #15", - "並未完成", - "目前未實作", + "完整且靜止", + "不會證明快照的來源、靜止狀態、完整性或原子一致性", "PyPI `0.1.0`", "不得用於 Chroma 掃描", ): @@ -135,15 +142,22 @@ def test_cli_help_advertises_disabled_chroma_and_exit_six(command): assert result.exit_code == 0 assert "disabled" in output.lower() - assert "6 = direct Chroma scanning disabled" in output + if command == "scan": + assert "6 = candidate dependency or activation environment unavailable" in output + else: + assert "6 = direct Chroma scanning disabled" in output assert "pinecone" not in output.lower() assert "production" not in output.lower() if command == "monitor": assert "create a new baseline" not in output.lower() -def test_current_docs_do_not_offer_a_chroma_scan_or_monitor_command(): +def test_current_docs_never_offer_direct_path_or_monitor_chroma_commands(): for path in ENGLISH_CURRENT + (ROOT / "README.zh-TW.md",): text = path.read_text(encoding="utf-8") - assert not re.search(r"ragleakguard\s+scan\s+--source\s+chroma", text, re.I) + assert not re.search( + r"ragleakguard\s+scan\s+--source\s+chroma(?:(?!```)[\s\S])*?--path", + text, + re.I, + ) assert not re.search(r"ragleakguard\s+monitor\s+--source\s+chroma", text, re.I) diff --git a/tests/test_wp7d_snapshot_activation.py b/tests/test_wp7d_snapshot_activation.py new file mode 100644 index 0000000..09e2122 --- /dev/null +++ b/tests/test_wp7d_snapshot_activation.py @@ -0,0 +1,1023 @@ +"""WP7D public operator-snapshot activation and finalization regressions.""" +from __future__ import annotations + +import dataclasses +import hashlib +import importlib.metadata +import os +import platform +import socket +import subprocess +import sys +import textwrap +import traceback +from pathlib import Path +from types import MappingProxyType, SimpleNamespace + +import pytest +from typer.testing import CliRunner + +from ragleakguard import _chroma_snapshot as chroma_private +from ragleakguard import cli +from ragleakguard import connectors +from ragleakguard import report as reporting + + +SOURCE_PATH_CANARY = "operator-snapshot-path-canary" +WORK_PATH_CANARY = "private-work-parent-path-canary" +REPORT_PATH_CANARY = "private-report-path-canary" +RAW_CANARIES = ( + "document-text-canary", + "metadata-value-canary", + "collection-name-canary", + "record-id-canary", + SOURCE_PATH_CANARY, + WORK_PATH_CANARY, + REPORT_PATH_CANARY, + "dependency-exception-canary", + "secret-token-canary", +) + + +def _detector_aggregate(**overrides): + values = { + "records_completed": 2, + "source_segments_completed": 3, + "source_utf8_bytes_completed": 12, + "records_with_findings": 1, + "total_findings": 2, + "finding_counts_by_type": {"EMAIL_ADDRESS": 2}, + } + values.update(overrides) + return connectors.DetectorAggregate(**values) + + +def _scan_result(**overrides): + values = { + "collections_completed": 1, + "records_completed": 2, + "source_segments_completed": 3, + "source_utf8_bytes_completed": 12, + "detector": _detector_aggregate(), + } + values.update(overrides) + return connectors.ChromaSnapshotScanResult(**values) + + +@pytest.mark.parametrize( + "value", + [ + "", + " ", + "a/b", + r"a\b", + ".leading", + "customer name", + "name\nforged", + "é", + "a" * 65, + None, + 1, + True, + ], +) +def test_source_id_grammar_rejects_paths_controls_non_ascii_and_bad_types(value): + with pytest.raises(connectors.InvalidChromaSnapshotRequest): + connectors.validate_chroma_source_id(value) + + +@pytest.mark.parametrize( + "value", ["a", "A0", "source-1", "source_1", "source.1", "a" * 64] +) +def test_source_id_grammar_accepts_only_the_narrow_ascii_contract(value): + assert connectors.validate_chroma_source_id(value) == value + + +def test_read_chroma_remains_synchronous_and_argument_opaque(): + events = [] + + class Hostile: + def __getattribute__(self, name): + if name == "__class__": + return object.__getattribute__(self, name) + events.append(name) + raise AssertionError + + def __fspath__(self): + events.append("fspath") + raise AssertionError + + def __str__(self): + events.append("str") + raise AssertionError + + with pytest.raises(connectors.ChromaConnectorUnavailableError): + connectors.read_chroma(Hostile(), Hostile()) + assert events == [] + + +@pytest.mark.parametrize( + "stage", + ["acknowledgement", "source-id", "locale", "runtime", "version", "platform"], +) +def test_every_public_preflight_gate_fails_before_snapshot_preparation( + monkeypatch, stage +): + prepared = [] + monkeypatch.setattr( + connectors._snapshot, + "_prepare_snapshot", + lambda *args, **kwargs: prepared.append((args, kwargs)), + ) + monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) + monkeypatch.setattr( + connectors._chroma_snapshot, "_public_activation_gate", lambda: "1.5.9" + ) + kwargs = { + "source_id": "source-1", + "acknowledge_offline_complete_snapshot": True, + "locale": None, + } + if stage == "acknowledgement": + kwargs["acknowledge_offline_complete_snapshot"] = False + elif stage == "source-id": + kwargs["source_id"] = "bad/path" + elif stage == "locale": + monkeypatch.setattr( + connectors, + "normalize_locale", + lambda value: (_ for _ in ()).throw(connectors.UnsupportedLocaleError()), + ) + kwargs["locale"] = "uk" + elif stage == "runtime": + monkeypatch.setattr( + connectors, + "validate_detection_runtime", + lambda value: (_ for _ in ()).throw( + connectors.MissingDetectionModelError() + ), + ) + elif stage in {"version", "platform"}: + monkeypatch.setattr( + connectors._chroma_snapshot, + "_public_activation_gate", + lambda: (_ for _ in ()).throw(chroma_private._ChromaScanError()), + ) + + expected = connectors.InvalidChromaSnapshotRequest if stage in { + "acknowledgement", + "source-id", + } else Exception + with pytest.raises(expected): + connectors.scan_chroma_snapshot( + SOURCE_PATH_CANARY, + WORK_PATH_CANARY, + **kwargs, + ) + assert prepared == [] + + +def test_public_result_is_immutable_bounded_and_mapping_is_read_only(): + result = _scan_result() + assert isinstance(result.detector.finding_counts_by_type, MappingProxyType) + assert result.detector.finding_counts_by_type == {"EMAIL_ADDRESS": 2} + with pytest.raises(dataclasses.FrozenInstanceError): + result.records_completed = 3 + with pytest.raises(TypeError): + result.detector.finding_counts_by_type["EMAIL_ADDRESS"] = 3 + assert SOURCE_PATH_CANARY not in repr(result) + assert WORK_PATH_CANARY not in repr(result) + + +@pytest.mark.parametrize( + "overrides", + [ + {"records_completed": True}, + {"records_completed": -1}, + {"records_completed": 10_001}, + {"source_segments_completed": 100_001}, + {"source_utf8_bytes_completed": 268_435_457}, + {"records_with_findings": 3}, + {"total_findings": -1}, + {"total_findings": 1_000_001}, + {"finding_counts_by_type": {"EMAIL_ADDRESS": True}}, + {"finding_counts_by_type": {"bad/type": 1}}, + {"finding_counts_by_type": {f"TYPE_{n}": 1 for n in range(65)}}, + {"finding_counts_by_type": {"EMAIL_ADDRESS": 1}}, + {"finding_counts_by_type": {}, "total_findings": 2}, + ], +) +def test_detector_aggregate_rejects_boolean_negative_oversized_and_inconsistent_values( + overrides, +): + with pytest.raises(ValueError): + _detector_aggregate(**overrides) + + +def test_connector_and_detector_counter_mismatch_is_rejected(): + with pytest.raises(ValueError): + _scan_result(detector=_detector_aggregate(records_completed=1)) + with pytest.raises(ValueError): + _scan_result(detector=_detector_aggregate(source_segments_completed=2)) + with pytest.raises(ValueError): + _scan_result(detector=_detector_aggregate(source_utf8_bytes_completed=11)) + + +def test_zero_record_zero_segment_zero_finding_result_is_valid(): + detector = connectors.DetectorAggregate( + records_completed=0, + source_segments_completed=0, + source_utf8_bytes_completed=0, + records_with_findings=0, + total_findings=0, + finding_counts_by_type={}, + ) + result = connectors.ChromaSnapshotScanResult( + collections_completed=0, + records_completed=0, + source_segments_completed=0, + source_utf8_bytes_completed=0, + detector=detector, + ) + assert result.detector.total_findings == 0 + + +def _successful_public_preflight(monkeypatch): + monkeypatch.setattr(connectors, "normalize_locale", lambda locale: locale) + monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) + monkeypatch.setattr( + connectors._chroma_snapshot, "_public_activation_gate", lambda: "1.5.9" + ) + + +@pytest.mark.parametrize("failure", ["scan", "cancellation", "cleanup"]) +def test_scan_cancellation_or_cleanup_uncertainty_returns_no_result_and_cleans( + monkeypatch, failure +): + _successful_public_preflight(monkeypatch) + events = [] + + class Prepared: + def cleanup(self): + events.append("cleanup") + if failure == "cleanup": + raise OSError("cleanup-path-canary") + + prepared = Prepared() + monkeypatch.setattr( + connectors._snapshot, "_prepare_snapshot", lambda *args, **kwargs: prepared + ) + + def scan(*args, **kwargs): + events.append("scan") + if failure == "cancellation": + assert kwargs["cancelled"]() is True + if failure in {"scan", "cancellation"}: + raise chroma_private._ChromaScanError("detector-exception-canary") + return ( + SimpleNamespace( + collections_enumerated=1, + records_enumerated=2, + source_segments_enumerated=3, + source_utf8_bytes_enumerated=12, + ), + { + "finding_counts_by_type": {"EMAIL_ADDRESS": 2}, + "records_completed": 2, + "records_with_findings": 1, + "source_segments_completed": 3, + "source_utf8_bytes_completed": 12, + "total_findings": 2, + }, + ) + + monkeypatch.setattr( + connectors._chroma_snapshot, + "_scan_prepared_chroma_with_detection", + scan, + ) + cancelled = (lambda: True) if failure == "cancellation" else None + with pytest.raises(connectors.ChromaSnapshotScanError) as caught: + connectors.scan_chroma_snapshot( + SOURCE_PATH_CANARY, + WORK_PATH_CANARY, + source_id="source-1", + acknowledge_offline_complete_snapshot=True, + cancelled=cancelled, + ) + assert events == ["scan", "cleanup"] + rendered = "".join( + traceback.format_exception(type(caught.value), caught.value, caught.value.__traceback__) + ) + for canary in RAW_CANARIES + ("cleanup-path-canary", "detector-exception-canary"): + assert canary not in rendered + + +def test_detector_invoked_once_per_first_pass_segment_and_not_on_second_pass(): + calls = [] + + def fake_detect(text, locale=None): + calls.append((text, locale)) + if "email" in text: + start = text.index("email") + return [{ + "type": "EMAIL_ADDRESS", + "start": start, + "end": start + 5, + "score": 0.9, + "text": "email", + }] + return [] + + accumulator = chroma_private._DetectorAccumulator( + None, + chroma_private._DEFAULT_DETECTOR_LIMITS, + fake_detect, + frozenset({"EMAIL_ADDRESS"}), + ) + accumulator.start_record() + first = chroma_private._canonical_content( + b"k" * 32, + "email document", + {"zeta": ["one", "two"], "metadata": "value"}, + chroma_private._PUBLIC_CHROMA_SCAN_LIMITS, + segment_consumer=accumulator.consume, + ) + accumulator.finish_record() + second = chroma_private._canonical_content( + b"k" * 32, + "email document", + {"zeta": ["one", "two"], "metadata": "value"}, + chroma_private._PUBLIC_CHROMA_SCAN_LIMITS, + ) + assert first == second + assert calls == [ + ("email document", None), + ("metadata", None), + ("value", None), + ("zeta", None), + ("one", None), + ("two", None), + ] + assert accumulator.result() == { + "finding_counts_by_type": {"EMAIL_ADDRESS": 1}, + "records_completed": 1, + "records_with_findings": 1, + "source_segments_completed": 6, + "source_utf8_bytes_completed": 37, + "total_findings": 1, + } + + +def test_offline_suffix_import_allows_no_real_socket_or_general_probe(): + previous = chroma_private._ATTEMPTED_EGRESS_OR_PROCESS + chroma_private._ATTEMPTED_EGRESS_OR_PROCESS = False + try: + with pytest.raises(OSError): + chroma_private._UnavailableIPv6Probe(socket.AF_INET6) + assert chroma_private._ATTEMPTED_EGRESS_OR_PROCESS is False + with pytest.raises(chroma_private._ChromaScanError): + chroma_private._UnavailableIPv6Probe(socket.AF_INET) + assert chroma_private._ATTEMPTED_EGRESS_OR_PROCESS is True + finally: + chroma_private._ATTEMPTED_EGRESS_OR_PROCESS = previous + + +@pytest.mark.parametrize( + "findings", + [ + None, + {}, + [{"type": "EMAIL_ADDRESS"}], + [{ + "type": "bad/type", "start": 0, "end": 1, "score": 0.5, "text": "x" + }], + [{ + "type": "EMAIL_ADDRESS", "start": True, "end": 1, + "score": 0.5, "text": "x" + }], + [{ + "type": "EMAIL_ADDRESS", "start": 0, "end": 2, + "score": 0.5, "text": "x" + }], + [{ + "type": "EMAIL_ADDRESS", "start": 0, "end": 1, + "score": float("nan"), "text": "x" + }], + ], +) +def test_malformed_detector_output_fails_closed(findings): + accumulator = chroma_private._DetectorAccumulator( + None, + chroma_private._DEFAULT_DETECTOR_LIMITS, + lambda text, locale=None: findings, + frozenset({"EMAIL_ADDRESS"}), + ) + accumulator.start_record() + with pytest.raises(chroma_private._ChromaScanError): + accumulator.consume("x", 1) + + +def test_detector_segment_bound_is_enforced_before_detection(): + calls = [] + limits = chroma_private._DetectorLimits(source_segments=1) + accumulator = chroma_private._DetectorAccumulator( + None, + limits, + lambda text, locale=None: calls.append(text) or [], + frozenset({"EMAIL_ADDRESS"}), + ) + accumulator.start_record() + accumulator.consume("first", 5) + with pytest.raises(chroma_private._ChromaScanError): + accumulator.consume("second", 6) + assert calls == ["first"] + + +def test_detection_request_and_response_are_bounded_and_privacy_minimal(): + request = chroma_private._detection_request( + "1.5.9", + "md5", + chroma_private._PUBLIC_CHROMA_SCAN_LIMITS, + chroma_private._DEFAULT_DETECTOR_LIMITS, + None, + 60.0, + ) + encoded = chroma_private._encode_frame(request, chroma_private._MAX_IPC_PAYLOAD) + serialized = encoded.decode("ascii", errors="ignore") + for canary in RAW_CANARIES: + assert canary not in serialized + assert len(encoded) <= chroma_private._MAX_IPC_PAYLOAD + 4 + + response = { + "collections": 1, + "detector": { + "finding_counts_by_type": {"EMAIL_ADDRESS": 2}, + "records_completed": 2, + "records_with_findings": 1, + "source_segments_completed": 3, + "source_utf8_bytes_completed": 12, + "total_findings": 2, + }, + "ok": True, + "records": 2, + "segments": 3, + "utf8_bytes": 12, + } + framed = chroma_private._encode_frame( + response, chroma_private._MAX_DETECTOR_RESPONSE_PAYLOAD + ) + assert len(framed) <= 16_388 + + +@pytest.mark.parametrize( + "mutate", + [ + lambda value: value.pop("detector"), + lambda value: value["detector"].pop("records_completed"), + lambda value: value["detector"].__setitem__("records_completed", True), + lambda value: value["detector"].__setitem__("records_completed", -1), + lambda value: value["detector"].__setitem__("records_completed", 10_001), + lambda value: value["detector"].__setitem__("total_findings", 1_000_001), + lambda value: value["detector"].__setitem__("records_with_findings", 0), + lambda value: value["detector"].__setitem__( + "finding_counts_by_type", {"bad/type": 2} + ), + lambda value: value["detector"].__setitem__( + "finding_counts_by_type", {"UNKNOWN_ENTITY": 2} + ), + lambda value: value["detector"].__setitem__( + "finding_counts_by_type", {"EMAIL_ADDRESS": 1} + ), + lambda value: value["detector"].__setitem__("source_segments_completed", 2), + ], +) +def test_missing_boolean_negative_oversized_malformed_and_inconsistent_worker_aggregate_fails( + mutate, +): + request = chroma_private._detection_request( + "1.5.9", + "md5", + chroma_private._PUBLIC_CHROMA_SCAN_LIMITS, + chroma_private._DEFAULT_DETECTOR_LIMITS, + None, + 60.0, + ) + response = { + "collections": 1, + "detector": { + "finding_counts_by_type": {"EMAIL_ADDRESS": 2}, + "records_completed": 2, + "records_with_findings": 1, + "source_segments_completed": 3, + "source_utf8_bytes_completed": 12, + "total_findings": 2, + }, + "ok": True, + "records": 2, + "segments": 3, + "utf8_bytes": 12, + } + mutate(response) + with pytest.raises(chroma_private._ChromaScanError): + chroma_private._validate_detector_response(response, request) + + +def test_duplicate_worker_aggregate_key_is_rejected_during_frame_decode(): + payload = b'{"detector":{},"detector":{},"ok":true}' + framed = len(payload).to_bytes(4, "big") + payload + with pytest.raises(chroma_private._ChromaScanError): + chroma_private._decode_frame( + framed, chroma_private._MAX_DETECTOR_RESPONSE_PAYLOAD + ) + + +def test_report_finalization_is_atomic_restrictive_bounded_and_path_free(tmp_path): + target = tmp_path / REPORT_PATH_CANARY + reporting._finalize_report("synthetic report", target) + assert target.read_text(encoding="utf-8") == "synthetic report" + if os.name != "nt": + assert target.stat().st_mode & 0o077 == 0 + assert not list(tmp_path.glob(".rlg-report-*.tmp")) + + +@pytest.mark.skipif(os.name != "nt", reason="NTFS alternate data streams are Windows-only") +def test_report_finalization_rejects_ntfs_alternate_data_stream_target(tmp_path): + target = tmp_path / "report.md:untrusted-stream" + with pytest.raises(reporting.ReportFinalizationError): + reporting._finalize_report("synthetic report", target) + assert not os.path.lexists(target) + assert not list(tmp_path.glob(".rlg-report-*.tmp")) + + +@pytest.mark.parametrize("existing", [False, True]) +@pytest.mark.parametrize("failure", ["write", "fsync", "replace", "directory-sync"]) +def test_report_finalization_failure_preserves_existing_or_absent_target( + tmp_path, monkeypatch, existing, failure +): + target = tmp_path / REPORT_PATH_CANARY + if existing: + target.write_bytes(b"existing-report\x00\xff") + before = target.read_bytes() + else: + before = None + + if failure == "write": + monkeypatch.setattr( + reporting, "_write_all", lambda *args: (_ for _ in ()).throw(OSError()) + ) + elif failure == "fsync": + monkeypatch.setattr( + reporting.os, "fsync", lambda *args: (_ for _ in ()).throw(OSError()) + ) + else: + target_object = reporting.os if failure == "replace" else reporting + attribute = "replace" if failure == "replace" else "_sync_report_directory" + monkeypatch.setattr( + target_object, attribute, lambda *args: (_ for _ in ()).throw(OSError()) + ) + + with pytest.raises(reporting.ReportFinalizationError) as caught: + reporting._finalize_report("replacement", target) + rendered = "".join( + traceback.format_exception(type(caught.value), caught.value, caught.value.__traceback__) + ) + for canary in RAW_CANARIES: + assert canary not in rendered + if existing: + assert target.read_bytes() == before + else: + assert not target.exists() + assert not list(tmp_path.glob(".rlg-report-*.tmp")) + + +def test_report_finalization_interrupt_is_scrubbed_and_preserves_target( + tmp_path, monkeypatch +): + target = tmp_path / REPORT_PATH_CANARY + target.write_bytes(b"existing") + monkeypatch.setattr( + reporting, "_write_all", lambda *args: (_ for _ in ()).throw(KeyboardInterrupt()) + ) + with pytest.raises(reporting.ReportFinalizationError): + reporting._finalize_report("replacement", target) + assert target.read_bytes() == b"existing" + assert not list(tmp_path.glob(".rlg-report-*.tmp")) + + +def test_report_finalization_detects_post_replace_swap_without_clobbering_it( + tmp_path, monkeypatch +): + target = tmp_path / REPORT_PATH_CANARY + target.write_bytes(b"existing") + + def swap_target(_parent): + target.unlink() + target.write_bytes(b"unowned-replacement") + return True + + monkeypatch.setattr(reporting, "_sync_report_directory", swap_target) + with pytest.raises(reporting.ReportFinalizationError): + reporting._finalize_report("replacement", target) + assert target.read_bytes() == b"unowned-replacement" + assert not list(tmp_path.glob(".rlg-report-*.tmp")) + + +def test_snapshot_report_contains_only_escaped_pseudonymous_identity(): + rendered = reporting.build_report( + {"EMAIL_ADDRESS": 1}, + n_records=1, + n_flagged=1, + source="chroma-snapshot", + path="source-1", + ) + assert "`chroma-snapshot` `source-1`" in rendered + for canary in RAW_CANARIES: + assert canary not in rendered + + +def test_report_finalization_rejects_oversize_symlink_and_directory(tmp_path): + target = tmp_path / "report.md" + with pytest.raises(reporting.ReportFinalizationError): + reporting._finalize_report("x" * (1_048_576 + 1), target) + target.mkdir() + with pytest.raises(reporting.ReportFinalizationError): + reporting._finalize_report("x", target) + target.rmdir() + actual = tmp_path / "actual.md" + actual.write_text("preserve", encoding="utf-8") + try: + target.symlink_to(actual) + except (OSError, NotImplementedError): + os.link(actual, target) + with pytest.raises(reporting.ReportFinalizationError): + reporting._finalize_report("x", target) + assert actual.read_text(encoding="utf-8") == "preserve" + if target.is_symlink(): + target.unlink() + hard_link = tmp_path / "hard-link.md" + try: + os.link(actual, hard_link) + except OSError: + pytest.skip("hard links are unavailable on this test filesystem") + with pytest.raises(reporting.ReportFinalizationError): + reporting._finalize_report("x", hard_link) + assert actual.read_text(encoding="utf-8") == "preserve" + + +def test_cli_success_occurs_only_after_cleanup_result_and_atomic_report(monkeypatch, tmp_path): + events = [] + result = _scan_result() + monkeypatch.setattr( + connectors, + "scan_chroma_snapshot", + lambda *args, **kwargs: events.append("scan-cleaned") or result, + ) + monkeypatch.setattr( + reporting, + "build_report", + lambda *args, **kwargs: events.append("report-built") or "aggregate-report", + ) + monkeypatch.setattr( + reporting, + "_finalize_report", + lambda *args, **kwargs: events.append("report-finalized"), + ) + response = CliRunner().invoke( + cli.app, + [ + "scan", "--source", "chroma", "--snapshot", str(tmp_path / SOURCE_PATH_CANARY), + "--work-parent", str(tmp_path / WORK_PATH_CANARY), "--source-id", "source-1", + "--acknowledge-offline-complete-snapshot", "--report", str(tmp_path / REPORT_PATH_CANARY), + ], + ) + assert response.exit_code == 0 + assert events == ["scan-cleaned", "report-built", "report-finalized"] + assert "completed" in response.output.lower() + for canary in RAW_CANARIES: + assert canary not in response.output + + +def test_cli_rejects_legacy_path_without_source_or_report_access(tmp_path): + response = CliRunner().invoke( + cli.app, + ["scan", "--source", "chroma", "--path", str(tmp_path / SOURCE_PATH_CANARY)], + ) + assert response.exit_code == cli.EXIT_USAGE + assert "legacy --path" in response.output.lower() + assert SOURCE_PATH_CANARY not in response.output + assert not list(tmp_path.iterdir()) + + +@pytest.mark.parametrize("failure", ["scan", "build", "finalize"]) +def test_cli_failure_creates_no_report_state_webhook_or_success_signal( + monkeypatch, tmp_path, failure +): + report_path = tmp_path / REPORT_PATH_CANARY + if failure == "scan": + monkeypatch.setattr( + connectors, + "scan_chroma_snapshot", + lambda *args, **kwargs: (_ for _ in ()).throw( + connectors.ChromaSnapshotScanError() + ), + ) + else: + monkeypatch.setattr( + connectors, "scan_chroma_snapshot", lambda *args, **kwargs: _scan_result() + ) + if failure == "build": + monkeypatch.setattr( + reporting, + "build_report", + lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("document-text-canary") + ), + ) + else: + monkeypatch.setattr(reporting, "build_report", lambda *args, **kwargs: "x") + monkeypatch.setattr( + reporting, + "_finalize_report", + lambda *args, **kwargs: (_ for _ in ()).throw( + reporting.ReportFinalizationError() + ), + ) + response = CliRunner().invoke( + cli.app, + [ + "scan", + "--source", + "chroma", + "--snapshot", + str(tmp_path / SOURCE_PATH_CANARY), + "--work-parent", + str(tmp_path / WORK_PATH_CANARY), + "--source-id", + "source-1", + "--acknowledge-offline-complete-snapshot", + "--report", + str(report_path), + ], + ) + assert response.exit_code == cli.EXIT_SCAN_FAILURE + assert "completed" not in response.output.lower() + assert not report_path.exists() + assert not (tmp_path / ".rlg-state.json").exists() + assert not list(tmp_path.glob(".rlg-report-*.tmp")) + for canary in RAW_CANARIES: + assert canary not in response.output + + +def test_monitor_new_scan_boundary_remains_disabled_and_unchanged(): + result = CliRunner().invoke(cli.app, ["monitor", "--help"]) + assert result.exit_code == 0 + assert "new scans are disabled" in result.output.lower() + assert "--snapshot" not in result.output + + +def test_numeric_contract_is_exact_and_narrower_than_wp7c(): + limits = chroma_private._PUBLIC_CHROMA_SCAN_LIMITS + detector = chroma_private._DEFAULT_DETECTOR_LIMITS + assert limits.collections == 1_000 + assert limits.records == 10_000 + assert limits.source_segments == 100_000 + assert limits.source_utf8_bytes == 268_435_456 + assert limits.document_bytes == 65_536 + assert detector.segment_bytes == 65_536 + assert detector.findings_per_segment == 4_096 + assert detector.total_findings == 1_000_000 + assert detector.entity_types == 64 + assert chroma_private._MAX_DETECTOR_RESPONSE_PAYLOAD == 16_384 + assert reporting._MAX_FINAL_REPORT_BYTES == 1_048_576 + assert reporting._REPORT_FINALIZATION_SECONDS == 30.0 + assert chroma_private._GLOBAL_SECONDS == 1_200.0 + assert chroma_private._AUTOMATIC_RETRIES == 0 + + +def test_public_activation_gate_accepts_only_exact_159_on_five_tuples(monkeypatch): + monkeypatch.setattr(chroma_private.importlib.metadata, "version", lambda name: "1.5.9") + allowed = { + ("Linux", (3, 10)), ("Linux", (3, 11)), ("Linux", (3, 12)), + ("Darwin", (3, 12)), ("Windows", (3, 12)), + } + assert chroma_private._PUBLIC_ACTIVATION_ENVIRONMENTS == allowed + + +@pytest.mark.parametrize("version", ["1.5.0", "1.5.8", "1.5.10", "1.5.9rc1", "malformed"]) +def test_public_activation_rejects_every_non_159_version(monkeypatch, version): + monkeypatch.setattr(chroma_private.importlib.metadata, "version", lambda name: version) + with pytest.raises(chroma_private._ChromaScanError): + chroma_private._public_activation_gate() + + +def test_public_connector_rejects_private_150_candidate_before_snapshot_access( + monkeypatch, +): + prepared = [] + monkeypatch.setattr(connectors, "normalize_locale", lambda locale: locale) + monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) + monkeypatch.setattr( + chroma_private.importlib.metadata, "version", lambda name: "1.5.0" + ) + monkeypatch.setattr( + connectors._snapshot, + "_prepare_snapshot", + lambda *args, **kwargs: prepared.append((args, kwargs)), + ) + with pytest.raises(connectors.ChromaSnapshotUnavailableError): + connectors.scan_chroma_snapshot( + SOURCE_PATH_CANARY, + WORK_PATH_CANARY, + source_id="source-1", + acknowledge_offline_complete_snapshot=True, + ) + assert prepared == [] + + +@pytest.mark.parametrize( + "system,python,machine", + [ + ("Linux", (3, 9), "x86_64"), + ("Windows", (3, 11), "AMD64"), + ("Darwin", (3, 11), "arm64"), + ("FreeBSD", (3, 12), "amd64"), + ], +) +def test_every_unlisted_public_host_tuple_fails_before_snapshot_access( + monkeypatch, system, python, machine +): + prepared = [] + monkeypatch.setattr(connectors, "normalize_locale", lambda locale: locale) + monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) + monkeypatch.setattr( + chroma_private.importlib.metadata, "version", lambda name: "1.5.9" + ) + monkeypatch.setattr(chroma_private.platform, "system", lambda: system) + monkeypatch.setattr(chroma_private.platform, "machine", lambda: machine) + monkeypatch.setattr(chroma_private.platform, "mac_ver", lambda: ("15.0", (), ())) + monkeypatch.setattr( + chroma_private.sys, + "version_info", + SimpleNamespace(major=python[0], minor=python[1]), + ) + monkeypatch.setattr( + connectors._snapshot, + "_prepare_snapshot", + lambda *args, **kwargs: prepared.append((args, kwargs)), + ) + with pytest.raises(connectors.ChromaSnapshotUnavailableError): + connectors.scan_chroma_snapshot( + SOURCE_PATH_CANARY, + WORK_PATH_CANARY, + source_id="source-1", + acknowledge_offline_complete_snapshot=True, + ) + assert prepared == [] + + +def test_wp7c_private_candidates_and_ten_cell_matrix_remain_intact(): + root = Path(__file__).resolve().parents[1] + workflow = (root / ".github" / "workflows" / "wp7c-private-chroma.yml").read_text( + encoding="utf-8" + ) + assert chroma_private._CANDIDATES == frozenset({"1.5.0", "1.5.9"}) + assert workflow.count('chroma: "1.5.0"') == 5 + assert workflow.count('chroma: "1.5.9"') == 5 + + +def test_wp7d_workflow_has_five_exact_cells_and_mandatory_evidence(): + root = Path(__file__).resolve().parents[1] + workflow = (root / ".github" / "workflows" / "wp7d-snapshot-chroma.yml").read_text( + encoding="utf-8" + ) + assert workflow.count('chroma: "1.5.9"') == 5 + assert workflow.count("- os: ubuntu-latest") == 3 + assert workflow.count("- os: macos-15") == 1 + assert workflow.count("- os: windows-latest") == 1 + for exact in ( + "chromadb==${{ matrix.chroma }}", + ".[chroma-snapshot,detect,dev]", + "pip check", + "pip list --format=freeze --disable-pip-version-check", + "RLG_WP7D_ACTIVATION", + "RLG_WP7D_MANDATORY", + "RLG_WP7D_OS_EGRESS_DENIED", + "RLG_REQUIRE_NATIVE_SNAPSHOT_FS", + "iptables", + "sandbox-exec", + "New-NetFirewallRule", + "tests/test_wp7d_snapshot_activation.py", + "if: always()", + ): + assert exact in workflow + assert "upload-artifact" not in workflow + + +_ACTIVATION = os.environ.get("RLG_WP7D_ACTIVATION") == "1" +_MANDATORY_MATRIX = os.environ.get("RLG_WP7D_MANDATORY") == "1" +_activation = pytest.mark.skipif( + not _ACTIVATION and not _MANDATORY_MATRIX, + reason="runs only in the exact five-cell WP7D activation matrix", +) +_PUBLIC_FIXTURE_SCRIPT = textwrap.dedent( + """ + import sys + import chromadb + from chromadb.config import DEFAULT_DATABASE, DEFAULT_TENANT, Settings + + path = sys.argv[1] + settings = Settings( + _env_file=None, + anonymized_telemetry=False, + is_persistent=True, + persist_directory=path, + migrations="apply", + migrations_hash_algorithm="md5", + allow_reset=False, + chroma_server_host=None, + chroma_server_headers=None, + chroma_server_http_port=None, + chroma_server_ssl_enabled=False, + chroma_client_auth_provider=None, + chroma_client_auth_credentials=None, + chroma_otel_collection_endpoint="", + chroma_otel_collection_headers={}, + chroma_otel_granularity=None, + ) + client = chromadb.PersistentClient( + path=path, settings=settings, tenant=DEFAULT_TENANT, database=DEFAULT_DATABASE + ) + collection = client.create_collection("synthetic-public") + collection.add( + ids=["synthetic-a", "synthetic-b"], + embeddings=[[0.1, 0.2], [0.3, 0.4]], + documents=["alice@example.com", "plain"], + metadatas=[{"kind": "alpha"}, {"kind": "beta"}], + ) + """ +) + + +def _tree_hashes(root: Path): + return { + path.relative_to(root).parts: hashlib.sha256(path.read_bytes()).digest() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + +@_activation +def test_activation_environment_is_exact_native_and_os_egress_denied(tmp_path): + assert _ACTIVATION + assert _MANDATORY_MATRIX + assert os.environ.get("RLG_WP7D_OS_EGRESS_DENIED") == "1" + assert importlib.metadata.version("chromadb") == "1.5.9" + assert (platform.system(), sys.version_info[:2]) in ( + chroma_private._PUBLIC_ACTIVATION_ENVIRONMENTS + ) + assert chroma_private._filesystem_type(tmp_path) == { + "Linux": "ext4", + "Darwin": "apfs", + "Windows": "ntfs", + }[platform.system()] + assert chroma_private._public_activation_gate() == "1.5.9" + + +@_activation +def test_exact_candidate_public_scan_detects_aggregates_preserves_source_and_cleans( + tmp_path, +): + source = tmp_path / "source" + work = tmp_path / "work" + source.mkdir() + work.mkdir() + created = subprocess.run( + [sys.executable, "-c", _PUBLIC_FIXTURE_SCRIPT, str(source)], + capture_output=True, + timeout=180, + check=False, + ) + assert created.returncode == 0 + assert created.stdout == b"" + assert created.stderr == b"" + source_before = _tree_hashes(source) + + result = connectors.scan_chroma_snapshot( + source, + work, + source_id="synthetic-source", + acknowledge_offline_complete_snapshot=True, + ) + + assert ( + result.collections_completed, + result.records_completed, + result.source_segments_completed, + result.source_utf8_bytes_completed, + result.detector.records_with_findings, + result.detector.total_findings, + dict(result.detector.finding_counts_by_type), + ) == (1, 2, 6, 39, 1, 1, {"EMAIL_ADDRESS": 1}) + assert _tree_hashes(source) == source_before + assert list(work.iterdir()) == [] From 3ca740328bef7a9c5a77c95f51ef712098c48aa3 Mon Sep 17 00:00:00 2001 From: Belle <63379322+missabundance9@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:32:13 +1000 Subject: [PATCH 2/3] ci: preserve WP7C egress evidence in WP7D Export the inherited WP7C OS-egress marker while the WP7D matrix runs the unchanged private compatibility tests under the same verified network denial. --- .github/workflows/wp7d-snapshot-chroma.yml | 10 ++++++++-- tests/test_wp7d_snapshot_activation.py | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wp7d-snapshot-chroma.yml b/.github/workflows/wp7d-snapshot-chroma.yml index 0d0b6b6..f751956 100644 --- a/.github/workflows/wp7d-snapshot-chroma.yml +++ b/.github/workflows/wp7d-snapshot-chroma.yml @@ -124,6 +124,7 @@ jobs: PYTHONWARNINGS: ignore RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7C_OS_EGRESS_DENIED: "1" RLG_WP7D_ACTIVATION: "1" RLG_WP7D_MANDATORY: "1" RLG_WP7D_OS_EGRESS_DENIED: "1" @@ -131,7 +132,7 @@ jobs: run: | python_path="$(command -v python)" cd /mnt/rlg-wp7d-ext4/repository - sudo --preserve-env=ANONYMIZED_TELEMETRY,PYTHONWARNINGS,RLG_REQUIRE_NATIVE_SNAPSHOT_FS,RLG_WP7C_COMPATIBILITY,RLG_WP7D_ACTIVATION,RLG_WP7D_MANDATORY,RLG_WP7D_OS_EGRESS_DENIED,TMPDIR \ + sudo --preserve-env=ANONYMIZED_TELEMETRY,PYTHONWARNINGS,RLG_REQUIRE_NATIVE_SNAPSHOT_FS,RLG_WP7C_COMPATIBILITY,RLG_WP7C_OS_EGRESS_DENIED,RLG_WP7D_ACTIVATION,RLG_WP7D_MANDATORY,RLG_WP7D_OS_EGRESS_DENIED,TMPDIR \ -u "${RLG_WP7D_TEST_USER}" env HOME=/mnt/rlg-wp7d-ext4/home \ "${python_path}" -m pytest tests/test_wp7d_snapshot_activation.py tests/test_chroma_snapshot_private.py -q --basetemp /mnt/rlg-wp7d-ext4/focused @@ -143,6 +144,7 @@ jobs: PYTHONWARNINGS: ignore RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7C_OS_EGRESS_DENIED: "1" RLG_WP7D_ACTIVATION: "1" RLG_WP7D_MANDATORY: "1" RLG_WP7D_OS_EGRESS_DENIED: "1" @@ -150,7 +152,7 @@ jobs: run: | python_path="$(command -v python)" cd /mnt/rlg-wp7d-ext4/repository - sudo --preserve-env=ANONYMIZED_TELEMETRY,PYTHONWARNINGS,RLG_REQUIRE_NATIVE_SNAPSHOT_FS,RLG_WP7C_COMPATIBILITY,RLG_WP7D_ACTIVATION,RLG_WP7D_MANDATORY,RLG_WP7D_OS_EGRESS_DENIED,TMPDIR \ + sudo --preserve-env=ANONYMIZED_TELEMETRY,PYTHONWARNINGS,RLG_REQUIRE_NATIVE_SNAPSHOT_FS,RLG_WP7C_COMPATIBILITY,RLG_WP7C_OS_EGRESS_DENIED,RLG_WP7D_ACTIVATION,RLG_WP7D_MANDATORY,RLG_WP7D_OS_EGRESS_DENIED,TMPDIR \ -u "${RLG_WP7D_TEST_USER}" env HOME=/mnt/rlg-wp7d-ext4/home \ "${python_path}" -m pytest -q --basetemp /mnt/rlg-wp7d-ext4/complete @@ -162,6 +164,7 @@ jobs: PYTHONWARNINGS: ignore RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7C_OS_EGRESS_DENIED: "1" RLG_WP7D_ACTIVATION: "1" RLG_WP7D_MANDATORY: "1" RLG_WP7D_OS_EGRESS_DENIED: "1" @@ -175,6 +178,7 @@ jobs: PYTHONWARNINGS: ignore RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7C_OS_EGRESS_DENIED: "1" RLG_WP7D_ACTIVATION: "1" RLG_WP7D_MANDATORY: "1" RLG_WP7D_OS_EGRESS_DENIED: "1" @@ -188,6 +192,7 @@ jobs: PYTHONWARNINGS: ignore RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7C_OS_EGRESS_DENIED: "1" RLG_WP7D_ACTIVATION: "1" RLG_WP7D_MANDATORY: "1" RLG_WP7D_OS_EGRESS_DENIED: "1" @@ -201,6 +206,7 @@ jobs: PYTHONWARNINGS: ignore RLG_REQUIRE_NATIVE_SNAPSHOT_FS: "1" RLG_WP7C_COMPATIBILITY: "1" + RLG_WP7C_OS_EGRESS_DENIED: "1" RLG_WP7D_ACTIVATION: "1" RLG_WP7D_MANDATORY: "1" RLG_WP7D_OS_EGRESS_DENIED: "1" diff --git a/tests/test_wp7d_snapshot_activation.py b/tests/test_wp7d_snapshot_activation.py index 09e2122..378bff2 100644 --- a/tests/test_wp7d_snapshot_activation.py +++ b/tests/test_wp7d_snapshot_activation.py @@ -903,6 +903,7 @@ def test_wp7d_workflow_has_five_exact_cells_and_mandatory_evidence(): "RLG_WP7D_ACTIVATION", "RLG_WP7D_MANDATORY", "RLG_WP7D_OS_EGRESS_DENIED", + "RLG_WP7C_OS_EGRESS_DENIED", "RLG_REQUIRE_NATIVE_SNAPSHOT_FS", "iptables", "sandbox-exec", From 18d6ea5d2265eb5dab8e421061416ba1f2e02448 Mon Sep 17 00:00:00 2001 From: Belle <63379322+missabundance9@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:06:07 +1000 Subject: [PATCH 3/3] fix: harden WP7D privacy and pre-source gates --- docs/ARCHITECTURE.md | 5 +- src/ragleakguard/_chroma_snapshot.py | 19 +- src/ragleakguard/connectors.py | 43 ++- src/ragleakguard/report.py | 94 +++++-- tests/test_fail_closed.py | 2 +- tests/test_wp7d_snapshot_activation.py | 369 ++++++++++++++++++++++++- 6 files changed, 480 insertions(+), 52 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1dac0f0..bb28e3e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -39,8 +39,9 @@ iterator or scan session. For `scan`, legacy `--path` is rejected before source access. The only active route requires `--snapshot`, `--work-parent`, a narrow pseudonymous `--source-id`, and explicit offline/complete -acknowledgement. Locale syntax, detector runtime, exact ChromaDB 1.5.9, and platform/Python gates -also precede source access. The filesystem gate is then revalidated on the WP7B work copy. +acknowledgement. Locale syntax, detector runtime, strict work-parent validation, and the exact +ChromaDB 1.5.9 platform/Python/native-filesystem gate also precede source access. The same exact +environment tuple is revalidated on the actual WP7B work copy before enumeration. For `monitor`, source/path and locale usage validation is followed by existing webhook configuration, monitor-key, state, and scope authentication. A valid pending alert retains WP6 precedence and is diff --git a/src/ragleakguard/_chroma_snapshot.py b/src/ragleakguard/_chroma_snapshot.py index f8eb5d8..bf4ef73 100644 --- a/src/ragleakguard/_chroma_snapshot.py +++ b/src/ragleakguard/_chroma_snapshot.py @@ -435,8 +435,10 @@ def _candidate_version() -> str: return raw -def _public_activation_gate() -> str: - """Reject every non-WP7D dependency or host tuple before source access.""" +def _public_activation_gate(work_parent: object) -> str: + """Reject every non-WP7D dependency, host, or filesystem tuple pre-source.""" + validated_work_parent, _ = _snapshot._strict_directory_path(work_parent) + filesystem = _filesystem_type(validated_work_parent) version = _candidate_version() system = platform.system() python = (sys.version_info.major, sys.version_info.minor) @@ -455,6 +457,7 @@ def _public_activation_gate() -> str: raise _ChromaScanError() except (ValueError, IndexError): raise _ChromaScanError() from None + _validate_environment(version, filesystem) return version @@ -533,10 +536,14 @@ class _StatFs(ctypes.Structure): raise _ChromaScanError() -def _environment_gate(version: str, path: Path) -> Tuple[str, Tuple[int, int], str]: +def _validate_environment( + version: str, + filesystem: str, +) -> Tuple[str, Tuple[int, int], str]: system = platform.system() python = (sys.version_info.major, sys.version_info.minor) - filesystem = _filesystem_type(path) + if type(filesystem) is not str: + raise _ChromaScanError() machine = platform.machine().lower() if (version, system, python, filesystem) not in _EFFECT_ALLOWLIST: raise _ChromaScanError() @@ -555,6 +562,10 @@ def _environment_gate(version: str, path: Path) -> Tuple[str, Tuple[int, int], s return system, python, filesystem +def _environment_gate(version: str, path: Path) -> Tuple[str, Tuple[int, int], str]: + return _validate_environment(version, _filesystem_type(path)) + + def _regular_file(path: Path, root_device: int) -> _snapshot._Identity: try: raw = os.lstat(path) diff --git a/src/ragleakguard/connectors.py b/src/ragleakguard/connectors.py index b845a3d..5ea5e99 100644 --- a/src/ragleakguard/connectors.py +++ b/src/ragleakguard/connectors.py @@ -62,6 +62,28 @@ def __init__(self) -> None: super().__init__(CHROMA_SNAPSHOT_FAILURE_MESSAGE) +def _scrub_public_error(error: BaseException) -> BaseException: + """Detach every retained exception before a static failure crosses the boundary.""" + pending = [error] + seen = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + if current.__cause__ is not None: + pending.append(current.__cause__) + if current.__context__ is not None: + pending.append(current.__context__) + nested = getattr(current, "exceptions", ()) + if type(nested) is tuple: + pending.extend(nested) + current.__cause__ = None + current.__context__ = None + current.__suppress_context__ = True + return error + + def _bounded_integer(value: object, maximum: int) -> int: if type(value) is not int or value < 0 or value > maximum: raise ValueError("Aggregate counter is outside the WP7D contract.") @@ -179,11 +201,15 @@ def scan_chroma_snapshot( validate_chroma_source_id(source_id) normalized_locale = normalize_locale(locale) normalized_locale = validate_detection_runtime(normalized_locale) + activation_failure = None try: - _chroma_snapshot._public_activation_gate() + _chroma_snapshot._public_activation_gate(work_parent) except BaseException: - raise ChromaSnapshotUnavailableError() from None + activation_failure = ChromaSnapshotUnavailableError() + if activation_failure is not None: + raise _scrub_public_error(activation_failure) + preparation_failure = None try: prepared = _snapshot._prepare_snapshot( snapshot, @@ -193,7 +219,9 @@ def scan_chroma_snapshot( except (KeyboardInterrupt, SystemExit): raise except BaseException: - raise ChromaSnapshotScanError() from None + preparation_failure = ChromaSnapshotScanError() + if preparation_failure is not None: + raise _scrub_public_error(preparation_failure) receipt = None detector_document = None @@ -208,12 +236,16 @@ def scan_chroma_snapshot( ) except BaseException: failed = True + cleanup_failure = None try: prepared.cleanup() except BaseException: - raise ChromaSnapshotScanError() from None + cleanup_failure = ChromaSnapshotScanError() + if cleanup_failure is not None: + raise _scrub_public_error(cleanup_failure) if failed or receipt is None or type(detector_document) is not dict: raise ChromaSnapshotScanError() from None + result_failure = None try: detector = DetectorAggregate(**detector_document) return ChromaSnapshotScanResult( @@ -224,7 +256,8 @@ def scan_chroma_snapshot( detector=detector, ) except BaseException: - raise ChromaSnapshotScanError() from None + result_failure = ChromaSnapshotScanError() + raise _scrub_public_error(result_failure) def read_pinecone(index: str) -> Iterator[Dict[str, Any]]: diff --git a/src/ragleakguard/report.py b/src/ragleakguard/report.py index b3c4a29..07bd8e7 100644 --- a/src/ragleakguard/report.py +++ b/src/ragleakguard/report.py @@ -50,19 +50,40 @@ def __init__(self) -> None: super().__init__(_REPORT_FAILURE) -def _scrub_report_error() -> ReportFinalizationError: - error = ReportFinalizationError() - error.__cause__ = None - error.__context__ = None - error.__suppress_context__ = True +def _scrub_report_error( + error: Optional[ReportFinalizationError] = None, +) -> ReportFinalizationError: + """Detach every retained exception that could carry operator-controlled data.""" + if type(error) is not ReportFinalizationError: + error = ReportFinalizationError() + pending = [error] + seen = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + if current.__cause__ is not None: + pending.append(current.__cause__) + if current.__context__ is not None: + pending.append(current.__context__) + nested = getattr(current, "exceptions", ()) + if type(nested) is tuple: + pending.extend(nested) + current.__cause__ = None + current.__context__ = None + current.__suppress_context__ = True return error def _report_now(clock: Callable[[], float]) -> float: + failure = None try: value = clock() except BaseException: - raise ReportFinalizationError() from None + failure = ReportFinalizationError() + if failure is not None: + raise _scrub_report_error(failure) if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): raise ReportFinalizationError() return float(value) @@ -132,6 +153,8 @@ def _report_identity(path: Path): def _read_existing_report(path: Path): + failure = None + result = None try: identity = _report_identity(path) flags = os.O_RDONLY | int(getattr(os, "O_BINARY", 0)) @@ -152,13 +175,16 @@ def _read_existing_report(path: Path): total += len(chunk) if total > _MAX_FINAL_REPORT_BYTES: raise ReportFinalizationError() - return identity, b"".join(chunks) + result = (identity, b"".join(chunks)) finally: os.close(descriptor) - except ReportFinalizationError: - raise + except ReportFinalizationError as error: + failure = error except BaseException: - raise ReportFinalizationError() from None + failure = ReportFinalizationError() + if failure is not None: + raise _scrub_report_error(failure) + return result def _write_all(descriptor: int, encoded: bytes) -> None: @@ -177,20 +203,25 @@ def _new_report_temp( clock: Callable[[], float], token_source: Callable[[int], bytes], ): - _report_check(deadline, clock) - try: - token = token_source(16) - except BaseException: - raise ReportFinalizationError() from None - if type(token) is not bytes or len(token) != 16: - raise ReportFinalizationError() - path = parent / (_REPORT_TEMP_PREFIX + token.hex() + _REPORT_TEMP_SUFFIX) - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | int(getattr(os, "O_BINARY", 0)) - flags |= int(getattr(os, "O_NOFOLLOW", 0)) + failure = None + result = None + path = None descriptor = None owned_identity = None failed = True try: + _report_check(deadline, clock) + try: + token = token_source(16) + except BaseException: + raise ReportFinalizationError() from None + if type(token) is not bytes or len(token) != 16: + raise ReportFinalizationError() + path = parent / (_REPORT_TEMP_PREFIX + token.hex() + _REPORT_TEMP_SUFFIX) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | int( + getattr(os, "O_BINARY", 0) + ) + flags |= int(getattr(os, "O_NOFOLLOW", 0)) descriptor = os.open(path, flags, 0o600) _snapshot._harden(path, False) owned_identity = _snapshot._identity(os.fstat(descriptor)) @@ -202,24 +233,27 @@ def _new_report_temp( if identity.size != len(encoded): raise ReportFinalizationError() failed = False - return path, identity - except ReportFinalizationError: - raise + result = (path, identity) + except ReportFinalizationError as error: + failure = error except BaseException: - raise ReportFinalizationError() from None + failure = ReportFinalizationError() finally: if descriptor is not None: try: os.close(descriptor) except BaseException: pass - if failed and owned_identity is not None: + if failed and path is not None and owned_identity is not None: try: observed = _report_identity(path) if _snapshot._same_object(observed, owned_identity): os.unlink(path) except BaseException: pass + if failure is not None: + raise _scrub_report_error(failure) + return result def _remove_owned_temp(path: Optional[Path], identity) -> None: @@ -264,6 +298,7 @@ def _finalize_report( existing_identity = None existing_bytes = None target_path = None + failure = None try: if type(markdown) is not str: raise ReportFinalizationError() @@ -328,7 +363,11 @@ def _finalize_report( ): raise ReportFinalizationError() _snapshot._assert_restrictive(target_path, False) - except BaseException: + except BaseException as error: + failure = ( + error if type(error) is ReportFinalizationError + else ReportFinalizationError() + ) if replaced and target_path is not None: try: if not _snapshot._same_object( @@ -354,7 +393,8 @@ def _finalize_report( except BaseException: pass _remove_owned_temp(temporary, temporary_identity) - raise _scrub_report_error() + if failure is not None: + raise _scrub_report_error(failure) def _risk_level( diff --git a/tests/test_fail_closed.py b/tests/test_fail_closed.py index ce1285b..f6242e7 100644 --- a/tests/test_fail_closed.py +++ b/tests/test_fail_closed.py @@ -214,7 +214,7 @@ def test_scan_supported_locale_forms_remain_compatible( monkeypatch.setattr( connectors._chroma_snapshot, "_public_activation_gate", - lambda: (_ for _ in ()).throw(RuntimeError()), + lambda path: (_ for _ in ()).throw(RuntimeError()), ) monkeypatch.setattr( connectors._snapshot, diff --git a/tests/test_wp7d_snapshot_activation.py b/tests/test_wp7d_snapshot_activation.py index 378bff2..8886ec6 100644 --- a/tests/test_wp7d_snapshot_activation.py +++ b/tests/test_wp7d_snapshot_activation.py @@ -1,7 +1,9 @@ """WP7D public operator-snapshot activation and finalization regressions.""" from __future__ import annotations +import builtins import dataclasses +import errno import hashlib import importlib.metadata import os @@ -39,6 +41,68 @@ ) +def _exception_graph(error): + pending = [error] + seen = set() + nodes = [] + surfaces = [] + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + nodes.append(current) + surfaces.extend( + ( + f"{type(current).__module__}.{type(current).__qualname__}", + str(current), + repr(current), + repr(current.args), + repr(getattr(current, "filename", None)), + repr(getattr(current, "filename2", None)), + "".join( + traceback.format_exception( + type(current), current, current.__traceback__ + ) + ), + ) + ) + if current.__cause__ is not None: + pending.append(current.__cause__) + if current.__context__ is not None: + pending.append(current.__context__) + nested = getattr(current, "exceptions", ()) + if type(nested) is tuple: + pending.extend(nested) + return nodes, "\n".join(surfaces) + + +def _assert_chain_free_static_error(error, expected_type, *canaries): + nodes, surfaces = _exception_graph(error) + assert type(error) is expected_type + assert nodes == [error] + assert error.__cause__ is None + assert error.__context__ is None + assert error.__suppress_context__ is True + assert not any(isinstance(node, OSError) for node in nodes) + assert not any( + isinstance(node, importlib.metadata.PackageNotFoundError) for node in nodes + ) + for canary in canaries: + assert str(canary) not in surfaces + + +class _VersionInfo(tuple): + def __new__(cls, major, minor): + return super().__new__(cls, (major, minor, 0, "final", 0)) + + major = property(lambda self: self[0]) + minor = property(lambda self: self[1]) + micro = property(lambda self: self[2]) + releaselevel = property(lambda self: self[3]) + serial = property(lambda self: self[4]) + + def _detector_aggregate(**overrides): values = { "records_completed": 2, @@ -131,7 +195,7 @@ def test_every_public_preflight_gate_fails_before_snapshot_preparation( ) monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) monkeypatch.setattr( - connectors._chroma_snapshot, "_public_activation_gate", lambda: "1.5.9" + connectors._chroma_snapshot, "_public_activation_gate", lambda path: "1.5.9" ) kwargs = { "source_id": "source-1", @@ -161,7 +225,7 @@ def test_every_public_preflight_gate_fails_before_snapshot_preparation( monkeypatch.setattr( connectors._chroma_snapshot, "_public_activation_gate", - lambda: (_ for _ in ()).throw(chroma_private._ChromaScanError()), + lambda path: (_ for _ in ()).throw(chroma_private._ChromaScanError()), ) expected = connectors.InvalidChromaSnapshotRequest if stage in { @@ -246,7 +310,7 @@ def _successful_public_preflight(monkeypatch): monkeypatch.setattr(connectors, "normalize_locale", lambda locale: locale) monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) monkeypatch.setattr( - connectors._chroma_snapshot, "_public_activation_gate", lambda: "1.5.9" + connectors._chroma_snapshot, "_public_activation_gate", lambda path: "1.5.9" ) @@ -532,6 +596,131 @@ def test_duplicate_worker_aggregate_key_is_rejected_during_frame_decode(): ) +@pytest.mark.parametrize( + "failure", + ["locked-target", "missing-parent", "temp-create", "existing-read", "clock"], +) +def test_report_failure_exception_graph_drops_paths_and_underlying_os_errors( + tmp_path, monkeypatch, failure +): + parent = tmp_path / ("missing-parent" if failure == "missing-parent" else "reports") + if failure != "missing-parent": + parent.mkdir() + target = parent / REPORT_PATH_CANARY + existing = failure in {"locked-target", "temp-create", "existing-read"} + if existing: + target.write_bytes(b"existing-report-sentinel") + before = target.read_bytes() + else: + before = None + + exception_text = "operator-exception-text-canary" + secondary_path = tmp_path / "secondary-filename-canary" + if failure == "locked-target": + monkeypatch.setattr( + reporting.os, + "replace", + lambda *args: (_ for _ in ()).throw( + PermissionError( + errno.EACCES, + exception_text, + str(target), + None, + str(secondary_path), + ) + ), + ) + elif failure == "temp-create": + real_open = reporting.os.open + + def fail_temp_open(path, *args, **kwargs): + if Path(path).name.startswith(reporting._REPORT_TEMP_PREFIX): + raise PermissionError(errno.EACCES, exception_text, str(path)) + return real_open(path, *args, **kwargs) + + monkeypatch.setattr(reporting.os, "open", fail_temp_open) + elif failure == "existing-read": + monkeypatch.setattr( + reporting.os, + "read", + lambda *args: (_ for _ in ()).throw( + OSError(errno.EIO, exception_text, str(target)) + ), + ) + elif failure == "clock": + monkeypatch.setattr( + reporting.time, + "monotonic", + lambda: (_ for _ in ()).throw( + OSError(errno.EIO, exception_text, str(target)) + ), + ) + + clock = reporting.time.monotonic if failure == "clock" else (lambda: 0.0) + with pytest.raises(reporting.ReportFinalizationError) as caught: + reporting._finalize_report("replacement", target, clock=clock) + + _assert_chain_free_static_error( + caught.value, + reporting.ReportFinalizationError, + target, + parent, + secondary_path, + exception_text, + REPORT_PATH_CANARY, + ) + if existing: + assert target.read_bytes() == before + else: + assert not target.exists() + if parent.exists(): + assert not list(parent.glob(".rlg-report-*.tmp")) + + +@pytest.mark.skipif( + not hasattr(builtins, "ExceptionGroup"), + reason="nested exception groups require Python 3.11 or newer", +) +def test_report_failure_scrubs_nested_exception_groups(monkeypatch, tmp_path): + target = tmp_path / REPORT_PATH_CANARY + leaf = OSError( + errno.EIO, + "nested-exception-text-canary", + str(target), + ) + nested = builtins.ExceptionGroup( + "outer-exception-group-canary", + [ + builtins.ExceptionGroup("inner-exception-group-canary", [leaf]), + PermissionError( + errno.EACCES, + "nested-permission-canary", + str(tmp_path / "secondary-exception-group-path-canary"), + ), + ], + ) + + def fail_with_group(*args): + raise reporting.ReportFinalizationError() from nested + + monkeypatch.setattr(reporting, "_write_all", fail_with_group) + with pytest.raises(reporting.ReportFinalizationError) as caught: + reporting._finalize_report("replacement", target, clock=lambda: 0.0) + + _assert_chain_free_static_error( + caught.value, + reporting.ReportFinalizationError, + target, + "nested-exception-text-canary", + "outer-exception-group-canary", + "inner-exception-group-canary", + "nested-permission-canary", + "secondary-exception-group-path-canary", + ) + assert not target.exists() + assert not list(tmp_path.glob(".rlg-report-*.tmp")) + + def test_report_finalization_is_atomic_restrictive_bounded_and_path_free(tmp_path): target = tmp_path / REPORT_PATH_CANARY reporting._finalize_report("synthetic report", target) @@ -795,26 +984,178 @@ def test_numeric_contract_is_exact_and_narrower_than_wp7c(): assert chroma_private._AUTOMATIC_RETRIES == 0 -def test_public_activation_gate_accepts_only_exact_159_on_five_tuples(monkeypatch): - monkeypatch.setattr(chroma_private.importlib.metadata, "version", lambda name: "1.5.9") +def _patch_activation_tuple(monkeypatch, system, python, machine, filesystem): + monkeypatch.setattr( + chroma_private.importlib.metadata, "version", lambda name: "1.5.9" + ) + monkeypatch.setattr(chroma_private.platform, "system", lambda: system) + monkeypatch.setattr(chroma_private.platform, "machine", lambda: machine) + monkeypatch.setattr(chroma_private.platform, "mac_ver", lambda: ("15.0", (), ())) + monkeypatch.setattr( + chroma_private.sys, + "version_info", + _VersionInfo(*python), + ) + monkeypatch.setattr( + chroma_private, "_filesystem_type", lambda path: filesystem + ) + + +@pytest.mark.parametrize( + "system,python,machine,filesystem", + [ + ("Linux", (3, 10), "x86_64", "ext4"), + ("Linux", (3, 11), "amd64", "ext4"), + ("Linux", (3, 12), "x86_64", "ext4"), + ("Darwin", (3, 12), "arm64", "apfs"), + ("Windows", (3, 12), "AMD64", "ntfs"), + ], +) +def test_public_activation_gate_accepts_only_exact_159_on_five_tuples( + monkeypatch, tmp_path, system, python, machine, filesystem +): + _patch_activation_tuple(monkeypatch, system, python, machine, filesystem) allowed = { ("Linux", (3, 10)), ("Linux", (3, 11)), ("Linux", (3, 12)), ("Darwin", (3, 12)), ("Windows", (3, 12)), } assert chroma_private._PUBLIC_ACTIVATION_ENVIRONMENTS == allowed + assert chroma_private._public_activation_gate(tmp_path) == "1.5.9" @pytest.mark.parametrize("version", ["1.5.0", "1.5.8", "1.5.10", "1.5.9rc1", "malformed"]) -def test_public_activation_rejects_every_non_159_version(monkeypatch, version): +def test_public_activation_rejects_every_non_159_version( + monkeypatch, tmp_path, version +): monkeypatch.setattr(chroma_private.importlib.metadata, "version", lambda name: version) with pytest.raises(chroma_private._ChromaScanError): - chroma_private._public_activation_gate() + chroma_private._public_activation_gate(tmp_path) + + +def test_public_dependency_gate_drops_complete_dependency_exception_graph( + monkeypatch, tmp_path +): + work = tmp_path / WORK_PATH_CANARY + work.mkdir() + prepared = [] + monkeypatch.setattr(connectors, "normalize_locale", lambda locale: locale) + monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) + + def missing_dependency(name): + raise importlib.metadata.PackageNotFoundError( + "dependency-exception-canary" + ) + + monkeypatch.setattr( + chroma_private.importlib.metadata, "version", missing_dependency + ) + monkeypatch.setattr( + connectors._snapshot, + "_prepare_snapshot", + lambda *args, **kwargs: prepared.append((args, kwargs)), + ) + + with pytest.raises(connectors.ChromaSnapshotUnavailableError) as caught: + connectors.scan_chroma_snapshot( + SOURCE_PATH_CANARY, + work, + source_id="source-1", + acknowledge_offline_complete_snapshot=True, + ) + + _assert_chain_free_static_error( + caught.value, + connectors.ChromaSnapshotUnavailableError, + SOURCE_PATH_CANARY, + work, + "dependency-exception-canary", + ) + assert prepared == [] + + +@pytest.mark.parametrize("filesystem", ["btrfs", "tmpfs", "exfat"]) +def test_unsupported_work_parent_filesystem_fails_before_any_snapshot_access( + monkeypatch, tmp_path, capsys, filesystem +): + work = tmp_path / WORK_PATH_CANARY + work.mkdir() + source_events = [] + prepared = [] + gate_events = [] + + class SnapshotProbe: + def __fspath__(self): + source_events.append("fspath") + raise AssertionError(SOURCE_PATH_CANARY) + + def __str__(self): + source_events.append("str") + raise AssertionError(SOURCE_PATH_CANARY) + + _patch_activation_tuple( + monkeypatch, "Windows", (3, 12), "AMD64", filesystem + ) + real_strict_directory_path = chroma_private._snapshot._strict_directory_path + + def strict_work_parent(value): + gate_events.append("strict-work-parent") + return real_strict_directory_path(value) + + monkeypatch.setattr( + chroma_private._snapshot, + "_strict_directory_path", + strict_work_parent, + ) + monkeypatch.setattr( + chroma_private, + "_filesystem_type", + lambda path: gate_events.append("filesystem") or filesystem, + ) + monkeypatch.setattr( + chroma_private.importlib.metadata, + "version", + lambda name: gate_events.append("version") or "1.5.9", + ) + monkeypatch.setattr(connectors, "normalize_locale", lambda locale: locale) + monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) + + def prepare_must_not_run(*args, **kwargs): + prepared.append((args, kwargs)) + raise AssertionError("snapshot-preparation-canary") + + monkeypatch.setattr( + connectors._snapshot, "_prepare_snapshot", prepare_must_not_run + ) + + with pytest.raises(connectors.ChromaSnapshotUnavailableError) as caught: + connectors.scan_chroma_snapshot( + SnapshotProbe(), + work, + source_id="source-1", + acknowledge_offline_complete_snapshot=True, + ) + + _assert_chain_free_static_error( + caught.value, + connectors.ChromaSnapshotUnavailableError, + SOURCE_PATH_CANARY, + work, + "snapshot-preparation-canary", + ) + assert prepared == [] + assert source_events == [] + assert gate_events == ["strict-work-parent", "filesystem", "version"] + assert capsys.readouterr() == ("", "") + assert list(work.iterdir()) == [] + assert {path.name for path in tmp_path.iterdir()} == {WORK_PATH_CANARY} def test_public_connector_rejects_private_150_candidate_before_snapshot_access( - monkeypatch, + monkeypatch, tmp_path ): prepared = [] + work = tmp_path / WORK_PATH_CANARY + work.mkdir() monkeypatch.setattr(connectors, "normalize_locale", lambda locale: locale) monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) monkeypatch.setattr( @@ -828,7 +1169,7 @@ def test_public_connector_rejects_private_150_candidate_before_snapshot_access( with pytest.raises(connectors.ChromaSnapshotUnavailableError): connectors.scan_chroma_snapshot( SOURCE_PATH_CANARY, - WORK_PATH_CANARY, + work, source_id="source-1", acknowledge_offline_complete_snapshot=True, ) @@ -845,9 +1186,11 @@ def test_public_connector_rejects_private_150_candidate_before_snapshot_access( ], ) def test_every_unlisted_public_host_tuple_fails_before_snapshot_access( - monkeypatch, system, python, machine + monkeypatch, tmp_path, system, python, machine ): prepared = [] + work = tmp_path / WORK_PATH_CANARY + work.mkdir() monkeypatch.setattr(connectors, "normalize_locale", lambda locale: locale) monkeypatch.setattr(connectors, "validate_detection_runtime", lambda locale: locale) monkeypatch.setattr( @@ -859,7 +1202,7 @@ def test_every_unlisted_public_host_tuple_fails_before_snapshot_access( monkeypatch.setattr( chroma_private.sys, "version_info", - SimpleNamespace(major=python[0], minor=python[1]), + _VersionInfo(*python), ) monkeypatch.setattr( connectors._snapshot, @@ -869,7 +1212,7 @@ def test_every_unlisted_public_host_tuple_fails_before_snapshot_access( with pytest.raises(connectors.ChromaSnapshotUnavailableError): connectors.scan_chroma_snapshot( SOURCE_PATH_CANARY, - WORK_PATH_CANARY, + work, source_id="source-1", acknowledge_offline_complete_snapshot=True, ) @@ -982,7 +1325,7 @@ def test_activation_environment_is_exact_native_and_os_egress_denied(tmp_path): "Darwin": "apfs", "Windows": "ntfs", }[platform.system()] - assert chroma_private._public_activation_gate() == "1.5.9" + assert chroma_private._public_activation_gate(tmp_path) == "1.5.9" @_activation