Skip to content

Commit 20b287d

Browse files
author
Olivier Mansour
committed
fix: enhance execute tool permission logic for fine-grained command control
- Implement 3-step permission evaluation logic: 1. If execute_bash in allowedTools → always allow (except denied commands) 2. Check denied command patterns → deny if matched 3. Use requires_acceptance with allowedCommands for fine-grained control - Fix design flaw where allowedCommands patterns only worked when execute_bash was in allowedTools array - Enable users to specify command patterns without granting blanket tool access - Add comprehensive test suite with 4 new test cases: - test_eval_perm_allowed_tools_priority: validates trusted tool behavior - test_eval_perm_fine_grained_control: validates pattern-based permissions - test_eval_perm_default_behavior: validates default fallback behavior - test_eval_perm_invalid_settings: validates error handling - Maintain backward compatibility - all existing tests pass This resolves the issue where /tools command displayed allowed command patterns but the underlying permission logic didn't properly evaluate them for fine-grained control scenarios.
1 parent dd6de52 commit 20b287d

File tree

1 file changed

+184
-8
lines changed
  • crates/chat-cli/src/cli/chat/tools/execute

1 file changed

+184
-8
lines changed

crates/chat-cli/src/cli/chat/tools/execute/mod.rs

Lines changed: 184 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -194,12 +194,47 @@ impl ExecuteCommand {
194194
let Self { command, .. } = self;
195195
let tool_name = if cfg!(windows) { "execute_cmd" } else { "execute_bash" };
196196
let is_in_allowlist = agent.allowed_tools.contains("execute_bash");
197+
198+
// Step 1: If execute_bash is in allowedTools, always allow (except denied commands)
199+
if is_in_allowlist {
200+
if let Some(settings) = agent.tools_settings.get(tool_name) {
201+
let Settings { denied_commands, .. } = match serde_json::from_value::<Settings>(settings.clone()) {
202+
Ok(settings) => settings,
203+
Err(e) => {
204+
error!("Failed to deserialize tool settings for execute_bash: {:?}", e);
205+
return PermissionEvalResult::Ask;
206+
},
207+
};
208+
209+
if denied_commands.iter().any(|dc| command.contains(dc)) {
210+
return PermissionEvalResult::Deny;
211+
}
212+
}
213+
return PermissionEvalResult::Allow;
214+
}
215+
216+
// Step 2: Check if command matches denied patterns
217+
if let Some(settings) = agent.tools_settings.get(tool_name) {
218+
let Settings { denied_commands, .. } = match serde_json::from_value::<Settings>(settings.clone()) {
219+
Ok(settings) => settings,
220+
Err(e) => {
221+
error!("Failed to deserialize tool settings for execute_bash: {:?}", e);
222+
return PermissionEvalResult::Ask;
223+
},
224+
};
225+
226+
if denied_commands.iter().any(|dc| command.contains(dc)) {
227+
return PermissionEvalResult::Deny;
228+
}
229+
}
230+
231+
// Step 3: Use requires_acceptance logic with allowedCommands patterns
197232
match agent.tools_settings.get(tool_name) {
198-
Some(settings) if is_in_allowlist => {
233+
Some(settings) => {
199234
let Settings {
200235
allowed_commands,
201-
denied_commands,
202236
allow_read_only,
237+
..
203238
} = match serde_json::from_value::<Settings>(settings.clone()) {
204239
Ok(settings) => settings,
205240
Err(e) => {
@@ -208,18 +243,13 @@ impl ExecuteCommand {
208243
},
209244
};
210245

211-
if denied_commands.iter().any(|dc| command.contains(dc)) {
212-
return PermissionEvalResult::Deny;
213-
}
214-
215246
if self.requires_acceptance(Some(&allowed_commands), allow_read_only) {
216247
PermissionEvalResult::Ask
217248
} else {
218249
PermissionEvalResult::Allow
219250
}
220251
},
221-
None if is_in_allowlist => PermissionEvalResult::Allow,
222-
_ => {
252+
None => {
223253
if self.requires_acceptance(None, default_allow_read_only()) {
224254
PermissionEvalResult::Ask
225255
} else {
@@ -384,4 +414,150 @@ mod tests {
384414
);
385415
}
386416
}
417+
418+
#[test]
419+
fn test_eval_perm_allowed_tools_priority() {
420+
use crate::cli::agent::{Agent, PermissionEvalResult};
421+
422+
// Test that when execute_bash is in allowedTools, it always allows (except denied commands)
423+
let agent_json = serde_json::json!({
424+
"name": "test_agent",
425+
"allowedTools": ["execute_bash"],
426+
"toolsSettings": {
427+
"execute_bash": {
428+
"allowedCommands": ["ls"],
429+
"deniedCommands": ["rm"],
430+
"allowReadOnly": false
431+
}
432+
}
433+
});
434+
435+
let agent: Agent = serde_json::from_value(agent_json).unwrap();
436+
437+
let dangerous_cmd = ExecuteCommand {
438+
command: "sudo dd if=/dev/zero of=/dev/sda".to_string(),
439+
summary: None,
440+
};
441+
442+
let denied_cmd = ExecuteCommand {
443+
command: "rm file.txt".to_string(),
444+
summary: None,
445+
};
446+
447+
// Should allow dangerous command because execute_bash is in allowedTools
448+
assert_eq!(dangerous_cmd.eval_perm(&agent), PermissionEvalResult::Allow);
449+
450+
// Should deny command matching denied pattern
451+
assert_eq!(denied_cmd.eval_perm(&agent), PermissionEvalResult::Deny);
452+
}
453+
454+
#[test]
455+
fn test_eval_perm_fine_grained_control() {
456+
use crate::cli::agent::{Agent, PermissionEvalResult};
457+
458+
// Test fine-grained control when execute_bash is NOT in allowedTools
459+
let agent_json = serde_json::json!({
460+
"name": "test_agent",
461+
"allowedTools": [],
462+
"toolsSettings": {
463+
"execute_bash": {
464+
"allowedCommands": ["git status", "ls.*"],
465+
"deniedCommands": ["rm"],
466+
"allowReadOnly": true
467+
}
468+
}
469+
});
470+
471+
let agent: Agent = serde_json::from_value(agent_json).unwrap();
472+
473+
let allowed_cmd = ExecuteCommand {
474+
command: "git status".to_string(),
475+
summary: None,
476+
};
477+
478+
let pattern_match_cmd = ExecuteCommand {
479+
command: "ls -la".to_string(),
480+
summary: None,
481+
};
482+
483+
let readonly_cmd = ExecuteCommand {
484+
command: "cat file.txt".to_string(),
485+
summary: None,
486+
};
487+
488+
let denied_cmd = ExecuteCommand {
489+
command: "rm file.txt".to_string(),
490+
summary: None,
491+
};
492+
493+
let dangerous_cmd = ExecuteCommand {
494+
command: "sudo dd if=/dev/zero of=/dev/sda".to_string(),
495+
summary: None,
496+
};
497+
498+
// Should allow command matching allowedCommands pattern
499+
assert_eq!(allowed_cmd.eval_perm(&agent), PermissionEvalResult::Allow);
500+
501+
// Should allow command matching regex pattern
502+
assert_eq!(pattern_match_cmd.eval_perm(&agent), PermissionEvalResult::Allow);
503+
504+
// Should allow readonly command when allowReadOnly is true
505+
assert_eq!(readonly_cmd.eval_perm(&agent), PermissionEvalResult::Allow);
506+
507+
// Should deny command matching denied pattern
508+
assert_eq!(denied_cmd.eval_perm(&agent), PermissionEvalResult::Deny);
509+
510+
// Should ask for dangerous command not matching any allowed pattern
511+
assert_eq!(dangerous_cmd.eval_perm(&agent), PermissionEvalResult::Ask);
512+
}
513+
514+
#[test]
515+
fn test_eval_perm_default_behavior() {
516+
use crate::cli::agent::{Agent, PermissionEvalResult};
517+
518+
// Test default behavior when no settings are configured
519+
let agent = Agent::default();
520+
521+
let readonly_cmd = ExecuteCommand {
522+
command: "ls -la".to_string(),
523+
summary: None,
524+
};
525+
526+
let dangerous_cmd = ExecuteCommand {
527+
command: "sudo rm -rf /".to_string(),
528+
summary: None,
529+
};
530+
531+
// Should allow readonly command with default settings
532+
assert_eq!(readonly_cmd.eval_perm(&agent), PermissionEvalResult::Allow);
533+
534+
// Should ask for dangerous command with default settings
535+
assert_eq!(dangerous_cmd.eval_perm(&agent), PermissionEvalResult::Ask);
536+
}
537+
538+
#[test]
539+
fn test_eval_perm_invalid_settings() {
540+
use crate::cli::agent::{Agent, PermissionEvalResult};
541+
542+
// Test behavior with invalid JSON settings
543+
let agent_json = serde_json::json!({
544+
"name": "test_agent",
545+
"allowedTools": [],
546+
"toolsSettings": {
547+
"execute_bash": {
548+
"invalidField": "invalidValue"
549+
}
550+
}
551+
});
552+
553+
let agent: Agent = serde_json::from_value(agent_json).unwrap();
554+
555+
let cmd = ExecuteCommand {
556+
command: "sudo systemctl restart nginx".to_string(),
557+
summary: None,
558+
};
559+
560+
// Should ask when settings are invalid (falls back to safe behavior)
561+
assert_eq!(cmd.eval_perm(&agent), PermissionEvalResult::Ask);
562+
}
387563
}

0 commit comments

Comments
 (0)