Skip to content

Commit 1fa1d74

Browse files
authored
Support deny_by_default for bash command tool (#2999)
Problem: Customer wants to allow some commands and deny all other commands. To do that, today customer needs to add commands to allow list and use a negative regex in deny list to exclude other commands. That actually doesn't work. The deny list regex we use in Rust doesn't work for such negative look around. Given such pattern, the regex will not match anything. This is bad because customer "deny list" is now not matching anything. The 1st change is to fallback deny list to "*" when regex compilation fails, instead of ignoring not matching anything. The 2nd change is to introduce a boolean flag called "denyByDefault" so anything not in allowed list can be denied by default (instead of "ask" by default). With this flag, user can deny by default and explicitly add allowed commands to allowed list. This flag is OFF by default so it doesn't change default UX.
1 parent b6f7819 commit 1fa1d74

File tree

2 files changed

+77
-17
lines changed

2 files changed

+77
-17
lines changed

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

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ impl ExecuteCommand {
195195
allowed_commands: Vec<String>,
196196
#[serde(default)]
197197
denied_commands: Vec<String>,
198+
#[serde(default)]
199+
deny_by_default: bool,
198200
#[serde(default = "default_allow_read_only")]
199201
auto_allow_readonly: bool,
200202
}
@@ -211,6 +213,7 @@ impl ExecuteCommand {
211213
let Settings {
212214
allowed_commands,
213215
denied_commands,
216+
deny_by_default,
214217
auto_allow_readonly,
215218
} = match serde_json::from_value::<Settings>(settings.clone()) {
216219
Ok(settings) => settings,
@@ -222,7 +225,14 @@ impl ExecuteCommand {
222225

223226
let denied_match_set = denied_commands
224227
.iter()
225-
.filter_map(|dc| Regex::new(&format!(r"\A{dc}\z")).ok())
228+
.filter_map(|dc| match Regex::new(&format!(r"\A{dc}\z")) {
229+
Ok(regex) => Some(regex),
230+
Err(e) => {
231+
error!("Invalid regex pattern '{}' in deniedCommands: {:?}. Treating as deny-all for security.", dc, e);
232+
// Invalid regex - treat as "deny all" for security
233+
Regex::new(r"\A.*\z").ok()
234+
}
235+
})
226236
.filter(|r| r.is_match(command))
227237
.map(|r| r.to_string())
228238
.collect::<Vec<_>>();
@@ -234,7 +244,11 @@ impl ExecuteCommand {
234244
if is_in_allowlist {
235245
PermissionEvalResult::Allow
236246
} else if self.requires_acceptance(Some(&allowed_commands), auto_allow_readonly) {
237-
PermissionEvalResult::Ask
247+
if deny_by_default {
248+
PermissionEvalResult::Deny(vec!["not in allowed commands list".to_string()])
249+
} else {
250+
PermissionEvalResult::Ask
251+
}
238252
} else {
239253
PermissionEvalResult::Allow
240254
}
@@ -273,7 +287,7 @@ mod tests {
273287
use std::collections::HashMap;
274288

275289
use super::*;
276-
use crate::cli::agent::ToolSettingTarget;
290+
use crate::cli::agent::{Agent, ToolSettingTarget};
277291

278292
#[test]
279293
fn test_requires_acceptance_for_readonly_commands() {
@@ -519,13 +533,6 @@ mod tests {
519533

520534
#[tokio::test]
521535
async fn test_eval_perm_allow_read_only_enabled() {
522-
use std::collections::HashMap;
523-
524-
use crate::cli::agent::{
525-
Agent,
526-
ToolSettingTarget,
527-
};
528-
529536
let os = Os::new().await.unwrap();
530537
let tool_name = if cfg!(windows) { "execute_cmd" } else { "execute_bash" };
531538

@@ -567,13 +574,6 @@ mod tests {
567574

568575
#[tokio::test]
569576
async fn test_eval_perm_allow_read_only_with_denied_commands() {
570-
use std::collections::HashMap;
571-
572-
use crate::cli::agent::{
573-
Agent,
574-
ToolSettingTarget,
575-
};
576-
577577
let os = Os::new().await.unwrap();
578578
let tool_name = if cfg!(windows) { "execute_cmd" } else { "execute_bash" };
579579

@@ -616,6 +616,63 @@ mod tests {
616616
assert!(matches!(res, PermissionEvalResult::Allow));
617617
}
618618

619+
#[tokio::test]
620+
async fn test_eval_perm_denied_commands_invalid_regex() {
621+
622+
let os = Os::new().await.unwrap();
623+
let agent = Agent {
624+
name: "test_agent".to_string(),
625+
tools_settings: {
626+
let mut map = HashMap::<ToolSettingTarget, serde_json::Value>::new();
627+
map.insert(
628+
ToolSettingTarget("execute_bash".to_string()),
629+
serde_json::json!({
630+
"deniedCommands": ["^(?!ls$).*"] // Invalid regex with unsupported lookahead
631+
}),
632+
);
633+
map
634+
},
635+
..Default::default()
636+
};
637+
638+
// Test command that should be denied by the pattern
639+
let pwd_cmd = serde_json::from_value::<ExecuteCommand>(serde_json::json!({"command": "pwd",})).unwrap();
640+
let res = pwd_cmd.eval_perm(&os, &agent);
641+
assert!(matches!(res, PermissionEvalResult::Deny(_)), "Invalid regex should deny all commands, got {:?}", res);
642+
}
643+
644+
#[tokio::test]
645+
async fn test_eval_perm_deny_by_default() {
646+
let os = Os::new().await.unwrap();
647+
let tool_name = if cfg!(windows) { "execute_cmd" } else { "execute_bash" };
648+
649+
let agent = Agent {
650+
name: "test_agent".to_string(),
651+
tools_settings: {
652+
let mut map = HashMap::<ToolSettingTarget, serde_json::Value>::new();
653+
map.insert(
654+
ToolSettingTarget(tool_name.to_string()),
655+
serde_json::json!({
656+
"allowedCommands": ["ls"],
657+
"denyByDefault": true
658+
}),
659+
);
660+
map
661+
},
662+
..Default::default()
663+
};
664+
665+
// Test allowed command - should be allowed
666+
let ls_cmd = serde_json::from_value::<ExecuteCommand>(serde_json::json!({"command": "ls",})).unwrap();
667+
let res = ls_cmd.eval_perm(&os, &agent);
668+
assert!(matches!(res, PermissionEvalResult::Allow));
669+
670+
// Test non-allowed command - should be denied (not asked)
671+
let pwd_cmd = serde_json::from_value::<ExecuteCommand>(serde_json::json!({"command": "pwd"})).unwrap();
672+
let res = pwd_cmd.eval_perm(&os, &agent);
673+
assert!(matches!(res, PermissionEvalResult::Deny(_)));
674+
}
675+
619676
#[tokio::test]
620677
async fn test_cloudtrail_tracking() {
621678
use crate::cli::chat::consts::{

docs/built-in-tools.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ Execute the specified bash command.
3737
| `allowedCommands` | array of strings | `[]` | List of specific commands that are allowed without prompting. Supports regex formatting. Note that regex entered are anchored with \A and \z |
3838
| `deniedCommands` | array of strings | `[]` | List of specific commands that are denied. Supports regex formatting. Note that regex entered are anchored with \A and \z. Deny rules are evaluated before allow rules |
3939
| `autoAllowReadonly` | boolean | `false` | Whether to allow read-only commands without prompting |
40+
| `denyByDefault` | boolean | `false` | When true, deny any command outside `allowedCommands` and not auto-approved by `autoAllowReadonly`, instead of prompting for approval |
41+
42+
Note: regex does NOT support look-around, including look-ahead and look-behind.
4043

4144
## Fs_read Tool
4245

0 commit comments

Comments
 (0)