Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(')') {
Expand All @@ -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<String>, memory: Option<String>) -> Option<String> {
match (base, memory) {
(Some(base), Some(memory)) if !base.trim().is_empty() => {
Expand Down
65 changes: 65 additions & 0 deletions tests/session_runner_slash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down
Loading