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..61444afea 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,196 @@ 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 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_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 private = + crate::privacy::CallCapability::for_test(crate::privacy::ProviderTier::Private, true); + let text = em + .read_resource( + "ui://cohort", + "nonexistent_ext", + Some(private), + CancellationToken::default(), + ) + .await + .expect_err("nothing is loaded under that name") + .message + .to_string(); + assert!(text.contains("developer"), "{text}"); + assert!(text.contains("ucsfomopagent"), "{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; 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