Skip to content

Commit 9eecb1e

Browse files
author
Olivier Mansour
committed
fix: adapt interactive allowed commands to use regex patterns
- Update interactive 'a' option to generate regex patterns instead of glob patterns - Fix pattern generation for option 1: use regex::escape() for exact commands - Fix pattern generation for option 2: use 'command\s.*' to match 'command ' + anything (but not command alone) - Remove glob-based pattern matching code that conflicted with main branch's regex approach - Update unit tests to reflect regex pattern behavior - Fix bug where commands like 'git log -1' weren't matching the allowed pattern Key changes: - Option 1 now generates: 'touch\ test\.txt' (escaped exact match) - Option 2 now generates: 'git\s.*' (matches 'git ' + anything, not 'git' alone) - Maintains compatibility with main branch's regex-based allowedCommands system - All tests passing with proper regex pattern validation Fixes issue where interactive menu generated incompatible glob patterns instead of the expected regex patterns used by the main branch.
1 parent 826db79 commit 9eecb1e

File tree

2 files changed

+24
-276
lines changed

2 files changed

+24
-276
lines changed

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1544,7 +1544,7 @@ impl ChatSession {
15441544
};
15451545

15461546
let (option_key, option_description) = if is_execute_tool {
1547-
("a", "' to allow similar commands. [")
1547+
("a", "' to allow similar or exact commands in the futur. [")
15481548
} else {
15491549
("t", "' to trust (always allow) this tool for the session. [")
15501550
};
@@ -2843,8 +2843,9 @@ impl ChatSession {
28432843

28442844
match choice {
28452845
"1" => {
2846-
// Allow this exact command only
2847-
self.add_allowed_command_pattern(&command, os).await?;
2846+
// Allow this exact command only - escape regex special characters
2847+
let escaped_command = regex::escape(&command);
2848+
self.add_allowed_command_pattern(&escaped_command, os).await?;
28482849
queue!(
28492850
self.stderr,
28502851
style::SetForegroundColor(Color::Green),
@@ -2854,13 +2855,14 @@ impl ChatSession {
28542855
return Ok(AllowCommandResult::AddedRule);
28552856
},
28562857
"2" => {
2857-
// Allow all commands with first word (e.g., "touch *")
2858-
let pattern = format!("{} *", first_word);
2858+
// Allow all commands with first word - create regex pattern
2859+
let escaped_first_word = regex::escape(&first_word);
2860+
let pattern = format!("{}\\s.*", escaped_first_word);
28592861
self.add_allowed_command_pattern(&pattern, os).await?;
28602862
queue!(
28612863
self.stderr,
28622864
style::SetForegroundColor(Color::Green),
2863-
style::Print(format!("\nAllowed command rule added: '{}'\n\n", pattern)),
2865+
style::Print(format!("\nAllowed command rule added for all '{}' commands\n\n", first_word)),
28642866
style::SetForegroundColor(Color::Reset)
28652867
)?;
28662868
return Ok(AllowCommandResult::AddedRule);

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

Lines changed: 16 additions & 270 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,6 @@ impl ExecuteCommand {
110110
return true;
111111
},
112112
Some(cmd) => {
113-
// Check if command matches any allowed pattern (exact match or wildcard)
114-
if Self::command_matches_allowed_patterns(cmd, &cmd_args.join(" "), allowed_commands) {
115-
continue;
116-
}
117113
// Special casing for `grep`. -P flag for perl regexp has RCE issues, apparently
118114
// should not be supported within grep but is flagged as a possibility since this is perl
119115
// regexp.
@@ -132,46 +128,6 @@ impl ExecuteCommand {
132128
false
133129
}
134130

135-
/// Check if a command matches any of the allowed patterns.
136-
/// Supports both exact string matching and wildcard patterns.
137-
fn command_matches_allowed_patterns(cmd: &str, full_command: &str, allowed_commands: &[String]) -> bool {
138-
for pattern in allowed_commands {
139-
// First try exact string matching for backward compatibility
140-
// Check both the first word and the full command
141-
if pattern == cmd || pattern == full_command {
142-
return true;
143-
}
144-
145-
// Then try wildcard pattern matching
146-
if pattern.contains('*') {
147-
// For wildcard patterns, we need to decide what to match against
148-
let match_target = if pattern.starts_with(cmd) {
149-
// If pattern starts with the command name, match against full command
150-
// e.g., "git commit*" should match "git commit -m message"
151-
full_command
152-
} else {
153-
// Otherwise, match against just the command name
154-
// e.g., "git*" should match "git"
155-
cmd
156-
};
157-
158-
if let Ok(glob) = globset::Glob::new(pattern) {
159-
if glob.compile_matcher().is_match(match_target) {
160-
return true;
161-
}
162-
} else {
163-
// If glob pattern is invalid, log warning and fall back to exact match
164-
tracing::warn!("Invalid glob pattern in allowedCommands: {}", pattern);
165-
if pattern == cmd || pattern == full_command {
166-
return true;
167-
}
168-
}
169-
}
170-
}
171-
172-
false
173-
}
174-
175131
pub async fn invoke(&self, output: &mut impl Write) -> Result<InvokeOutput> {
176132
let output = run_command(&self.command, MAX_TOOL_RESPONSE_SIZE / 3, Some(output)).await?;
177133
let clean_stdout = sanitize_unicode_tags(&output.stdout);
@@ -395,238 +351,28 @@ mod tests {
395351
}
396352
}
397353

398-
#[test]
399-
fn test_wildcard_pattern_matching() {
400-
let test_cases = vec![
401-
// Test case: (command, allowed_patterns, allow_read_only, should_require_acceptance)
402-
403-
// Exact string matching (backward compatibility)
404-
("git status", vec!["git".to_string()], true, false),
405-
("git status", vec!["ls".to_string()], true, true), // git is not read-only
406-
407-
// Basic wildcard patterns
408-
("git status", vec!["git*".to_string()], true, false),
409-
("git commit -m 'test'", vec!["git*".to_string()], true, false),
410-
("ls -la", vec!["git*".to_string()], true, false), // ls is read-only, so allowed
411-
("rm file", vec!["git*".to_string()], true, true), // rm is not read-only and not in pattern
412-
413-
// Specific command with wildcard
414-
("git commit -m 'test'", vec!["git commit*".to_string()], true, false),
415-
("git commit --amend", vec!["git commit*".to_string()], true, false),
416-
("git status", vec!["git commit*".to_string()], true, true), // git status doesn't match git commit*
417-
418-
// Multiple patterns
419-
("git status", vec!["ls*".to_string(), "git*".to_string()], true, false),
420-
("ls -la", vec!["ls*".to_string(), "git*".to_string()], true, false),
421-
("rm file", vec!["ls*".to_string(), "git*".to_string()], true, true),
422-
423-
// Complex patterns
424-
("npm install package", vec!["npm install *".to_string()], true, false),
425-
("npm run build", vec!["npm install *".to_string()], true, true),
426-
("npm run test", vec!["npm run *".to_string()], true, false),
427-
428-
// Mixed exact and wildcard patterns
429-
("git status", vec!["ls".to_string(), "git*".to_string()], true, false),
430-
("ls", vec!["ls".to_string(), "git*".to_string()], true, false),
431-
("rm file", vec!["ls".to_string(), "git*".to_string()], true, true),
432-
433-
// Edge cases
434-
("git", vec!["git*".to_string()], true, false),
435-
("g", vec!["git*".to_string()], true, true), // g is not read-only and doesn't match
436-
("gitfoo", vec!["git*".to_string()], true, false), // This should match git*
437-
438-
// Test with allow_read_only = false
439-
("ls -la", vec!["git*".to_string()], false, true), // ls doesn't match pattern and read-only not allowed
440-
("cat file", vec!["git*".to_string()], false, true), // cat doesn't match pattern and read-only not allowed
441-
];
442-
443-
for (command, allowed_patterns, allow_read_only, should_require_acceptance) in test_cases {
444-
let tool = serde_json::from_value::<ExecuteCommand>(serde_json::json!({
445-
"command": command,
446-
}))
447-
.unwrap();
448-
449-
let result = tool.requires_acceptance(Some(&allowed_patterns), allow_read_only);
450-
assert_eq!(
451-
result, should_require_acceptance,
452-
"Command '{}' with patterns {:?}, allow_read_only={} - expected requires_acceptance: {}, got: {}",
453-
command, allowed_patterns, allow_read_only, should_require_acceptance, result
454-
);
455-
}
456-
}
457-
458-
#[test]
459-
fn test_command_matches_allowed_patterns() {
460-
// Test exact matching - first word
461-
assert!(ExecuteCommand::command_matches_allowed_patterns("git", "git status", &["git".to_string()]));
462-
assert!(!ExecuteCommand::command_matches_allowed_patterns("git", "git status", &["ls".to_string()]));
463-
464-
// Test exact matching - full command
465-
assert!(ExecuteCommand::command_matches_allowed_patterns("cargo", "cargo check", &["cargo check".to_string()]));
466-
assert!(ExecuteCommand::command_matches_allowed_patterns("npm", "npm install package", &["npm install package".to_string()]));
467-
assert!(!ExecuteCommand::command_matches_allowed_patterns("cargo", "cargo build", &["cargo check".to_string()]));
468-
469-
// Test wildcard matching
470-
assert!(ExecuteCommand::command_matches_allowed_patterns("git", "git status", &["git*".to_string()]));
471-
assert!(ExecuteCommand::command_matches_allowed_patterns("git", "git commit -m test", &["git*".to_string()]));
472-
assert!(!ExecuteCommand::command_matches_allowed_patterns("ls", "ls -la", &["git*".to_string()]));
473-
474-
// Test specific command wildcards
475-
assert!(ExecuteCommand::command_matches_allowed_patterns("git", "git commit -m test", &["git commit*".to_string()]));
476-
assert!(!ExecuteCommand::command_matches_allowed_patterns("git", "git status", &["git commit*".to_string()]));
477-
478-
// Test complex patterns
479-
assert!(ExecuteCommand::command_matches_allowed_patterns("npm", "npm install package", &["npm install *".to_string()]));
480-
assert!(!ExecuteCommand::command_matches_allowed_patterns("npm", "npm run test", &["npm install *".to_string()]));
481-
482-
// Test invalid patterns (should fall back to exact matching)
483-
assert!(!ExecuteCommand::command_matches_allowed_patterns("git", "git status", &["[invalid".to_string()]));
484-
}
485-
486-
#[test]
487-
fn test_wildcard_patterns_with_dangerous_commands() {
488-
// Even with wildcard patterns, dangerous commands should still be caught
489-
let dangerous_commands = vec![
490-
"rm -rf / && echo done", // && is dangerous
491-
"git status && rm important_file", // && is dangerous
492-
"echo $(rm file)", // $() is dangerous
493-
];
494-
495-
for cmd in dangerous_commands {
496-
let tool = serde_json::from_value::<ExecuteCommand>(serde_json::json!({
497-
"command": cmd,
498-
}))
499-
.unwrap();
500-
501-
// Even with very permissive wildcard patterns, dangerous commands should require acceptance
502-
let very_permissive_patterns = vec!["*".to_string(), "git*".to_string(), "rm*".to_string()];
503-
assert!(
504-
tool.requires_acceptance(Some(&very_permissive_patterns), true),
505-
"Dangerous command '{}' should require acceptance even with permissive patterns",
506-
cmd
507-
);
508-
}
509-
510-
// Test piped commands - these should be handled differently
511-
// For piped commands, each command in the pipe is checked separately
512-
let piped_commands = vec![
513-
("ls | rm", vec!["ls*".to_string()], true), // rm is not allowed
514-
("ls | rm", vec!["ls*".to_string(), "rm*".to_string()], false), // both allowed
515-
("cat file | grep pattern", vec!["cat*".to_string()], false), // grep is read-only
516-
];
517-
518-
for (cmd, patterns, should_require_acceptance) in piped_commands {
519-
let tool = serde_json::from_value::<ExecuteCommand>(serde_json::json!({
520-
"command": cmd,
521-
}))
522-
.unwrap();
523-
524-
let result = tool.requires_acceptance(Some(&patterns), true);
525-
assert_eq!(
526-
result, should_require_acceptance,
527-
"Piped command '{}' with patterns {:?} - expected requires_acceptance: {}, got: {}",
528-
cmd, patterns, should_require_acceptance, result
529-
);
530-
}
531-
}
532-
533-
#[test]
534-
fn test_toolsettings_independent_of_allowedtools() {
535-
// Test that toolsSettings work even when execute_bash is NOT in allowedTools
536-
// Create agent through JSON deserialization to handle the complex types
537-
let agent_json = serde_json::json!({
538-
"name": "test_agent",
539-
"allowedTools": ["fs_read"], // execute_bash NOT included
540-
"toolsSettings": {
541-
"execute_bash": {
542-
"allowedCommands": ["git*", "ls*"],
543-
"allowReadOnly": true
544-
}
545-
}
546-
});
547-
548-
let agent: crate::cli::agent::Agent = serde_json::from_value(agent_json).unwrap();
549-
550-
let test_cases = vec![
551-
// Commands that should match patterns and be allowed
552-
("git status", false),
553-
("git commit -m test", false),
554-
("ls -la", false),
555-
556-
// Commands that don't match patterns but are read-only (should be allowed)
557-
("cat file.txt", false),
558-
("echo hello", false),
559-
560-
// Commands that don't match patterns and aren't read-only (should require acceptance)
561-
("rm file.txt", true),
562-
("npm install", true),
563-
];
564-
565-
for (command, should_require_acceptance) in test_cases {
566-
let tool = serde_json::from_value::<ExecuteCommand>(serde_json::json!({
567-
"command": command,
568-
}))
569-
.unwrap();
570-
571-
let result = tool.eval_perm(&agent);
572-
let requires_acceptance = matches!(result, PermissionEvalResult::Ask);
573-
574-
assert_eq!(
575-
requires_acceptance, should_require_acceptance,
576-
"Command '{}' - expected requires_acceptance: {}, got: {} (result: {:?})",
577-
command, should_require_acceptance, requires_acceptance, result
578-
);
579-
}
580-
}
581-
582354
#[test]
583355
fn test_exact_full_command_matching_bug_fix() {
584-
// This test specifically covers the bug where "cargo check" was in allowedCommands
585-
// but still prompted for permission because we only compared against the first word "cargo"
356+
// This test specifically covers the bug where exact commands should be matched
357+
// using regex patterns with proper escaping
586358

587359
let test_cases = vec![
588360
// Test case: (command, allowed_patterns, should_require_acceptance)
589-
("cargo check", vec!["cargo check".to_string()], false), // Should be allowed
590-
("cargo build", vec!["cargo check".to_string()], true), // Should require acceptance
591-
("npm install package", vec!["npm install".to_string()], true), // Partial match, should require acceptance
592-
("npm install", vec!["npm install".to_string()], false), // Exact match, should be allowed
593-
("git commit -m test", vec!["git commit -m test".to_string()], false), // Exact full match
594-
("git commit -m different", vec!["git commit -m test".to_string()], true), // Different, should require acceptance
595-
];
596-
597-
for (command, allowed_patterns, should_require_acceptance) in test_cases {
598-
let tool = serde_json::from_value::<ExecuteCommand>(serde_json::json!({
599-
"command": command,
600-
}))
601-
.unwrap();
361+
("cargo check", vec![regex::escape("cargo check")], false), // Should be allowed
362+
("cargo build", vec![regex::escape("cargo check")], true), // Should require acceptance
363+
("npm install", vec![regex::escape("npm install")], false), // Exact match, should be allowed
364+
("git commit -m test", vec![regex::escape("git commit -m test")], false), // Exact full match
365+
("git commit -m different", vec![regex::escape("git commit -m test")], true), // Different, should require acceptance
602366

603-
let result = tool.requires_acceptance(Some(&allowed_patterns), true);
604-
assert_eq!(
605-
result, should_require_acceptance,
606-
"Command '{}' with patterns {:?} - expected requires_acceptance: {}, got: {}",
607-
command, allowed_patterns, should_require_acceptance, result
608-
);
609-
}
610-
}
611-
612-
#[test]
613-
fn test_piped_commands_with_wildcards() {
614-
let test_cases = vec![
615-
// Safe piped commands with wildcards
616-
("git log | grep commit", vec!["git*".to_string(), "grep*".to_string()], false),
617-
("ls -la | grep .txt", vec!["ls*".to_string(), "grep*".to_string()], false),
618-
619-
// Mixed allowed/disallowed in pipe
620-
("git status | rm", vec!["git*".to_string()], true), // rm not allowed and not read-only
621-
("ls | grep pattern", vec!["ls*".to_string()], false), // grep is read-only, so allowed
367+
// Test regex patterns for command prefixes (new pattern: git\s.* - matches "git " + anything but NOT "git" alone)
368+
("git status", vec!["git\\s.*".to_string()], false), // Should match "git " + anything
369+
("git commit -m test", vec!["git\\s.*".to_string()], false), // Should match
370+
("git", vec!["git\\s.*".to_string()], true), // Should NOT match "git" alone, and git is not read-only, so requires acceptance
371+
("gitfoo", vec!["git\\s.*".to_string()], true), // Should NOT match (no space after git)
372+
("ls", vec!["git\\s.*".to_string()], false), // ls is read-only, so allowed anyway
622373

623-
// All commands in pipe allowed
624-
("find . -name '*.rs' | grep main | head -5",
625-
vec!["find*".to_string(), "grep*".to_string(), "head*".to_string()], false),
626-
627-
// Test with read-only commands in pipe
628-
("cat file | head -10", vec![], false), // both cat and head are read-only
629-
("ls | sort", vec![], true), // ls is read-only but sort is not, and sort not in patterns
374+
// Test that "git" alone would require acceptance if it wasn't read-only
375+
("rm", vec!["git\\s.*".to_string()], true), // rm doesn't match pattern and isn't read-only
630376
];
631377

632378
for (command, allowed_patterns, should_require_acceptance) in test_cases {
@@ -638,7 +384,7 @@ mod tests {
638384
let result = tool.requires_acceptance(Some(&allowed_patterns), true);
639385
assert_eq!(
640386
result, should_require_acceptance,
641-
"Piped command '{}' with patterns {:?} - expected requires_acceptance: {}, got: {}",
387+
"Command '{}' with patterns {:?} - expected requires_acceptance: {}, got: {}",
642388
command, allowed_patterns, should_require_acceptance, result
643389
);
644390
}

0 commit comments

Comments
 (0)