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