Skip to content

privacy: stop the resource surface handing a public caller the extension roster (M6), and stop it calling an absent extension private (M18) - #219

Merged
Broccolito merged 3 commits into
mainfrom
claude/wonderful-driscoll-143290
Sep 10, 2026
Merged

privacy: stop the resource surface handing a public caller the extension roster (M6), and stop it calling an absent extension private (M18)#219
Broccolito merged 3 commits into
mainfrom
claude/wonderful-driscoll-143290

Conversation

@Broccolito

Copy link
Copy Markdown
Collaborator

Why

The 2026-09-10 test drive of merged main measured two defects on the resource sibling of Gate
C
, the surface PR #206 half-fixed. Report: ~/biorouter-runs/test-drive/merged/report.md, sections
M6 and M18.

M6 (MEDIUM, privacy). 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(), handing back in one sentence every
name the loop had just spent itself withholding. POST /agent/call_tool reaches it with
CallCapability::public_enforced() and forwards the tool text verbatim, 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 identical leak at POST /agent/read_resource by replacing the message at the
route; the tool path has no such reader.

M18 (LOW). POST /agent/read_resource with an extension name that does not exist answered
403 — correctly — with "nonexistent_ext is a private extension: it reaches data held inside the
institution …"
. 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.

  • The unfiltered roster was built in read_resource_tool's fan-out branch, and again one door
    further in at read_resource's get_server_client miss.
  • assert_extension_reachable reads an unknown name as Private — the one place in this
    feature 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 reading
from, 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. admitted is threaded, never resampled.

read_resource's narrower copy also moves onto the miss path. It was handed to an eager ok_or, so
every successful resource read was composing a roster it never used; Gate E's verdict costs a
resolve_extension per installed entry, so that is not tidying.

M18 — a new privacy::refusal::private_or_absent_refusal. Failing closed is correct and is
unchanged. 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_refusal is untouched and keeps its flat sentence for Gate C proper (dispatch_tool_call
resolves 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 refusal
through assert_extension_manageable, which is assert_extension_reachable verbatim and carries
the 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 fixed
here plus #206's route classifier and its test literal. list_resources' fan-out returns no roster
at all — refusals go to debug!, failures to error! — and list_resources_from_extension names
only 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:

a_public_callers_resource_miss_is_answered_with_gate_es_roster_only        FAILED
  → "Resource with uri 'nope://x' not found. Here are the available extensions:
     ucsfomopagent, developer"
a_private_extension_and_a_name_that_is_not_installed_are_one_refusal       FAILED
  → "`nonexistent_ext` is a private extension: it reaches data held inside the institution…"
the_named_branchs_not_found_message_is_composed_from_gate_es_roster        FAILED
privacy_toggle::the_master_toggle_governs_every_gate_in_both_directions    FAILED
  → "a public caller was handed `ucsfomopagent` in a not-found message"
privacy_guard_wiring::every_privacy_guard_has_a_live_caller_or_a_reason    FAILED

New:

  • Three unit tests in extension_manager.rs: the fan-out roster under a public and a private
    capability (the private column is what distinguishes "filtered" from "silenced"); 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. The ON column asserts against
    the 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.
  • One route test for the M18 wording, built from the real composer.
  • Registry rows in privacy_guard_wiring.rs for 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_client reads the SAME map assert_extension_reachable 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 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.

cargo test -p biorouter --lib                          3773 passed; 0 failed
cargo test -p biorouter --lib -- agents::extension_manager
                                                        146 passed; 0 failed
cargo test -p biorouter --test privacy_toggle             4 passed; 0 failed
cargo test -p biorouter --test privacy_capability         4 passed; 0 failed
cargo test -p biorouter --test privacy_guard_wiring       3 passed; 0 failed
cargo test -p biorouter-server --lib                    582 passed; 0 failed
cargo test -p biorouter-server --lib -- routes::agent     59 passed; 0 failed
cargo fmt --all -- --check                              FMT OK
./scripts/clippy-lint.sh                                too_many_lines ok; no banned TLS crates

BIOROUTER_DISABLE_KEYRING=true on every run. No OpenAPI regeneration: the only non-comment change
in routes/agent.rs is inside #[cfg(test)] mod read_resource_route_tests — no handler, no
#[utoipa::path], and openapi.json is untouched.

Runtime verification

Own sandboxed daemon (XDG_CONFIG_HOME under the scratchpad, port 57391, provider claude_code /
claude-opus-5, public). The report's sandbox had no private third-party extension loaded, which
is why it could say nothing NEW leaked there; this one loads a private-named ucsfomopagent, so the
filter is visible biting.

The manager's raw list, GET /agent/tools prefixes — 8:

['code_execution', 'developer', 'extensionmanager', 'platform', 'skills', 'todo',
 'ucsfomopagent', 'workspace']

The report's exact curl, POST /agent/call_tool {"name":"extensionmanager__read_resource", "arguments":{"uri":"nope://x"}}6, and ucsfomopagent is gone:

Extension operation failed: Failed to read resource: Resource with uri 'nope://x' not found.
Here are the available extensions: code_execution, developer, extensionmanager, skills, todo,
workspace

M18, POST /agent/read_resource, three names on the same session:

extension_name Status Answer
nonexistent_ext 403 the reworded refusal, below
ucsfomopagent (loaded, private) 403 byte-identical but for the name
developer (loaded, public) 502 Could not read resource with uri: nope://x — unchanged
`nonexistent_ext` cannot be reached from this chat, which is running on a public model. To a
public model a private extension — one that reaches data held inside the institution — and a
name that is not installed here are the same answer, and Biorouter does not say which
`nonexistent_ext` is. If it is a private extension, ask the user to switch this chat to a
private model (Settings > Models, or the model chip in the composer) and try again; if it is
not installed, no model will reach it. This is a data-protection boundary set by the Biorouter
marketplace, not something to work around: do not retry with a different tool name, through
code execution, or through a resource read.

The second row is the point: a public caller comparing the two answers learns nothing.

Follow-ups, not touched here

  • The ENABLE door has the same false sentence. extension_enable_refusal's tier arm still
    answers 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 refusal
    installed 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/tools is the ExtensionManager's list, not the Gate-E-filtered one. The report
    flags 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.

…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.
@Broccolito
Broccolito merged commit c1e16e5 into main Sep 10, 2026
23 of 24 checks passed
@Broccolito
Broccolito deleted the claude/wonderful-driscoll-143290 branch September 10, 2026 20:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant