privacy: stop the resource surface handing a public caller the extension roster (M6), and stop it calling an absent extension private (M18) - #219
Merged
Conversation
…ion roster 2026-09-10 test drive, findings M6 (MEDIUM) and M18 (LOW), both on the resource sibling of Gate C. M6 — `extensionmanager__read_resource` with no `extension_name` fans out over every installed extension, refusing the private ones one at a time, and then composed its not-found message from `self.extensions.lock().await.keys()`: "Here are the available extensions: …", handing back in one sentence every name the loop had just spent itself withholding. `POST /agent/call_tool` reaches it with `CallCapability::public_enforced()`, so an HTTP client holding only the daemon secret could read a chat's private-connector names out of a not-found message. PR #206 closed the same leak at `POST /agent/read_resource` by REPLACING the message at the route; the tool path has no such reader. Both rosters now come from `allowed_extension_keys(admitted)` — Gate E's own verdict, the same one that built the tool list the model is reading from, so the message can never name an extension the caller was not already shown. The list is filtered rather than deleted: a model that has passed Gate E is still told where to look. `read_resource`'s narrower copy moves onto the miss path as well; it was handed to an eager `ok_or`, so every successful resource read was paying for a roster it never used. M18 — `assert_extension_reachable` reads an unknown name as Private, which is deliberate and stays: to a public caller "this private connector is installed", "it is not installed" and "no such extension" must be one indistinguishable refusal, or the gate becomes an existence oracle over exactly the names M6 closes. But it composed that refusal with `privacy_refusal`, whose sentence states "`x` is a private extension" — a fact this gate has not established, and one that sends a model looking for a private model to reach something that does not exist. New `privacy::refusal::private_or_absent_refusal` states the disjunction and answers both cases identically, keeping the actionable half conditionally. `privacy_refusal` is unchanged and keeps its flat sentence for Gate C proper and the enable doors, which resolve the extension before they refuse. Two renderings of `tier_refuses`, each true where it is used. The unload door (`workspace_set_tools {remove_extensions}`) reaches the reworded refusal through `assert_extension_manageable`, so its expectation moves too; its non-oracle assertion is unchanged and now means more. Tests: three unit tests in `extension_manager.rs` (the fan-out roster under a public and a private capability, the named branch's roster, and the two refusals being byte-identical modulo the name); row 4b in the `privacy_toggle` matrix in both toggle positions; one route test for the M18 wording. Registry rows in `privacy_guard_wiring.rs` updated for the moved call site and the new guard.
…ous probe The first version of `the_named_branchs_not_found_message_carries_gate_es_roster_only` passed against the unfixed tree, and the reason generalises. `get_server_client` reads the SAME extension map `assert_extension_reachable` has just consulted, so presence implies a client: a public caller naming an absent extension is refused before it arrives, and a private caller that does arrive is shown everything by Gate E anyway. No capability reaches that branch with a roster that differs from the map, so no behavioural test can see the change. Replaced with a source guard over the lookup's miss arm, plus the one reachable assertion there is (a private caller, an absent name, a usable list back), and a doc that says plainly which half is which. The change itself stays: it is defence in depth on a message #206's route classifier currently discards, and it moves a roster off the eager `ok_or` that made every successful resource read pay for it.
…what it refused Section 14.4 quoted Gate C's refusal and nothing else, so a reader meeting the resource surface's different sentence and grepping this document would find only the wording it does not use. Gate C' reads an unknown name as Private, so it cannot state that the extension is private — and the two cases must keep one indistinguishable answer or the repair becomes an existence oracle over the names Gate E withholds. Both halves are now written down beside the Gate C example they contrast with.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The 2026-09-10 test drive of merged
mainmeasured two defects on the resource sibling of GateC, the surface PR #206 half-fixed. Report:
~/biorouter-runs/test-drive/merged/report.md, sectionsM6 and M18.
M6 (MEDIUM, privacy).
extensionmanager__read_resourcewith noextension_namefans out overevery installed extension, refusing the private ones one at a time — and then composed its
not-found message from
self.extensions.lock().await.keys(), handing back in one sentence everyname the loop had just spent itself withholding.
POST /agent/call_toolreaches it withCallCapability::public_enforced()and forwards the tool text verbatim, so an HTTP client holdingonly the daemon secret could read a chat's private-connector names out of a not-found message.
PR #206 closed the identical leak at
POST /agent/read_resourceby replacing the message at theroute; the tool path has no such reader.
M18 (LOW).
POST /agent/read_resourcewith an extension name that does not exist answered403— correctly — with "nonexistent_extis a private extension: it reaches data held inside theinstitution …". The refusal is right and the status is right; the sentence asserts a fact the gate
never established, and it sends a model looking for a private model to reach something that is not
there.
Root cause
Both live in
crates/biorouter/src/agents/extension_manager.rs.read_resource_tool's fan-out branch, and again one doorfurther in at
read_resource'sget_server_clientmiss.assert_extension_reachablereads an unknown name asPrivate— the one place in thisfeature where the unknown-name default is inverted, deliberately — and then composed its refusal
with
privacy_refusal, whose sentence states the extension IS private.What changed
M6 — the roster is Gate E's, never the extension map's. Both sites now compose from
allowed_extension_keys(admitted): the same verdict that built the tool list this model is readingfrom, so the message can never name an extension the caller was not already shown. Filtered rather
than deleted — a model that has passed Gate E is still told where to look — and sorted, because the
extension map's iteration order is randomised per process.
admittedis threaded, never resampled.read_resource's narrower copy also moves onto the miss path. It was handed to an eagerok_or, soevery successful resource read was composing a roster it never used; Gate E's verdict costs a
resolve_extensionper installed entry, so that is not tidying.M18 — a new
privacy::refusal::private_or_absent_refusal. Failing closed is correct and isunchanged. What could not change is that the two cases stay one indistinguishable answer: saying
"no such extension" for the absent branch would build an existence oracle out of the repair, over
exactly the private names M6 closes next door. So the new composer states the disjunction and
returns the identical string either way, keeping the actionable half conditionally.
privacy_refusalis untouched and keeps its flat sentence for Gate C proper (dispatch_tool_callresolves an installed client before it refuses) and for the enable doors (which resolve against the
config first). Two renderings of
tier_refuses, each true where it is used.Fallout, deliberate.
workspace_set_tools {remove_extensions}reaches the reworded refusalthrough
assert_extension_manageable, which isassert_extension_reachableverbatim and carriesthe same inverted default. Its expectation moves to a second helper; its non-oracle assertion is
unchanged and now means more.
Checked and clean.
grep -rn 'available extensions' crates/finds exactly the two sites fixedhere plus #206's route classifier and its test literal.
list_resources' fan-out returns no rosterat all — refusals go to
debug!, failures toerror!— andlist_resources_from_extensionnamesonly the extension the caller supplied.
Tests
Fail-before was measured by reverting only the production hunks and re-running the new tests
against them, not asserted:
New:
extension_manager.rs: the fan-out roster under a public and a privatecapability (the private column is what distinguishes "filtered" from "silenced"); the named
branch's roster; and the two refusals being byte-identical modulo the name.
privacy_togglematrix, in both toggle positions. The ON column asserts againstthe live extension map rather than the constant, so it cannot pass by naming the one extension
the fixture happens to load; the OFF column asserts the private name comes back, so ON cannot pass
against an implementation that deleted the list.
privacy_guard_wiring.rsfor the moved call site and the new guard.⚠ One test was rewritten because it passed against the unfixed tree, and the reason is worth
recording:
get_server_clientreads the SAME mapassert_extension_reachablejust consulted, sopresence implies a client. A public caller naming an absent extension is refused before it arrives,
and a private caller that does arrive is shown everything by Gate E anyway — no capability reaches
that branch with a differing roster. That half is defence in depth, it is now pinned at the source
with the reachable half asserted underneath, and the doc says which is which.
BIOROUTER_DISABLE_KEYRING=trueon every run. No OpenAPI regeneration: the only non-comment changein
routes/agent.rsis inside#[cfg(test)] mod read_resource_route_tests— no handler, no#[utoipa::path], andopenapi.jsonis untouched.Runtime verification
Own sandboxed daemon (
XDG_CONFIG_HOMEunder the scratchpad, port 57391, providerclaude_code/claude-opus-5, public). The report's sandbox had no private third-party extension loaded, whichis why it could say nothing NEW leaked there; this one loads a private-named
ucsfomopagent, so thefilter is visible biting.
The manager's raw list,
GET /agent/toolsprefixes — 8:The report's exact curl,
POST /agent/call_tool{"name":"extensionmanager__read_resource", "arguments":{"uri":"nope://x"}}— 6, anducsfomopagentis gone:M18,
POST /agent/read_resource, three names on the same session:extension_namenonexistent_extucsfomopagent(loaded, private)developer(loaded, public)Could not read resource with uri: nope://x— unchangedThe second row is the point: a public caller comparing the two answers learns nothing.
Follow-ups, not touched here
extension_enable_refusal's tier arm stillanswers a public caller naming a nonexistent extension with
privacy_refusal's flat claim(
workspace_extension.rs's own test pins that a public caller gets the identical refusalinstalled or not, while a private caller gets "unknown extension"). Same defect, same reason,
different gate; out of scope for a finding filed against
/agent/read_resource.GET /agent/toolsis the ExtensionManager's list, not the Gate-E-filtered one. The reportflags this as a bad comparator rather than a finding; it is what made the 8-vs-6 measurement above
possible, and it is worth deciding on separately.