fix(assistant): disabled tools are listed and callable — the gate's refusal IS the Settings signal

Live evidence, two ways: the 9abc1623 banner never fired because D-16 hides
ungranted tools (model never calls → refused_categories always empty), and
the [[needs:id>]] marker fix failed because a small local model answers with
a workaround narrative instead of emitting structured markers.

The model's reliable, trained behavior is tool CALLING — so disabled tools
are now listed in a DISABLED prompt section and remain in the schema. A call
hits the execution gate, which refuses and records the category → the
trusted chrome offers Settings → AI Data Access. Deterministic and
model-independent. The prompt split is UX/attack-surface shaping; the
security boundary remains the server-side grant re-check in execute_tool
(loop_.rs), unchanged and now the single enforcement layer by design.

Tests: ungranted_tool_only_ever_in_disabled_section (section-aware),
disabled_tools_are_listed_as_callable_but_refused, marker extraction kept
as a harmless safety net. 127/127 assistant suite green.

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