SECURITY DOC: the shipped README claims the plugin worker has "no filesystem and no network"; both are false, and our own threat model says so #114

Closed
opened 2026-09-04 15:59:31 +02:00 by buildagent · 1 comment
Member

Found while auditing #79's containment criteria. Verified first-hand in source before filing, and the README half is already fixed in the working tree (uncommitted) — filing it because it shipped, in v0.26.1, released today.

The claim

README.md:299-301, as shipped:

A plugin package (.cip) is a language extractor that runs inside code-index-plugin-host, a sandboxed worker with no filesystem and no network.

Both halves are false, and we already knew

Filesystem — false on every platform

There is no filesystem confinement whatsoever. The decisive artefact is a passing test, named for the property it proves:

crates/daemon/tests/escaped_worker_probes.rs
    fn an_absolute_path_stays_open_to_a_confined_process()

Its own doc is explicit that this is deliberate and that the opposite assertion would be a lie:

That is the strongest true statement available, and a filesystem probe asserting the opposite would be asserting a property this product does not have. So this asserts the DOCUMENTED behaviour. It is a test that is meant to be deleted one day: the day a filesystem filter is added it goes red, and the two documents have to be corrected in the same change rather than a year later.

_prdoc/guides/80-threat-model.md §6 says the same in plain terms:

Nothing here is a sandbox in the kernel sense. There is no namespace and no capability drop… Code that escaped wasmtime inside a worker could still read any file the invoking user can read — the project's own source, ~/.ssh, the daemon's database. The cwd is private and empty; absolute paths are unaffected by that.

Network — false everywhere except Linux x86_64/aarch64

crates/plugin-supervisor/src/contain.rs:1279-1286 gates the entire seccomp module:

#[cfg(all(target_os = "linux",
          any(target_arch = "x86_64", target_arch = "aarch64")))]
pub(crate) mod linux { … }

Everything else returns NetworkDenial::Unsupported. The threat model states it: "On any platform that is not linux, and on a linux this build has no syscall table for, there is no filter at all." So the claim is false on Windows and macOS, both of which we ship.

The part that is true, and should be said instead

The guest is wasm with zero imports — it cannot name a syscall at all, on any platform. That is a genuinely strong, structural property and it is what the README should be claiming. The distinction the shipped text loses is between the guest as loaded (which truly has no filesystem and no network) and the worker process it runs in (which is not a sandbox).

A second, smaller finding from the same check

The threat model says the unfiltered case is disclosed — "and the worker says so, in the payload." That is true only of the worker's self-report to its host. NetworkDenial has 33 references, every one inside crates/plugin-supervisor and crates/plugin-host (verified with find_references, not grep). It reaches no MCP payload, no plugin status, no index_coverage.

crates/plugin-host/src/main.rs:88-94 confirms the intent and its limit — the eprintln! is explicitly "for an operator running a worker by hand", and the parent reads none of it because stdio is null.

So on an unfiltered platform an operator has to know, rather than be told. That is a real disclosure gap and it is the more interesting half of this issue: the product's own rule is that absent must never read as negative.

I nearly shipped this same error while fixing it — my first draft of the README correction said the worker "says so in its payload", which is exactly the overclaim above. It survived only because I checked NetworkDenial's references before leaving the edit.

What was done, and what is left

  • Done (in tree, uncommitted): README rewritten to state the zero-imports property as the real guarantee, to say plainly that the worker is not a kernel sandbox, to scope network denial to Linux x86_64/aarch64, and to point at the threat model's §6 and §7.
  • Still open: surface the filter state to an operator. A per-package or per-project field carrying enforced / not_requested / unsupported / unavailable:<errno>, on the three-state contract — absent means this build did not report, never "fine". NetworkDenial's own doc already has the right design and says so: "EVERY VARIANT IS A STATEMENT. There is no variant meaning 'fine' that a worker could reach by doing nothing." It just stops at the crate boundary.

Why this is worth a tracked record rather than a silent doc fix

A security overclaim in a README is the one piece of documentation a user reads before deciding to trust a package. We had the honest analysis written down in 80-threat-model.md the whole time — the defect was that the summary aimed at users contradicted the document aimed at reviewers. Worth a checkable rule that the README's security claims cannot exceed the threat model's, rather than trusting the next edit to remember.

Found auditing #79. Same family as #109 and #108 (a gate green while the thing it checked was false). The disclosure half belongs with the honesty work in #101/#99/#107.

