diff --git a/core/archipelago/src/assistant/egress.rs b/core/archipelago/src/assistant/egress.rs index d6331a69..7cdd334b 100644 --- a/core/archipelago/src/assistant/egress.rs +++ b/core/archipelago/src/assistant/egress.rs @@ -524,14 +524,12 @@ mod tests { !has_bip39_length_word_run(&prompt), "the node's own system prompt must never trip the seed screen" ); - // The disabled-categories paragraph (needs-marker instruction) is - // prompt text too — it must hold to the same guarantee. - let all_disabled: Vec<_> = - crate::assistant::PermissionCategory::ALL.into_iter().collect(); - let prompt_with_disabled = crate::assistant::build_system_prompt(&visible, &all_disabled); + // The DISABLED section (listed-but-refused tools) is prompt text + // too — it must hold to the same guarantee. + let prompt_with_disabled = crate::assistant::build_system_prompt(&[], &visible); assert!( !has_bip39_length_word_run(&prompt_with_disabled), - "the disabled-categories paragraph must never trip the seed screen" + "the DISABLED tools section must never trip the seed screen" ); let body = json!({ "model": "claude-haiku-4-5", diff --git a/core/archipelago/src/assistant/loop_.rs b/core/archipelago/src/assistant/loop_.rs index e8e60d2d..1f835625 100644 --- a/core/archipelago/src/assistant/loop_.rs +++ b/core/archipelago/src/assistant/loop_.rs @@ -190,8 +190,9 @@ pub async fn run_loop( /// The single choke point every tool call passes through, regardless of /// which backend produced it. Enforces, in order: D-06 (curated allowlist — /// unknown names are refused, never silently ignored), D-16 (default-closed -/// category grants — re-checked here even though the system prompt already -/// omits ungranted tools; never trust that as the only enforcement layer), +/// category grants — re-checked here even though the system prompt splits +/// available vs DISABLED tools; never trust the prompt as an enforcement +/// layer), /// schema validation (never coerce, never guess — AI-SPEC §4b.1), and D-07 /// (every destructive tool suspends on the confirm gate before execution — /// only a matching human "yes" releases it; a decline or timeout returns a diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs index f82b2f5f..05b0fad8 100644 --- a/core/archipelago/src/assistant/mod.rs +++ b/core/archipelago/src/assistant/mod.rs @@ -799,7 +799,7 @@ recommendation renders as plain text and is easily missed."; pub fn build_system_prompt( visible_tools: &[tools::ToolDef], - disabled_categories: &[PermissionCategory], + disabled_tools: &[tools::ToolDef], ) -> String { let mut prompt = String::from(SYSTEM_PROMPT_PREAMBLE); if visible_tools.is_empty() { @@ -815,29 +815,33 @@ pub fn build_system_prompt( } } // A DISABLED capability the user asks for is the moment the trusted - // chrome can help — but only if the turn says so in a parseable way. - // The model never sees the disabled tools themselves (D-16), only the - // category names; the marker it emits can only ever make the app OFFER - // the Settings screen, never change a grant. - if !disabled_categories.is_empty() { - let list = disabled_categories - .iter() - .map(|c| { - serde_json::to_value(c) - .ok() - .and_then(|v| v.as_str().map(|s| s.to_string())) - .unwrap_or_else(|| format!("{c:?}")) - }) - .collect::>() - .join(", "); - prompt.push_str(&format!( - "\n\nSome capabilities are currently DISABLED by the operator: {list}. If \ - fulfilling the request would need one of them, say so plainly — the operator can \ - switch it on in Settings → AI Data Access — and end the reply with the marker \ - [[needs:]] (one per disabled category the request touches, e.g. \ - [[needs:media]]). Never name a tool for a disabled category; the marker is how \ - the app offers the right toggle." - )); + // chrome can help — and the RELIABLE signal for it is a tool call, not + // prose compliance (a small local model asked for shared files answered + // with a workaround narrative and no marker, live on 2026-08-07). So + // disabled tools are LISTED here and stay callable in the schema: the + // execution gate refuses the call and records the category, which is + // what the chrome turns into the "enable it in Settings" offer. The + // prompt split is UX and attack-surface shaping — the boundary is, and + // remains, the server-side grant re-check in `execute_tool`. + if !disabled_tools.is_empty() { + prompt.push_str( + "\n\nDISABLED by the operator right now (each is switched off in Settings → AI \ + Data Access; calling one is REFUSED, and that refusal is exactly how the operator \ + gets offered the on-switch):\n", + ); + for tool in disabled_tools { + let cat = serde_json::to_value(tool.category) + .ok() + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| format!("{:?}", tool.category)); + prompt.push_str(&format!("- {} [{}]: {}\n", tool.name, cat, tool.description)); + } + prompt.push_str( + "When the request genuinely needs one of these, CALL it — then tell the operator \ + it is currently switched off and can be enabled in Settings → AI Data Access. \ + Never answer with a workaround narrative while a listed tool is the real path, \ + and never call a disabled tool speculatively.", + ); } prompt } @@ -926,15 +930,20 @@ pub async fn chat_with_surfaces( let registry = tools::registry(); let grants = caller.granted_categories(handler.data_dir()).await; let visible_tools = registry.visible_to(&grants); - let disabled_categories: Vec = PermissionCategory::ALL - .into_iter() - .filter(|c| !grants.contains(c)) + // Disabled tools stay IN the schema: the model's reliable signal for + // "this needs a toggle" is calling the tool and being refused by the + // execution gate — not prose compliance (see build_system_prompt). + let all_tools = registry.all(); + let disabled_tools: Vec = all_tools + .iter() + .filter(|t| !grants.contains(&t.category)) + .cloned() .collect(); let (backend, backend_id) = backends::select_backend(&handler).await; tracing::info!(backend = %backend_id, "assistant.chat: backend selected for this turn"); - let system_prompt = build_system_prompt(&visible_tools, &disabled_categories); + let system_prompt = build_system_prompt(&visible_tools, &disabled_tools); let key = history::HistoryKey::from_caller(&caller); @@ -963,7 +972,7 @@ pub async fn chat_with_surfaces( let (answer, turn_messages) = loop_::run_loop( backend.as_ref(), &system_prompt, - &visible_tools, + &all_tools, turn_history, &ctx, ) @@ -1288,26 +1297,39 @@ mod tests { ); } - /// The system prompt built for a caller must never mention a tool - /// whose category is not currently granted — the prompt filter is - /// defense in depth, never the gate (S-05's gate is - /// `settings_tool_respects_category_grant` in tools.rs), but it must - /// still hold. + /// The prompt's AVAILABLE section must list only granted-category tools. + /// Ungranted tools appear exclusively under DISABLED — listed so the + /// model's call attempt hits the execution gate and records the refusal + /// (the reliable Settings-offer signal), never as a usable capability. #[test] - fn ungranted_tool_absent_from_system_prompt() { + fn ungranted_tool_only_ever_in_disabled_section() { let reg = tools::registry(); let mut grants = BTreeSet::new(); grants.insert(PermissionCategory::System); let visible = reg.visible_to(&grants); - let prompt = build_system_prompt(&visible, &[]); + let disabled: Vec = reg + .all() + .into_iter() + .filter(|t| !grants.contains(&t.category)) + .collect(); + let prompt = build_system_prompt(&visible, &disabled); + let available_block = prompt + .split("DISABLED by the operator") + .next() + .unwrap_or(&prompt); for tool in reg.all() { if tool.category == PermissionCategory::System { continue; } assert!( - !prompt.contains(tool.name), - "ungranted tool {} leaked into the system prompt", + !available_block.contains(tool.name), + "ungranted tool {} leaked into the AVAILABLE section", + tool.name + ); + assert!( + prompt.contains(tool.name), + "ungranted tool {} missing from the DISABLED section — the chrome would have no refusal to offer", tool.name ); } @@ -1316,8 +1338,8 @@ mod tests { assert!( reg.visible_to(&grants) .iter() - .any(|t| prompt.contains(t.name)), - "expected at least one granted-category tool name in the prompt" + .any(|t| available_block.contains(t.name)), + "expected at least one granted-category tool name in the available section" ); } @@ -1350,19 +1372,27 @@ mod tests { } } - /// The disabled-categories paragraph exists so the chrome's - /// "enable it in Settings" offer has something to key on: no tools are - /// named, but a `[[needs:]]` marker ends the reply and becomes a - /// refused category. With nothing disabled, no marker instruction. + /// Disabled tools are LISTED (name + category) so the model's call + /// attempt hits the gate and the refusal becomes the chrome's Settings + /// offer. With nothing disabled, no DISABLED section exists. #[test] - fn disabled_categories_produce_needs_marker_instruction() { - let prompt = build_system_prompt(&[], &[PermissionCategory::Media, PermissionCategory::Apps]); - assert!(prompt.contains("media"), "disabled ids must be listed"); - assert!(prompt.contains("[[needs:media]]"), "marker example must be taught"); + fn disabled_tools_are_listed_as_callable_but_refused() { + let disabled: Vec = tools::registry() + .all() + .into_iter() + .filter(|t| t.category == PermissionCategory::Media) + .collect(); + let prompt = build_system_prompt(&[], &disabled); + assert!(prompt.contains("DISABLED by the operator")); + assert!(prompt.contains("content_list [media]")); + assert!( + prompt.contains("CALL it"), + "the prompt must route needs through a refused call, not prose workarounds" + ); let nothing_disabled = build_system_prompt(&[], &[]); assert!( - !nothing_disabled.contains("[[needs:"), - "no disabled categories → no marker instruction" + !nothing_disabled.contains("DISABLED by the operator"), + "no disabled tools → no DISABLED section" ); }