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),
"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",
+3 -2
View File
@@ -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
+80 -50
View File
@@ -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::<Vec<_>>()
.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:<id>]] (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> = 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<tools::ToolDef> = 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<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() {
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:<id>]]` 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::ToolDef> = 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"
);
}