Found while auditing #79's containment criteria. **Verified first-hand in source before filing**, and the README half is already **fixed in the working tree** (uncommitted) — filing it because it shipped, in v0.26.1, released today. ## The claim `README.md:299-301`, as shipped: > A plugin package (`.cip`) is a language extractor that runs inside `code-index-plugin-host`, **a sandboxed worker with no filesystem and no network.** ## Both halves are false, and we already knew ### Filesystem — false on **every** platform There is no filesystem confinement whatsoever. The decisive artefact is a **passing test**, named for the property it proves: ``` crates/daemon/tests/escaped_worker_probes.rs fn an_absolute_path_stays_open_to_a_confined_process() ``` Its own doc is explicit that this is deliberate and that the opposite assertion would be a lie: > That is the strongest true statement available, and a filesystem probe asserting the opposite would be asserting a property this product does not have. So this asserts the DOCUMENTED behaviour. It is a test that is meant to be deleted one day: the day a filesystem filter is added it goes red, and the two documents have to be corrected in the same change rather than a year later. `_prdoc/guides/80-threat-model.md` §6 says the same in plain terms: > Nothing here is a sandbox in the kernel sense. There is no namespace and no capability drop… Code that escaped wasmtime inside a worker could still **read any file the invoking user can read** — the project's own source, `~/.ssh`, the daemon's database. The cwd is private and empty; absolute paths are unaffected by that. ### Network — false everywhere except Linux x86_64/aarch64 `crates/plugin-supervisor/src/contain.rs:1279-1286` gates the entire seccomp module: ```rust #[cfg(all(target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64")))] pub(crate) mod linux { … } ``` Everything else returns `NetworkDenial::Unsupported`. The threat model states it: *"On any platform that is not linux, and on a linux this build has no syscall table for, there is no filter at all."* So the claim is false on **Windows and macOS**, both of which we ship. ## The part that is true, and should be said instead The guest is wasm with **zero imports** — it cannot name a syscall at all, on any platform. That is a genuinely strong, structural property and it is what the README should be claiming. The distinction the shipped text loses is between *the guest as loaded* (which truly has no filesystem and no network) and *the worker process it runs in* (which is not a sandbox). ## A second, smaller finding from the same check The threat model says the unfiltered case is disclosed — *"and the worker says so, in the payload."* That is true only of the **worker's self-report to its host**. `NetworkDenial` has **33 references, every one inside `crates/plugin-supervisor` and `crates/plugin-host`** (verified with `find_references`, not grep). It reaches no MCP payload, no `plugin status`, no `index_coverage`. `crates/plugin-host/src/main.rs:88-94` confirms the intent and its limit — the `eprintln!` is explicitly "for an operator running a worker by hand", and the parent reads none of it because stdio is null. So on an unfiltered platform an operator has to *know*, rather than be *told*. That is a real disclosure gap and it is the more interesting half of this issue: the product's own rule is that absent must never read as negative. **I nearly shipped this same error while fixing it** — my first draft of the README correction said the worker "says so in its payload", which is exactly the overclaim above. It survived only because I checked `NetworkDenial`'s references before leaving the edit. ## What was done, and what is left - **Done (in tree, uncommitted):** README rewritten to state the zero-imports property as the real guarantee, to say plainly that the worker is not a kernel sandbox, to scope network denial to Linux x86_64/aarch64, and to point at the threat model's §6 and §7. - **Still open:** surface the filter state to an operator. A per-package or per-project field carrying `enforced` / `not_requested` / `unsupported` / `unavailable:<errno>`, on the three-state contract — **absent** means this build did not report, never "fine". `NetworkDenial`'s own doc already has the right design and says so: *"EVERY VARIANT IS A STATEMENT. There is no variant meaning 'fine' that a worker could reach by doing nothing."* It just stops at the crate boundary. ## Why this is worth a tracked record rather than a silent doc fix A security overclaim in a README is the one piece of documentation a user reads before deciding to trust a package. We had the honest analysis written down in `80-threat-model.md` the whole time — the defect was that the summary aimed at users contradicted the document aimed at reviewers. Worth a checkable rule that the README's security claims cannot exceed the threat model's, rather than trusting the next edit to remember. ## Related Found auditing #79. Same family as #109 and #108 (a gate green while the thing it checked was false). The disclosure half belongs with the honesty work in #101/#99/#107.
Author
Member

Triage 2026-09-06: CLOSING. Both halves are in, and the repair is a gate over every shipped README rather than three edits — which is what makes it durable.

Verified against master; landed in 8e6bcac.

Half (a) — the claim

search_text("no filesystem and no network") returns 2 hits, and both are in code that forbids the sentence: crates/daemon/tests/readme_security_claims.rs:6 and crates/daemon/src/activation.rs:472. The claim itself is gone from every shipped README.

The important part is why it took a gate. 8e6bcac's own message records it: fixing the two narrative sentences by hand left the same claims shipping from two places nobody had looked at — the components table thirty lines below, and the archive README that release.yml writes into every release artifact, in both packaging arms. A reader who downloads a release never sees the repo README at all, so the copy that mattered most was the copy neither correction touched.

So the unit of crates/daemon/tests/readme_security_claims.rs is every shipped README — README.md plus each cat > README.md << EOF heredoc in release.yml. And the thresholds are derived, not transcribed: from FILESYSTEM_CONFINEMENT, NetworkDenial::Enforced.encode(), and NETWORK_FILTER_SUPPORTED's own cfg! (crates/plugin-supervisor/src/contain.rs:690). A copied spelling would have drifted; a derivation cannot.

