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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user