From 7c0f67134a6c03680d225af3cd33081382746898 Mon Sep 17 00:00:00 2001 From: local Date: Tue, 2 Jun 2026 17:42:15 +0800 Subject: [PATCH] Fix constrained deny-list tool declarations --- src/session.rs | 24 ++++++++++--- tests/session_runner_slash.rs | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/src/session.rs b/src/session.rs index eeec543..466281a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -628,19 +628,19 @@ fn tool_allowed(name: &str, scope: Option<&SessionRuntimeScope>) -> bool { && !scope .allowed_tools .iter() - .any(|decl| tool_decl_matches(decl, name)) + .any(|decl| tool_decl_allows(decl, name)) { return false; } !scope .disallowed_tools .iter() - .any(|decl| tool_decl_matches(decl, name)) + .any(|decl| tool_decl_denies(decl, name)) } -fn tool_decl_matches(decl: &str, name: &str) -> bool { +fn tool_decl_allows(decl: &str, name: &str) -> bool { let decl = decl.trim(); - if decl == "*" || decl.eq_ignore_ascii_case(name) { + if decl == "*" { return true; } if decl.contains('(') || decl.contains(')') { @@ -649,6 +649,22 @@ fn tool_decl_matches(decl: &str, name: &str) -> bool { decl.eq_ignore_ascii_case(name) } +fn tool_decl_denies(decl: &str, name: &str) -> bool { + let decl = decl.trim(); + if tool_decl_allows(decl, name) { + return true; + } + constrained_tool_decl_head(decl).is_some_and(|head| head.eq_ignore_ascii_case(name)) +} + +fn constrained_tool_decl_head(decl: &str) -> Option<&str> { + if !decl.contains('(') && !decl.contains(')') { + return None; + } + let head = decl.split(['(', ')']).next().unwrap_or("").trim(); + (!head.is_empty()).then_some(head) +} + fn combine_system_prompt(base: Option, memory: Option) -> Option { match (base, memory) { (Some(base), Some(memory)) if !base.trim().is_empty() => { diff --git a/tests/session_runner_slash.rs b/tests/session_runner_slash.rs index 162861a..74a6d01 100644 --- a/tests/session_runner_slash.rs +++ b/tests/session_runner_slash.rs @@ -456,6 +456,71 @@ async fn constrained_tool_policy_declarations_do_not_expand_to_whole_tool() { ); } +#[tokio::test] +async fn constrained_disallowed_tool_policy_blocks_whole_tool() { + let seen = Arc::new(Mutex::new(Vec::new())); + let seen_tools = Arc::new(Mutex::new(Vec::new())); + let log = Arc::new(Mutex::new(Vec::new())); + let model = ScriptedModel { + seen, + seen_tools: seen_tools.clone(), + chunks: vec![ + ModelChunk::ToolCall(ToolCall { + id: "call-1".to_string(), + name: "bash".to_string(), + input: serde_json::json!({ "text": "blocked" }), + }), + ModelChunk::End { + stop_reason: StopReason::EndTurn, + }, + ], + }; + let session = Arc::new(Session::new( + Arc::new(model), + vec![Arc::new(EchoTool { + name: "bash", + log: log.clone(), + })], + )); + + let mut templates = HashMap::new(); + templates.insert( + "audit".to_string(), + skill_template( + "Audit.", + SkillRuntimeOptions { + disallowed_tools: vec!["Bash(git push:*)".to_string()], + ..SkillRuntimeOptions::default() + }, + ), + ); + + let mut events = Vec::new(); + let runner = SessionRunner::new(session, "test-session".into(), Arc::new(NullHost)) + .with_prompt_templates(Arc::new(templates)); + + let outcome = runner + .run_input("/audit".to_string(), |ev| events.push(ev)) + .await; + assert_eq!(outcome, RunOutcome::Completed); + assert!( + seen_tools.lock().unwrap()[0].is_empty(), + "constrained denied tool should not be exposed to the model" + ); + assert!(log.lock().unwrap().is_empty(), "denied tool must not run"); + assert!( + events.iter().any(|ev| matches!( + ev, + ra::session_runner::RunnerEvent::ToolCallEnd { + is_error: true, + content, + .. + } if content.contains("denied by skill-scoped tool policy") + )), + "denied tool should produce an error ToolCallEnd: {events:?}" + ); +} + #[tokio::test] async fn skill_scoped_disallowed_tools_block_execution() { let seen = Arc::new(Mutex::new(Vec::new()));