It carries its own control — the_checker_rejects_the_sentence_that_shipped — which caught a vacuous first draft.

Half (b) — the disclosure this issue left open

Closed. ActivationDisclosure::worker_containment: Option<WorkerContainment> — crates/daemon/src/activation.rs:511; WorkerContainment at :539; NETWORK_NOT_REQUESTED :555, NETWORK_UNSUPPORTED :558, NETWORK_REQUESTED_UNVERIFIED :561.

And it is honest in the way this issue demanded. The parent structurally cannot learn a worker's real NetworkDenial — it rides only in SelfReport behind a policy false at every production site, stderr is null, --abi-report is a different process, and a failed filter is non-fatal so exit codes cannot discriminate. So enforced is unpublishable, and what ships is requested_unverified | unsupported | not_requested, read from the live host's policy and graded by spawning a real worker rather than by restating a cfg. Printing enforced off a cfg would have been this issue's own overclaim one level down.

Runs (exit 0)

cargo test -p code-index-daemon --test readme_security_claims --test worker_containment_disclosure
→ 9 passed + 7 passed, 0 failed

incl. no_shipped_readme_calls_the_worker_a_sandbox, no_shipped_readme_claims_the_product_has_no_network, the_platform_scope_in_every_readme_is_the_filters_own_cfg, the_checker_rejects_the_sentence_that_shipped, no_platform_lets_this_daemon_claim_the_filter_is_enforced, a_waived_filter_is_reported_as_waived_on_every_platform.

Residual

None. Worth carrying forward as a lesson rather than as work: a correction is a new claim, and the reason this closes cleanly is that the correction was made checkable instead of merely made.

🤖 Triage lane, 2026-09-06, master 45cf6e4

## Triage 2026-09-06: CLOSING. Both halves are in, and the repair is a **gate over every shipped README** rather than three edits — which is what makes it durable. Verified against master; landed in `8e6bcac`. ### Half (a) — the claim `search_text("no filesystem and no network")` returns **2 hits, and both are in code that forbids the sentence**: `crates/daemon/tests/readme_security_claims.rs:6` and `crates/daemon/src/activation.rs:472`. The claim itself is gone from every shipped README. The important part is *why it took a gate*. `8e6bcac`'s own message records it: fixing the two narrative sentences by hand left the same claims shipping from two places nobody had looked at — the components table thirty lines below, and **the archive README that `release.yml` writes into every release artifact, in both packaging arms**. A reader who downloads a release never sees the repo README at all, so the copy that mattered most was the copy neither correction touched. So the unit of `crates/daemon/tests/readme_security_claims.rs` is *every shipped README* — `README.md` plus each `cat > README.md << EOF` heredoc in `release.yml`. And the thresholds are **derived**, not transcribed: from `FILESYSTEM_CONFINEMENT`, `NetworkDenial::Enforced.encode()`, and `NETWORK_FILTER_SUPPORTED`'s own `cfg!` (`crates/plugin-supervisor/src/contain.rs:690`). A copied spelling would have drifted; a derivation cannot. It carries its own control — `the_checker_rejects_the_sentence_that_shipped` — which caught a vacuous first draft. ### Half (b) — the disclosure this issue left open Closed. `ActivationDisclosure::worker_containment: Option<WorkerContainment>` — `crates/daemon/src/activation.rs:511`; `WorkerContainment` at `:539`; `NETWORK_NOT_REQUESTED` `:555`, `NETWORK_UNSUPPORTED` `:558`, `NETWORK_REQUESTED_UNVERIFIED` `:561`. And it is honest in the way this issue demanded. The parent structurally **cannot** learn a worker's real `NetworkDenial` — it rides only in `SelfReport` behind a policy false at every production site, stderr is null, `--abi-report` is a different process, and a failed filter is non-fatal so exit codes cannot discriminate. So `enforced` is **unpublishable**, and what ships is `requested_unverified | unsupported | not_requested`, read from the live host's policy and graded by spawning a real worker rather than by restating a `cfg`. Printing `enforced` off a `cfg` would have been this issue's own overclaim one level down. ### Runs (exit 0) ``` cargo test -p code-index-daemon --test readme_security_claims --test worker_containment_disclosure → 9 passed + 7 passed, 0 failed ``` incl. `no_shipped_readme_calls_the_worker_a_sandbox`, `no_shipped_readme_claims_the_product_has_no_network`, `the_platform_scope_in_every_readme_is_the_filters_own_cfg`, `the_checker_rejects_the_sentence_that_shipped`, `no_platform_lets_this_daemon_claim_the_filter_is_enforced`, `a_waived_filter_is_reported_as_waived_on_every_platform`. ### Residual None. Worth carrying forward as a lesson rather than as work: **a correction is a new claim**, and the reason this closes cleanly is that the correction was made checkable instead of merely made. 🤖 Triage lane, 2026-09-06, master `45cf6e4`
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#114
No description provided.