From ce288e3e394f83a5ad76c1e148f538e1c4ebe4c5 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Thu, 10 Sep 2026 10:56:33 -0700 Subject: [PATCH 1/3] privacy: stop the resource surface handing a public caller the extension roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/biorouter-server/src/routes/agent.rs | 79 ++++- .../biorouter/src/agents/extension_manager.rs | 286 ++++++++++++++++-- .../src/agents/workspace_extension.rs | 28 +- crates/biorouter/src/privacy/refusal.rs | 65 ++++ .../biorouter/tests/privacy_guard_wiring.rs | 37 ++- crates/biorouter/tests/privacy_toggle.rs | 65 ++++ 6 files changed, 514 insertions(+), 46 deletions(-) diff --git a/crates/biorouter-server/src/routes/agent.rs b/crates/biorouter-server/src/routes/agent.rs index d50a9f0df..8dc1604f8 100644 --- a/crates/biorouter-server/src/routes/agent.rs +++ b/crates/biorouter-server/src/routes/agent.rs @@ -2407,14 +2407,19 @@ async fn read_resource( /// /// ⚠ **The not-found message is REPLACED rather than forwarded, and that is the /// whole reason this takes `requested`.** The manager's own text reads -/// *"Extension 'x' not found. Here are the available extensions: …"* — a list of -/// every extension loaded in that chat, private ones included. It is written for -/// a model that has already passed Gate E and may therefore be told what it can -/// reach; this route is `public_enforced`, so forwarding it would hand a caller -/// evaluated as public exactly the private-extension names Gate E exists to -/// withhold. The other two messages ARE forwarded: a refusal names only the -/// extension the caller itself asked for and is written to be read, and the read -/// failure names only the caller's own URI. +/// *"Extension 'x' not found. Here are the available extensions: …"*. That list +/// is now Gate E's rather than the raw extension map's — the 2026-09-10 test +/// drive's finding M6 closed it at the source, in `ExtensionManager` — so what +/// arrives here can no longer name an extension a public caller was not already +/// shown. This route still replaces it, for two reasons that outlive that fix: +/// the caller named ONE extension and a roster it did not ask for is not an +/// answer to its question, and a message that is safe only because its producer +/// filters it correctly is one edit away from being unsafe again. Defence in +/// depth on a boundary this cheap to hold is worth keeping. +/// +/// The other two messages ARE forwarded: a refusal names only the extension the +/// caller itself asked for and is written to be read, and the read failure names +/// only the caller's own URI. fn read_resource_failure(error: rmcp::model::ErrorData, requested: &str) -> ErrorResponse { use rmcp::model::ErrorCode; @@ -2457,7 +2462,10 @@ mod read_resource_route_tests { //! hand-made error with the same code. use super::{read_resource_failure, StatusCode}; - use biorouter::privacy::{refusal::privacy_refusal, ProviderTier}; + use biorouter::privacy::{ + refusal::{privacy_refusal, private_or_absent_refusal}, + ProviderTier, + }; use rmcp::model::{ErrorCode, ErrorData}; const SOURCE: &str = include_str!("agent.rs"); @@ -2526,6 +2534,59 @@ mod read_resource_route_tests { ); } + /// Finding M18: the 403 must not tell the caller a thing this gate never + /// established. + /// + /// `POST /agent/read_resource` with `extension_name: "nonexistent_ext"` + /// answered *"`nonexistent_ext` is a private extension: it reaches data held + /// inside the institution …"*. Failing closed there is correct and stays — + /// `assert_extension_reachable` reads an unknown name as Private on purpose, + /// and answering "no such extension" instead would let a public caller walk + /// names until it found the private connectors Gate E hides. What was wrong + /// was the *claim*: a name that names nothing was asserted to be a private + /// extension, sending a model to the model picker to fix a typo. + /// + /// The refusal is the REAL one, built by the function + /// `assert_extension_reachable` composes, for the same reason the test above + /// builds a real `privacy_refusal`: a hand-made `ErrorData` would pass + /// against a production gate that had never been rewired. + #[test] + fn a_refusal_for_a_name_this_gate_cannot_resolve_does_not_call_it_private() { + let refusal = private_or_absent_refusal( + "nonexistent_ext", + ProviderTier::Private, + ProviderTier::Public, + ) + .expect("an unknown name reads Private, so a public caller is refused"); + + let response = read_resource_failure(refusal, "nonexistent_ext"); + + assert_eq!( + response.status, + StatusCode::FORBIDDEN, + "failing closed is the correct behaviour and this finding does not relax it" + ); + assert!( + !response + .message + .contains("`nonexistent_ext` is a private extension"), + "the refusal still asserts that a name this gate could not resolve IS a private \ + extension: {}", + response.message + ); + assert!( + response.message.contains("nonexistent_ext"), + "the caller must still learn which name it was refused: {}", + response.message + ); + assert!( + response.message.contains("not installed"), + "the refusal has to state the OTHER case it covers, or it is the same assertion \ + in softer words: {}", + response.message + ); + } + /// A refusal that never reaches this classifier is not refused at all. /// /// The handler is the only caller, so the mapping above is worth exactly as diff --git a/crates/biorouter/src/agents/extension_manager.rs b/crates/biorouter/src/agents/extension_manager.rs index 936902cfc..cd8f615a1 100644 --- a/crates/biorouter/src/agents/extension_manager.rs +++ b/crates/biorouter/src/agents/extension_manager.rs @@ -2247,7 +2247,17 @@ impl ExtensionManager { }, |extension| crate::privacy::resolve_extension(name, Some(&extension.config)), ); - match crate::privacy::refusal::privacy_refusal(name, class.tier, cap.tier()) { + // ⚠ **`private_or_absent_refusal`, NOT `privacy_refusal`, and the reason + // is the inverted default documented above.** An unknown name arrives + // here already read as Private, so `privacy_refusal`'s flat *"`x` is a + // private extension"* asserts a fact this gate has not established — it + // sent a caller looking for a private model to reach an extension that + // does not exist (2026-09-10 test drive, finding M18). The replacement + // states the disjunction and answers the two cases IDENTICALLY, which is + // what keeps the repair from becoming an existence oracle over exactly + // the private names Gate E hides. The predicate underneath is unchanged: + // both compose `tier_refuses`. + match crate::privacy::refusal::private_or_absent_refusal(name, class.tier, cap.tier()) { // DR-15's master opt-out, read through the capability so the tier // and the toggle can never be sampled at two different instants — // the same predicate Gate C asks, never a second narrower flag. @@ -2416,19 +2426,43 @@ impl ExtensionManager { } } - // None of the extensions had the resource so we raise an error - let available_extensions = self - .extensions - .lock() - .await - .keys() - .map(|s| s.as_str()) - .collect::>() - .join(", "); - let error_msg = format!( - "Resource with uri '{}' not found. Here are the available extensions: {}", - uri, available_extensions - ); + // None of the extensions had the resource so we raise an error. + // + // ⚠ **The roster is Gate E's, never the extension map's** (issue #56; + // 2026-09-10 test drive, finding M6). This branch used to build the list + // straight from `self.extensions.lock().await.keys()`, so a caller + // evaluated as PUBLIC — every `POST /agent/call_tool`, which passes + // `CallCapability::public_enforced()` — was handed the names of the + // private extensions loaded in that chat, in the one place the loop + // above had just finished refusing it each of them one at a time. Gate E + // exists to keep those names out of a public model's sight; a + // not-found message is not an exemption from it. + // + // Filtered rather than dropped, because the list is genuinely useful to + // a model that has passed Gate E, and `allowed_extension_keys` is the + // SAME verdict that built the tool list this model is reading from — so + // it can never name an extension the model was not already shown. + // `admitted` is threaded, not resampled: this is a tool-call path, and + // re-reading the provider here is the read-then-read `CallCapability` + // exists to prevent. + // + // Sorted because `extensions` is a `HashMap` whose iteration order is + // randomised per process, exactly as `cross_affiliation_warnings` and + // the manager's own enabled listing sort. + let mut visible = self.allowed_extension_keys(admitted).await; + visible.sort(); + let error_msg = if visible.is_empty() { + format!( + "Resource with uri '{}' not found in any extension this chat can reach.", + uri + ) + } else { + format!( + "Resource with uri '{}' not found. Here are the available extensions: {}", + uri, + visible.join(", ") + ) + }; Err(ErrorData::new( ErrorCode::RESOURCE_NOT_FOUND, @@ -2451,23 +2485,37 @@ impl ExtensionManager { self.assert_extension_reachable(extension_name, admitted) .await?; - let available_extensions = self - .extensions - .lock() - .await - .keys() - .map(|s| s.as_str()) - .collect::>() - .join(", "); - let error_msg = format!( - "Extension '{}' not found. Here are the available extensions: {}", - extension_name, available_extensions - ); - - let client = self - .get_server_client(extension_name) - .await - .ok_or(ErrorData::new(ErrorCode::INVALID_PARAMS, error_msg, None))?; + let client = match self.get_server_client(extension_name).await { + Some(client) => client, + None => { + // Gate E's roster, for the reason the fan-out branch above + // states at length. This one is narrower and was already + // covered at the route by #206's `read_resource_failure`, which + // REPLACES this message rather than forwarding it — but the + // route is not the only caller, and a message that is safe only + // because one of its readers throws it away is a leak waiting + // for a second reader. + // + // ⚠ Built on the MISS, not before the lookup. The list used to + // be composed unconditionally and handed to `ok_or`, which is + // eager: every successful resource read paid for it. Gate E's + // verdict costs a `resolve_extension` per installed entry, so + // moving it here is not tidying — leaving it above would put + // that walk on the hot path. + let mut visible = self.allowed_extension_keys(admitted).await; + visible.sort(); + let error_msg = if visible.is_empty() { + format!("Extension '{}' is not loaded in this chat.", extension_name) + } else { + format!( + "Extension '{}' not found. Here are the available extensions: {}", + extension_name, + visible.join(", ") + ) + }; + return Err(ErrorData::new(ErrorCode::INVALID_PARAMS, error_msg, None)); + } + }; let client_guard = &*client; client_guard @@ -6302,6 +6350,182 @@ mod tests { ); } + /// Finding M6: the roster a resource read hands back when it finds nothing + /// is Gate E's, never the extension map's. + /// + /// `read_resource_tool` with no `extension_name` fans out over every + /// installed extension, refusing the private ones one at a time — and then + /// ended by composing *"Here are the available extensions: …"* from + /// `self.extensions.lock().await.keys()`, handing back in one sentence every + /// name it had just spent the loop withholding. `POST /agent/call_tool` + /// reaches this 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. + /// + /// ⚠ **`developer` rides along, and the test is worth nothing without it.** + /// An implementation that simply deleted the list passes every "does not + /// contain `ucsfomopagent`" assertion. Requiring the public extension to + /// still be named is what distinguishes "filtered through Gate E" from + /// "silenced". + #[tokio::test] + async fn a_public_callers_resource_miss_is_answered_with_gate_es_roster_only() { + let (_dir, em, _sm, _id) = manager_with_a_session().await; + em.add_mock_extension("ucsfomopagent".to_string(), Arc::new(MockClient {})) + .await; + em.add_mock_extension("developer".to_string(), Arc::new(MockClient {})) + .await; + + let public = + crate::privacy::CallCapability::for_test(crate::privacy::ProviderTier::Public, true); + let text = em + .read_resource_tool( + serde_json::json!({ "uri": "nope://x" }), + Some(public), + CancellationToken::default(), + ) + .await + .expect_err("no extension holds that uri") + .message + .to_string(); + + assert!( + !text.contains("ucsfomopagent"), + "a caller evaluated as public was handed the name of a private extension in a \ + not-found message — the set Gate E exists to withhold: {text}" + ); + assert!( + text.contains("developer"), + "the roster was silenced rather than filtered, so this test would pass against an \ + implementation that told a legitimate model nothing: {text}" + ); + + // The same call on a PRIVATE model still sees everything, which is what + // makes the assertion above a privacy filter rather than a deletion. + let private = + crate::privacy::CallCapability::for_test(crate::privacy::ProviderTier::Private, true); + let as_private = em + .read_resource_tool( + serde_json::json!({ "uri": "nope://x" }), + Some(private), + CancellationToken::default(), + ) + .await + .expect_err("no extension holds that uri") + .message + .to_string(); + assert!(as_private.contains("ucsfomopagent"), "{as_private}"); + assert!(as_private.contains("developer"), "{as_private}"); + } + + /// The same leak one door further in: `read_resource`'s `get_server_client` + /// miss, which composed the identical roster. + /// + /// `POST /agent/read_resource` never showed it — #206's + /// `read_resource_failure` replaces that message rather than forwarding it — + /// and that route keeps doing so. This asserts the message is safe at the + /// SOURCE, because a string that is only safe while one of its readers + /// discards it is one new reader away from being unsafe. + /// + /// The fixture is a name that IS reachable and yet has no client: a + /// capability the manager records without a server behind it. That is the + /// only shape that gets past `assert_extension_reachable` and into the miss. + #[tokio::test] + async fn the_named_branchs_not_found_message_carries_gate_es_roster_only() { + let (_dir, em, _sm, _id) = manager_with_a_session().await; + em.add_mock_extension("ucsfomopagent".to_string(), Arc::new(MockClient {})) + .await; + em.add_mock_extension("developer".to_string(), Arc::new(MockClient {})) + .await; + + let public = + crate::privacy::CallCapability::for_test(crate::privacy::ProviderTier::Public, true); + let mut visible = em.allowed_extension_keys(Some(public)).await; + visible.sort(); + assert_eq!( + visible, + vec!["developer".to_string()], + "the fixture does not discriminate unless Gate E really hides the private one" + ); + + // Reached directly, because the only production route into the miss is a + // key present in Gate E's verdict whose client has gone. Asserting the + // composed message is what this test is for. + let text = em + .read_resource( + "ui://cohort", + "developer", + Some(public), + CancellationToken::default(), + ) + .await + .expect_err("MockClient refuses every read") + .message + .to_string(); + assert!( + !text.contains("ucsfomopagent"), + "the named branch leaked the private roster: {text}" + ); + } + + /// Finding M18: a name this gate could not resolve is still refused, and the + /// refusal no longer says it is a private extension. + /// + /// ⚠ **The two answers must stay IDENTICAL, and that is the assertion that + /// matters.** `assert_extension_reachable` reads an unknown name as Private + /// deliberately: to a public caller "this private connector is installed", + /// "this private connector is not installed" and "no such extension" are one + /// refusal, which is what stops the gate being an existence oracle over the + /// names finding M6 closes next door. So the repair could not be "say `no + /// such extension` for the absent case" — it had to be one sentence true of + /// both. Comparing the two messages with the name held constant is what + /// stops a later edit adding one helpful clause to the branch it can tell + /// apart. + #[tokio::test] + async fn a_private_extension_and_a_name_that_is_not_installed_are_one_refusal() { + let (_dir, em, _sm, _id) = manager_with_a_session().await; + em.add_mock_extension("ucsfomopagent".to_string(), Arc::new(MockClient {})) + .await; + + let public = + crate::privacy::CallCapability::for_test(crate::privacy::ProviderTier::Public, true); + let refusal_for = |name: &'static str| { + let em = &em; + async move { + em.read_resource( + "ui://cohort", + name, + Some(public), + CancellationToken::default(), + ) + .await + .expect_err("a public caller reaches neither") + .message + .to_string() + } + }; + + let loaded_and_private = refusal_for("ucsfomopagent").await; + let never_installed = refusal_for("nonexistent_ext").await; + + assert!( + !never_installed.contains("`nonexistent_ext` is a private extension"), + "the gate still asserts that a name it could not resolve IS a private extension, \ + which sends a model looking for a private model to reach something that does not \ + exist: {never_installed}" + ); + assert_eq!( + loaded_and_private.replace("ucsfomopagent", "NAME"), + never_installed.replace("nonexistent_ext", "NAME"), + "the two cases now read differently, so a public caller can walk names and learn \ + which private extensions this chat has loaded" + ); + assert!( + never_installed.contains("not installed"), + "the refusal must state the other case it covers, or it is the same claim in \ + softer words: {never_installed}" + ); + } + /// **Step 3.2.** Take every optional input away and the private extension is /// still private. /// diff --git a/crates/biorouter/src/agents/workspace_extension.rs b/crates/biorouter/src/agents/workspace_extension.rs index 43789a3f0..6febce687 100644 --- a/crates/biorouter/src/agents/workspace_extension.rs +++ b/crates/biorouter/src/agents/workspace_extension.rs @@ -7311,6 +7311,32 @@ pub(crate) mod tests { .to_string() } + /// The refusal the UNLOAD door returns, which is a different sentence from + /// the enable door's above — issue #56; 2026-09-10 test drive, finding M18. + /// + /// `workspace_set_tools {remove_extensions}` reaches + /// `ExtensionManager::assert_extension_manageable`, which is + /// `assert_extension_reachable` verbatim, and that gate reads an **unknown** + /// name as Private. It therefore cannot state that the extension is private + /// — the name may name nothing — so it states the disjunction instead, and + /// states it IDENTICALLY in both cases. The non-oracle property this test + /// ends on is what forbids the obvious repair of saying "no such extension" + /// for the absent branch. + /// + /// The enable door keeps `privacy_refusal`'s flat sentence because it + /// resolves the extension against the config before its tier arm fires, so + /// there the claim is a fact. + fn expected_unreachable_extension_refusal(name: &str) -> String { + crate::privacy::refusal::private_or_absent_refusal( + name, + crate::privacy::ProviderTier::Private, + crate::privacy::ProviderTier::Public, + ) + .expect("a public caller may reach neither a private extension nor an unknown name") + .message + .to_string() + } + /// A task-local `extensions:` map, so a test can install an extension — /// or pin one off — **without writing the developer's `config.yaml`**. /// @@ -7494,7 +7520,7 @@ pub(crate) mod tests { ); assert_eq!(refused.is_error, Some(true), "{loaded_refusal}"); assert!( - loaded_refusal.contains(&expected_private_extension_refusal(private_ext)), + loaded_refusal.contains(&expected_unreachable_extension_refusal(private_ext)), "not Gate F1's refusal: {loaded_refusal}" ); diff --git a/crates/biorouter/src/privacy/refusal.rs b/crates/biorouter/src/privacy/refusal.rs index a58362de5..2c043ba1b 100644 --- a/crates/biorouter/src/privacy/refusal.rs +++ b/crates/biorouter/src/privacy/refusal.rs @@ -18,6 +18,7 @@ //! | 23 | the two spawn variants and `PrivacyRefusal::spawn_upgrade` / `spawn_downgrade` | //! | 41 | [`PrivacyRefusal::AppSessionTierFixed`], DR-21's app-runtime refusal | //! | DR-31 | [`PrivacyRefusal::SpawnCrossesAffiliation`] and [`PrivacyRefusal::spawn_affiliation`] — the spawn gate's third axis | +//! | test drive M18 | [`private_or_absent_refusal`] — [`privacy_refusal`]'s sentence, restated for the ONE gate that reads an unknown name as Private and therefore cannot claim the extension is private | //! | findings 4+13's seam | [`extension_enable_refusal`] — the WHOLE enable gate, tier arm above the operator pin, called by both agent enable doors — and [`tier_refuses`], the boolean under [`privacy_refusal`] that the user's HTTP enable door asks instead of re-typing | use super::{ProviderTier, SessionClassification}; @@ -583,6 +584,70 @@ pub fn privacy_refusal( )) } +/// The same boundary, composed by the one gate that **cannot tell a private +/// extension from a name that is not installed** — issue #56 Gate C', the +/// resource and prompt surface. +/// +/// [`ExtensionManager::assert_extension_reachable`] reads an unknown name as +/// Private. That is the single place in this feature where the unknown-name +/// default is inverted, deliberately: the alternative is permitting a reach at a +/// name the manager could not resolve at all. The inversion is also what makes +/// [`privacy_refusal`]'s sentence WRONG there — it states *"`x` is a private +/// extension"* about a name that may name nothing, and a model told that goes +/// looking for a private model to reach an extension that does not exist. The +/// 2026-09-10 test drive measured it as finding M18: `POST /agent/read_resource` +/// answered `403` *"`nonexistent_ext` is a private extension …"*. +/// +/// ⚠ **The two cases must keep ONE answer, and that is the whole difficulty.** +/// Saying "no such extension" for the absent case would build an existence +/// oracle out of the repair: a public caller could walk names and learn which +/// private connectors a chat has loaded — precisely the set Gate E hides, and +/// precisely the leak finding M6 closes on the roster next door in +/// `read_resource_tool`. So this states the disjunction rather than resolving +/// it, and returns the identical string in both cases. Failing closed is +/// unchanged; only the claim about *why* is. +/// +/// ⚠ **`privacy_refusal` keeps its sentence, and this does not replace it.** +/// Gate C proper (`dispatch_tool_call`) resolves the extension from an +/// installed client before it refuses, so "is a private extension" is a fact +/// there and the flat statement is the better one to give a model. Two +/// renderings of [`tier_refuses`], each true where it is used — not one +/// rendering hedged everywhere. +/// +/// The actionable half survives, conditionally rather than as an assertion: a +/// caller that really did meet a private extension still learns what clears it, +/// and one that merely mistyped a name is no longer sent to the model picker to +/// fix a typo. +/// +/// §14.4 as everywhere else: the extension the caller itself named, the two +/// tiers, and nothing else. +/// +/// [`ExtensionManager::assert_extension_reachable`]: crate::agents::ExtensionManager +pub fn private_or_absent_refusal( + extension: &str, + extension_tier: ProviderTier, + caller_tier: ProviderTier, +) -> Option { + if !tier_refuses(extension_tier, caller_tier) { + return None; + } + Some(ErrorData::new( + ErrorCode::INVALID_REQUEST, + format!( + "`{extension}` 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 `{extension}` 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." + ), + None, + )) +} + /// The refusal a **cross-affiliation** mismatch produces at a gate that refuses /// — Gate C (dispatch), Gate F (the extension channels) and the agent's own /// enable path (DR-26, Task 48). diff --git a/crates/biorouter/tests/privacy_guard_wiring.rs b/crates/biorouter/tests/privacy_guard_wiring.rs index 7e1f76c38..ae20fc417 100644 --- a/crates/biorouter/tests/privacy_guard_wiring.rs +++ b/crates/biorouter/tests/privacy_guard_wiring.rs @@ -638,9 +638,15 @@ const REGISTRY: &[Guard] = &[ }, Site { file: "crates/biorouter/src/agents/extension_manager.rs", - counts: c(4, 0, 0), + counts: c(3, 0, 0), kind: SiteKind::Guard, - what: "Gates E and F: the tool list and tool dispatch", + what: "Gates E and F: the tool list and tool dispatch. It was FOUR until the \ + 2026-09-10 test drive's finding M18: the fourth was \ + `assert_extension_reachable`, the one gate that reads an unknown name \ + as Private, where this function's flat sentence asserted a privateness \ + the gate had not established. That call now goes to \ + `private_or_absent_refusal`, tracked in its own row. Moving it back \ + would put the false claim back", }, Site { file: "crates/biorouter/src/agents/subagent_tool.rs", @@ -719,6 +725,24 @@ const REGISTRY: &[Guard] = &[ apply in the removal direction.", }], }, + Guard { + ident: "private_or_absent_refusal", + defined_in: "crates/biorouter/src/privacy/refusal.rs", + decides: "the sentence Gate C' returns — the resource and prompt surface, whose \ + unknown-name default is Private, so its refusal must cover BOTH a private \ + extension and a name that is not installed and must not tell the two apart", + status: Status::Wired, + sites: &[Site { + file: "crates/biorouter/src/agents/extension_manager.rs", + counts: c(1, 0, 0), + kind: SiteKind::Guard, + what: "`assert_extension_reachable`, the ONE caller and deliberately so. Every \ + other tier gate resolves its extension from an installed record before it \ + refuses, so `privacy_refusal`'s flat statement is a fact there and the \ + better thing to hand a model. A second caller of this one would be a gate \ + hedging about an extension it can see", + }], + }, Guard { ident: "tier_refuses", defined_in: "crates/biorouter/src/privacy/refusal.rs", @@ -739,10 +763,13 @@ const REGISTRY: &[Guard] = &[ }, Site { file: "crates/biorouter/src/privacy/refusal.rs", - counts: c(1, 0, 0), + counts: c(2, 0, 0), kind: SiteKind::Guard, - what: "`privacy_refusal`, which is this predicate plus the sentence the model \ - reads", + what: "`privacy_refusal` and `private_or_absent_refusal`, which are this \ + predicate plus the two sentences the model reads. TWO renderings and \ + one rule is the whole point of the split: the gate that resolved the \ + extension states it is private, the gate that read an unknown name as \ + Private states the disjunction, and neither re-derives WHEN to refuse", }, ], }, diff --git a/crates/biorouter/tests/privacy_toggle.rs b/crates/biorouter/tests/privacy_toggle.rs index 98587970f..b6f031ea1 100644 --- a/crates/biorouter/tests/privacy_toggle.rs +++ b/crates/biorouter/tests/privacy_toggle.rs @@ -353,6 +353,36 @@ async fn read_private_resource(agent: &Agent) -> String { } } +/// Row 4b's subject: the same surface's **fan-out** branch, which is a different +/// leak from row 4's refusal and was live on `main` until the 2026-09-10 test +/// drive measured it (finding M6). +/// +/// `read_resource_tool` with no `extension_name` probes every installed +/// extension in turn, 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 withheld. Row 4 +/// cannot see it: that row asks the NAMED branch, which never reaches this +/// message. +/// +/// `None` for the admitted capability, exactly as row 4 does, so the guard +/// samples the toggle live the way the six non-tool-call entries do. +/// +/// Returns the error text, or the success shape, whichever came back. +async fn read_unknown_resource_across_extensions(agent: &Agent) -> String { + match agent + .extension_manager + .read_resource_tool( + serde_json::json!({ "uri": "nope://x" }), + None, + tokio_util::sync::CancellationToken::default(), + ) + .await + { + Ok(ok) => format!("{ok:?}"), + Err(e) => e.message.to_string(), + } +} + /// Rows 5's subject: what discovery — and therefore the SYSTEM PROMPT — is /// allowed to name. `get_extensions_info` carries a private server's own /// instructions, so this covers Gate F2 as well as Gate E. @@ -549,6 +579,30 @@ async fn the_master_toggle_governs_every_gate_in_both_directions() { "{gate_c_prime_on}" ); + // 4b C' (2026-09-10 test drive, M6) — the SAME surface's fan-out branch. + // Row 4 asks the named branch and is blind to this one: the leak is + // not the refusal, it is the not-found message the fan-out composes + // after every private extension has already been refused. + // + // Asserted against the LIVE map rather than against the constant, so + // the row cannot pass by naming the one extension the fixture + // happens to load. Every name in this fixture's map is private, so + // Gate E's verdict for a public caller is empty and the message may + // carry none of them. + let loaded = agent3.extension_manager.list_extensions().await.unwrap(); + assert!( + !loaded.is_empty(), + "the fixture loaded nothing, so this row asserts nothing" + ); + let fanout_on = read_unknown_resource_across_extensions(&agent3).await; + for name in &loaded { + assert!( + !fanout_on.contains(name.as_str()), + "a public caller was handed `{name}` in a not-found message — the set Gate E \ + exists to withhold: {fanout_on}" + ); + } + // 5+12 E+F2 (Tasks 16, 18) — discovery, and a private server's instructions // in a public system prompt. assert!(!extension_names_and_instructions(&agent3) @@ -772,6 +826,17 @@ async fn the_master_toggle_governs_every_gate_in_both_directions() { assert!(extension_names_and_instructions(&agent3) .await .contains(PRIVATE_EXTENSION)); + // 4b: the fan-out's roster stops being filtered too, which is what makes the + // ON column above a privacy filter rather than a message that never names + // anything. `allowed_extension_keys` returns every key with the toggle + // off, so the private fixture is back in the list. + let fanout_off = read_unknown_resource_across_extensions(&agent3).await; + assert!( + fanout_off.contains(PRIVATE_EXTENSION), + "the fan-out named nothing with privacy tiers OFF, so the ON column would pass \ + against an implementation that simply deleted the roster: {fanout_off}" + ); + assert_eq!(search_as(ProviderTier::Public, &sm6, "cohort").await, 1); // 7 + 18: the same public session now reads the private chat, title and all. let load_off = chatrecall_load_as(&agent7, &s7, &target7.id).await; From 56f2814f4909fb480809f37045bfb36538e87da0 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Thu, 10 Sep 2026 11:07:44 -0700 Subject: [PATCH 2/3] privacy: pin the named branch's roster at the source, not with a vacuous 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. --- .../biorouter/src/agents/extension_manager.rs | 82 +++++++++++-------- 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/crates/biorouter/src/agents/extension_manager.rs b/crates/biorouter/src/agents/extension_manager.rs index cd8f615a1..61444afea 100644 --- a/crates/biorouter/src/agents/extension_manager.rs +++ b/crates/biorouter/src/agents/extension_manager.rs @@ -6417,54 +6417,68 @@ mod tests { assert!(as_private.contains("developer"), "{as_private}"); } - /// The same leak one door further in: `read_resource`'s `get_server_client` - /// miss, which composed the identical roster. - /// - /// `POST /agent/read_resource` never showed it — #206's - /// `read_resource_failure` replaces that message rather than forwarding it — - /// and that route keeps doing so. This asserts the message is safe at the - /// SOURCE, because a string that is only safe while one of its readers - /// discards it is one new reader away from being unsafe. - /// - /// The fixture is a name that IS reachable and yet has no client: a - /// capability the manager records without a server behind it. That is the - /// only shape that gets past `assert_extension_reachable` and into the miss. + /// The same roster one door further in: `read_resource`'s `get_server_client` + /// miss, which composed the identical list from the raw extension map. + /// + /// ⚠ **This one is defence in depth, and the honest reason is worth stating + /// rather than dressing up.** No capability can reach that branch with a + /// roster that differs from the map. `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 above and + /// never arrives, and a private caller — who does arrive — is shown + /// everything by Gate E anyway. What is left is the race the two locks + /// admit, and a message that is safe only because one of its readers (#206's + /// `read_resource_failure`, which replaces it) throws it away. + /// + /// So the composition is pinned at the SOURCE, because behaviour cannot see + /// it, and the reachable half is asserted underneath so the branch is known + /// to work rather than merely to be spelled correctly. A test that claimed + /// to demonstrate a public-caller leak here would be vacuous: the first + /// draft of this one passed against the unfixed tree. #[tokio::test] - async fn the_named_branchs_not_found_message_carries_gate_es_roster_only() { + async fn the_named_branchs_not_found_message_is_composed_from_gate_es_roster() { + let source = include_str!("extension_manager.rs"); + let miss = source + .split("let client = match self.get_server_client(extension_name).await {") + .nth(1) + .expect("`read_resource` must still look its client up before reading") + .split("\n };") + .next() + .expect("the lookup must be a match block"); + assert!( + miss.contains("self.allowed_extension_keys(admitted).await"), + "the not-found message is composed from something other than Gate E's verdict \ + again: {miss}" + ); + assert!( + !miss.contains("self.extensions"), + "the not-found message reaches back into the raw extension map, which is the \ + shape finding M6 closed next door: {miss}" + ); + + // The reachable half. A private caller naming something that is not + // installed is the ONE path into this branch, and it must still answer + // with a usable list rather than an empty one. let (_dir, em, _sm, _id) = manager_with_a_session().await; em.add_mock_extension("ucsfomopagent".to_string(), Arc::new(MockClient {})) .await; em.add_mock_extension("developer".to_string(), Arc::new(MockClient {})) .await; - - let public = - crate::privacy::CallCapability::for_test(crate::privacy::ProviderTier::Public, true); - let mut visible = em.allowed_extension_keys(Some(public)).await; - visible.sort(); - assert_eq!( - visible, - vec!["developer".to_string()], - "the fixture does not discriminate unless Gate E really hides the private one" - ); - - // Reached directly, because the only production route into the miss is a - // key present in Gate E's verdict whose client has gone. Asserting the - // composed message is what this test is for. + let private = + crate::privacy::CallCapability::for_test(crate::privacy::ProviderTier::Private, true); let text = em .read_resource( "ui://cohort", - "developer", - Some(public), + "nonexistent_ext", + Some(private), CancellationToken::default(), ) .await - .expect_err("MockClient refuses every read") + .expect_err("nothing is loaded under that name") .message .to_string(); - assert!( - !text.contains("ucsfomopagent"), - "the named branch leaked the private roster: {text}" - ); + assert!(text.contains("developer"), "{text}"); + assert!(text.contains("ucsfomopagent"), "{text}"); } /// Finding M18: a name this gate could not resolve is still refused, and the From e22bae3fddef29f4f46dd4c83a9183a72b7069d3 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Thu, 10 Sep 2026 11:13:47 -0700 Subject: [PATCH 3/3] docs(privacy): record why the resource surface's refusal cannot name what it refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/security/privacy-tiers.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/security/privacy-tiers.md b/docs/security/privacy-tiers.md index 12ddee355..8153afa87 100644 --- a/docs/security/privacy-tiers.md +++ b/docs/security/privacy-tiers.md @@ -2577,6 +2577,33 @@ workarounds**, following the register established by issue #42's operator-disabl **Never leak content in a refusal.** Refusals go into the model's context, so they name the tool and the tier only — never a session title (LLM-generated content) and never a working directory. +**Gate C' — the resource and prompt surface — says something different, and must.** The sibling +entry points that reach a server without being a tool call go through +`ExtensionManager::assert_extension_reachable`, which reads an **unknown** name as Private. That is +the one place in this feature where the unknown-name default is inverted, deliberately: the +alternative is permitting a reach at a name the manager could not resolve at all. It means the gate +does not know whether the name it is refusing belongs to a private extension or to nothing, so it +cannot say that it does — and until the 2026-09-10 test drive (finding M18) it said it anyway, +sending a model looking for a private model to reach an extension that did not exist. + +`privacy::refusal::private_or_absent_refusal` is the sentence that surface returns instead. It +states the disjunction — a private extension, or a name that is not installed — and returns the +**identical string in both cases**, which is the constraint that rules out the obvious repair: + +> `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. … + +⚠ **Answering "no such extension" for the absent branch would be an existence oracle.** A public +caller could walk names until one answered differently and learn which private connectors a chat has +loaded — precisely the set Gate E withholds, and precisely the leak finding M6 closed next door in +`read_resource_tool`'s not-found roster. Two renderings of one predicate (`tier_refuses`), each true +where it is used: Gate C proper resolves an installed client before it refuses, so its flat +statement is a fact; Gate C' has not, so its statement is a disjunction. + ### 14.5 Two low-cost changes that remove whole classes of surprise **Relabel the provider groups so the two taxonomies are literally the same words in the same