@@ -110,10 +110,6 @@ impl ExecuteCommand {
110
110
return true ;
111
111
} ,
112
112
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
- }
117
113
// Special casing for `grep`. -P flag for perl regexp has RCE issues, apparently
118
114
// should not be supported within grep but is flagged as a possibility since this is perl
119
115
// regexp.
@@ -132,46 +128,6 @@ impl ExecuteCommand {
132
128
false
133
129
}
134
130
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
-
175
131
pub async fn invoke ( & self , output : & mut impl Write ) -> Result < InvokeOutput > {
176
132
let output = run_command ( & self . command , MAX_TOOL_RESPONSE_SIZE / 3 , Some ( output) ) . await ?;
177
133
let clean_stdout = sanitize_unicode_tags ( & output. stdout ) ;
@@ -395,238 +351,28 @@ mod tests {
395
351
}
396
352
}
397
353
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
-
582
354
#[ test]
583
355
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
586
358
587
359
let test_cases = vec ! [
588
360
// 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
602
366
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
622
373
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
630
376
] ;
631
377
632
378
for ( command, allowed_patterns, should_require_acceptance) in test_cases {
@@ -638,7 +384,7 @@ mod tests {
638
384
let result = tool. requires_acceptance ( Some ( & allowed_patterns) , true ) ;
639
385
assert_eq ! (
640
386
result, should_require_acceptance,
641
- "Piped command '{}' with patterns {:?} - expected requires_acceptance: {}, got: {}" ,
387
+ "Command '{}' with patterns {:?} - expected requires_acceptance: {}, got: {}" ,
642
388
command, allowed_patterns, should_require_acceptance, result
643
389
) ;
644
390
}
0 commit comments