1#![expect(
2 clippy::indexing_slicing,
3 reason = "Dangerous-command matching uses validated token lengths and fixed ASCII option prefixes."
4)]
5
6pub fn command_might_be_dangerous(command: &[String]) -> bool {
20 let Some(command) = unwrap_command_prefix(command) else {
21 return !command.is_empty();
22 };
23 let Some(executable) = command.first() else {
24 return false;
25 };
26 if executable_is_dynamic(executable) {
27 return true;
28 }
29
30 if is_encoded_powershell_invocation(command) {
34 return true;
35 }
36
37 #[cfg(windows)]
38 {
39 if crate::command_safety::windows::is_dangerous_command_windows(command) {
40 return true;
41 }
42 }
43
44 if is_dangerous_to_call_with_exec(command) {
45 return true;
46 }
47
48 if command.len() >= 3
51 && matches!(extract_command_name(&command[0]), "bash" | "sh" | "zsh")
52 && (command[1] == "-c" || command[1] == "-lc" || command[1] == "-ilc")
53 {
54 let script = &command[2];
55 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
56 for sub_cmd in sub_commands {
57 if command_might_be_dangerous(&sub_cmd) {
58 return true;
59 }
60 }
61 } else {
62 return true;
63 }
64 }
65
66 false
67}
68
69pub fn command_requires_approval(command: &[String]) -> bool {
77 let Some(command) = unwrap_command_prefix(command) else {
78 return false;
79 };
80 if is_inline_code_execution(command) {
81 return true;
82 }
83
84 if command.len() >= 3
85 && matches!(extract_command_name(&command[0]), "bash" | "sh" | "zsh")
86 && matches!(command[1].as_str(), "-c" | "-lc" | "-ilc")
87 && let Ok(commands) = crate::command_safety::shell_parser::parse_shell_commands(&command[2])
88 {
89 return commands.iter().any(|nested| command_requires_approval(nested));
90 }
91
92 false
93}
94
95fn executable_is_dynamic(executable: &str) -> bool {
96 executable
97 .chars()
98 .any(|character| matches!(character, '$' | '`' | '*' | '?' | '[' | ']' | '{' | '}'))
99}
100
101fn is_environment_assignment(argument: &str) -> bool {
102 let Some((name, _value)) = argument.split_once('=') else {
103 return false;
104 };
105 let mut characters = name.chars();
106 characters
107 .next()
108 .is_some_and(|character| character == '_' || character.is_ascii_alphabetic())
109 && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
110}
111
112pub(super) fn unwrap_command_prefix(mut command: &[String]) -> Option<&[String]> {
113 loop {
114 while command.first().is_some_and(|argument| is_environment_assignment(argument)) {
115 command = &command[1..];
116 }
117 let executable = command.first()?;
118 match extract_command_name(executable) {
119 "env" => {
120 command = &command[1..];
121 while let Some(argument) = command.first().map(String::as_str) {
122 if is_environment_assignment(argument) || matches!(argument, "-i" | "--ignore-environment") {
123 command = &command[1..];
124 } else if matches!(argument, "-u" | "--unset") {
125 command = command.get(2..)?;
126 } else if argument.starts_with("--unset=") {
127 command = &command[1..];
128 } else if argument == "--" {
129 command = &command[1..];
130 break;
131 } else if argument.starts_with('-') {
132 return None;
133 } else {
134 break;
135 }
136 }
137 }
138 "sudo" => {
139 command = &command[1..];
140 while let Some(argument) = command.first().map(String::as_str) {
141 if argument == "--" {
142 command = &command[1..];
143 break;
144 }
145 if matches!(argument, "-u" | "--user" | "-g" | "--group" | "-h" | "--host" | "-C" | "--chdir") {
146 command = command.get(2..)?;
147 } else if matches!(argument, "-E" | "-H" | "-n" | "-S" | "-k" | "-K" | "-b")
148 || argument.starts_with("--user=")
149 || argument.starts_with("--group=")
150 || argument.starts_with("--host=")
151 || argument.starts_with("--chdir=")
152 {
153 command = &command[1..];
154 } else if argument.starts_with('-') {
155 return None;
156 } else {
157 break;
158 }
159 }
160 }
161 _ => return Some(command),
162 }
163 }
164}
165
166fn is_inline_code_execution(command: &[String]) -> bool {
167 let Some(executable) = command.first().map(|value| extract_command_name(value).to_ascii_lowercase()) else {
168 return false;
169 };
170 let arguments = &command[1..];
171 match executable.as_str() {
172 "python" | "python3" | "python.exe" | "python3.exe" => arguments.iter().any(|argument| argument == "-c"),
173 "node" | "node.exe" | "ruby" | "ruby.exe" | "perl" | "perl.exe" | "osascript" => {
174 arguments.iter().any(|argument| argument == "-e")
175 }
176 "php" | "php.exe" => arguments.iter().any(|argument| argument == "-r"),
177 "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => arguments.iter().any(|argument| {
178 matches!(
179 argument.to_ascii_lowercase().as_str(),
180 "-command" | "-c" | "-encodedcommand" | "-encoded" | "-enc" | "-e"
181 )
182 }),
183 _ => false,
184 }
185}
186
187fn is_encoded_powershell_invocation(command: &[String]) -> bool {
188 let Some(executable) = command.first() else {
189 return false;
190 };
191 let executable = extract_command_name(executable).to_ascii_lowercase();
192 if !matches!(executable.as_str(), "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe") {
193 return false;
194 }
195
196 command.iter().skip(1).any(|argument| {
197 matches!(argument.to_ascii_lowercase().as_str(), "-encodedcommand" | "-encoded" | "-enc" | "-e")
198 })
199}
200
201fn is_git_global_option_with_value(arg: &str) -> bool {
203 matches!(
204 arg,
205 "-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
206 )
207}
208
209fn is_git_global_option_with_inline_value(arg: &str) -> bool {
211 matches!(
212 arg,
213 s if s.starts_with("--config-env=")
214 || s.starts_with("--exec-path=")
215 || s.starts_with("--git-dir=")
216 || s.starts_with("--namespace=")
217 || s.starts_with("--super-prefix=")
218 || s.starts_with("--work-tree=")
219 ) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
220}
221
222pub fn git_global_option_requires_prompt(arg: &str) -> bool {
225 matches!(
226 arg,
227 "-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
228 ) || matches!(
229 arg,
230 s if (s.starts_with("-C") && s.len() > 2)
231 || (s.starts_with("-c") && s.len() > 2)
232 || s.starts_with("--config-env=")
233 || s.starts_with("--exec-path=")
234 || s.starts_with("--git-dir=")
235 || s.starts_with("--namespace=")
236 || s.starts_with("--super-prefix=")
237 || s.starts_with("--work-tree=")
238 )
239}
240
241pub(crate) fn find_git_subcommand<'a>(command: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
246 let cmd0 = command.first().map(String::as_str)?;
247 if !cmd0.ends_with("git") {
248 return None;
249 }
250
251 let mut skip_next = false;
252 for (idx, arg) in command.iter().enumerate().skip(1) {
253 if skip_next {
254 skip_next = false;
255 continue;
256 }
257
258 let arg = arg.as_str();
259
260 if is_git_global_option_with_inline_value(arg) {
261 continue;
262 }
263
264 if is_git_global_option_with_value(arg) {
265 skip_next = true;
266 continue;
267 }
268
269 if arg == "--" || arg.starts_with('-') {
270 continue;
271 }
272
273 if subcommands.contains(&arg) {
274 return Some((idx, arg));
275 }
276
277 return None;
281 }
282
283 None
284}
285
286fn short_flag_group_contains(arg: &str, target: char) -> bool {
288 arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
289}
290
291fn git_branch_is_delete(branch_args: &[String]) -> bool {
293 branch_args.iter().map(String::as_str).any(|arg| {
296 matches!(arg, "-d" | "-D" | "--delete")
297 || arg.starts_with("--delete=")
298 || short_flag_group_contains(arg, 'd')
299 || short_flag_group_contains(arg, 'D')
300 })
301}
302
303fn git_push_is_dangerous(push_args: &[String]) -> bool {
305 push_args.iter().map(String::as_str).any(|arg| {
306 matches!(arg, "--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d")
307 || arg.starts_with("--force-with-lease=")
308 || arg.starts_with("--force-if-includes=")
309 || arg.starts_with("--delete=")
310 || short_flag_group_contains(arg, 'f')
311 || short_flag_group_contains(arg, 'd')
312 || git_push_refspec_is_dangerous(arg)
313 })
314}
315
316fn git_push_refspec_is_dangerous(arg: &str) -> bool {
318 (arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
320}
321
322fn git_clean_is_force(clean_args: &[String]) -> bool {
324 clean_args.iter().map(String::as_str).any(|arg| {
325 matches!(arg, "--force" | "-f") || arg.starts_with("--force=") || short_flag_group_contains(arg, 'f')
326 })
327}
328
329fn is_dangerous_git_subcommand(command: &[String]) -> bool {
332 if command.is_empty() {
333 return false;
334 }
335
336 let first_arg = command[0].as_str();
337
338 match first_arg {
340 "reset" | "rm" => true,
341 "branch" => git_branch_is_delete(&command[1..]),
342 "push" => git_push_is_dangerous(&command[1..]),
343 "clean" => git_clean_is_force(&command[1..]),
344 opt if opt.starts_with('-') => {
347 if let Some((idx, subcommand)) =
349 find_git_subcommand_from_args(command, &["reset", "rm", "branch", "push", "clean"])
350 {
351 match subcommand {
352 "reset" | "rm" => true,
353 "branch" => git_branch_is_delete(&command[idx + 1..]),
354 "push" => git_push_is_dangerous(&command[idx + 1..]),
355 "clean" => git_clean_is_force(&command[idx + 1..]),
356 _ => false,
357 }
358 } else {
359 false
360 }
361 }
362 _ => false,
363 }
364}
365
366fn find_git_subcommand_from_args<'a>(args: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
368 let mut skip_next = false;
369 for (idx, arg) in args.iter().enumerate() {
370 if skip_next {
371 skip_next = false;
372 continue;
373 }
374
375 let arg = arg.as_str();
376
377 if is_git_global_option_with_inline_value(arg) {
378 continue;
379 }
380
381 if is_git_global_option_with_value(arg) {
382 skip_next = true;
383 continue;
384 }
385
386 if arg == "--" || arg.starts_with('-') {
387 continue;
388 }
389
390 if subcommands.contains(&arg) {
391 return Some((idx, arg));
392 }
393
394 return None;
396 }
397
398 None
399}
400
401fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
403 if command.is_empty() {
404 return false;
405 }
406
407 let cmd0 = command.first().map(String::as_str);
408 let base_cmd = extract_command_name(cmd0.unwrap_or(""));
409
410 match base_cmd {
411 "git" => {
413 let Some((subcommand_idx, subcommand)) =
414 find_git_subcommand(command, &["reset", "rm", "branch", "push", "clean"])
415 else {
416 return false;
417 };
418
419 match subcommand {
420 "reset" | "rm" => true,
421 "branch" => git_branch_is_delete(&command[subcommand_idx + 1..]),
422 "push" => git_push_is_dangerous(&command[subcommand_idx + 1..]),
423 "clean" => git_clean_is_force(&command[subcommand_idx + 1..]),
424 other => {
425 debug_assert!(false, "unexpected git subcommand from matcher: {other}");
426 false
427 }
428 }
429 }
430
431 "rm" => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf" | "-fr" | "-r")),
433
434 _ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
436 "dd" | "shutdown" | "reboot" | "init" => true,
437
438 _ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
440
441 "sudo" => {
443 if command.len() > 1 {
444 is_dangerous_to_call_with_exec(&command[1..])
445 } else {
446 false
447 }
448 }
449
450 _ => is_dangerous_git_subcommand(command),
452 }
453}
454
455fn extract_command_name(cmd: &str) -> &str {
457 std::path::Path::new(cmd)
458 .file_name()
459 .and_then(|osstr| osstr.to_str())
460 .unwrap_or(cmd)
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 fn vec_str(args: &[&str]) -> Vec<String> {
468 args.iter().map(|s| s.to_string()).collect()
469 }
470
471 #[test]
472 fn git_reset_is_dangerous() {
473 let cmd = vec!["git".to_string(), "reset".to_string()];
474 assert!(is_dangerous_to_call_with_exec(&cmd));
475 }
476
477 #[test]
478 fn git_reset_hard_is_dangerous() {
479 let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
480 assert!(is_dangerous_to_call_with_exec(&cmd));
481 }
482
483 #[test]
484 fn git_status_is_safe() {
485 let cmd = vec!["git".to_string(), "status".to_string()];
486 assert!(!is_dangerous_to_call_with_exec(&cmd));
487 }
488
489 #[test]
490 fn git_log_is_safe() {
491 let cmd = vec!["git".to_string(), "log".to_string()];
492 assert!(!is_dangerous_to_call_with_exec(&cmd));
493 }
494
495 #[test]
496 fn rm_f_is_dangerous() {
497 let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
498 assert!(is_dangerous_to_call_with_exec(&cmd));
499 }
500
501 #[test]
502 fn rm_rf_is_dangerous() {
503 let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
504 assert!(is_dangerous_to_call_with_exec(&cmd));
505 }
506
507 #[test]
508 fn rm_without_flags_is_safe() {
509 let cmd = vec!["rm".to_string()];
510 assert!(!is_dangerous_to_call_with_exec(&cmd));
511 }
512
513 #[test]
514 fn mkfs_is_dangerous() {
515 let cmd = vec!["mkfs".to_string()];
516 assert!(is_dangerous_to_call_with_exec(&cmd));
517 }
518
519 #[test]
520 fn mkfs_variants_are_dangerous() {
521 let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
522 assert!(is_dangerous_to_call_with_exec(&cmd));
523 }
524
525 #[test]
526 fn dd_is_dangerous() {
527 let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
528 assert!(is_dangerous_to_call_with_exec(&cmd));
529 }
530
531 #[test]
532 fn shutdown_is_dangerous() {
533 let cmd = vec!["shutdown".to_string()];
534 assert!(is_dangerous_to_call_with_exec(&cmd));
535 }
536
537 #[test]
538 fn sudo_git_reset_is_dangerous() {
539 let cmd = vec![
540 "sudo".to_string(),
541 "git".to_string(),
542 "reset".to_string(),
543 "--hard".to_string(),
544 ];
545 assert!(is_dangerous_to_call_with_exec(&cmd));
546 }
547
548 #[test]
549 fn sudo_git_status_is_safe() {
550 let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
551 assert!(!is_dangerous_to_call_with_exec(&cmd));
552 }
553
554 #[test]
555 fn absolute_path_git_reset_is_dangerous() {
556 let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string()];
557 assert!(is_dangerous_to_call_with_exec(&cmd));
558 }
559
560 #[test]
561 fn empty_command_is_safe() {
562 let cmd: Vec<String> = vec![];
563 assert!(!is_dangerous_to_call_with_exec(&cmd));
564 }
565
566 #[test]
567 fn command_might_be_dangerous_detects_git_reset() {
568 let cmd = vec!["git".to_string(), "reset".to_string()];
569 assert!(command_might_be_dangerous(&cmd));
570 }
571
572 #[test]
573 fn command_might_be_dangerous_allows_git_status() {
574 let cmd = vec!["git".to_string(), "status".to_string()];
575 assert!(!command_might_be_dangerous(&cmd));
576 }
577
578 #[test]
579 fn wrappers_and_absolute_executables_do_not_hide_dangerous_commands() {
580 assert!(command_might_be_dangerous(&vec_str(&[
581 "env",
582 "MODE=test",
583 "sudo",
584 "-u",
585 "root",
586 "/usr/bin/git",
587 "reset",
588 "--hard",
589 ])));
590 assert!(command_might_be_dangerous(&vec_str(&["MODE=test", "/bin/sh", "-c", "rm -rf /",])));
591 }
592
593 #[test]
594 fn inline_interpreter_programs_are_code_execution_boundaries() {
595 for command in [
596 vec_str(&["/usr/bin/python3", "-c", "print('ok')"]),
597 vec_str(&["node", "-e", "console.log('ok')"]),
598 vec_str(&["ruby", "-e", "puts 'ok'"]),
599 vec_str(&["perl", "-e", "print 'ok'"]),
600 vec_str(&["php", "-r", "echo 'ok';"]),
601 vec_str(&["osascript", "-e", "return 1"]),
602 vec_str(&["pwsh", "-Command", "Write-Output ok"]),
603 ] {
604 assert!(!command_might_be_dangerous(&command), "inline code is not forbidden outright: {command:?}");
605 assert!(command_requires_approval(&command), "inline code should require policy admission: {command:?}");
606 }
607
608 let nested = vec_str(&["bash", "-lc", "python3 -c 'print(1)'"]);
609 assert!(command_requires_approval(&nested));
610 assert!(!command_might_be_dangerous(&nested));
611 }
612
613 #[test]
614 fn dynamic_or_unknown_wrapped_executables_fail_closed() {
615 assert!(command_might_be_dangerous(&vec_str(&["$TOOL", "status"])));
616 assert!(command_might_be_dangerous(&vec_str(&["env", "--unknown", "git", "status"])));
617 assert!(command_might_be_dangerous(&vec_str(&["sudo", "--unknown", "git", "status"])));
618 }
619
620 #[test]
623 fn git_branch_delete_is_dangerous() {
624 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-d", "feature",])));
625 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-D", "feature",])));
626 let script = "git branch --delete feature";
628 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
629 for sub_cmd in sub_commands {
630 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
631 }
632 }
633 }
634
635 #[test]
636 fn git_branch_delete_with_stacked_short_flags_is_dangerous() {
637 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-dv", "feature",])));
638 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vd", "feature",])));
639 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vD", "feature",])));
640 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-Dvv", "feature",])));
641 }
642
643 #[test]
644 fn git_branch_delete_with_global_options_is_dangerous() {
645 assert!(command_might_be_dangerous(&vec_str(&["git", "-C", ".", "branch", "-d", "feature",])));
646 assert!(command_might_be_dangerous(&vec_str(&["git", "-c", "color.ui=false", "branch", "-D", "feature",])));
647 let script = "git -C . branch -d feature";
649 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
650 for sub_cmd in sub_commands {
651 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
652 }
653 }
654 }
655
656 #[test]
657 fn git_checkout_reset_is_not_dangerous() {
658 assert!(!command_might_be_dangerous(&vec_str(&["git", "checkout", "reset",])));
661 }
662
663 #[test]
666 fn git_push_force_is_dangerous() {
667 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--force", "origin", "main",])));
668 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-f", "origin", "main",])));
669 assert!(command_might_be_dangerous(&vec_str(&[
670 "git",
671 "-C",
672 ".",
673 "push",
674 "--force-with-lease",
675 "origin",
676 "main",
677 ])));
678 }
679
680 #[test]
681 fn git_push_plus_refspec_is_dangerous() {
682 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", "+main",])));
683 assert!(command_might_be_dangerous(&vec_str(
684 &["git", "push", "origin", "+refs/heads/main:refs/heads/main",]
685 )));
686 }
687
688 #[test]
689 fn git_push_delete_flag_is_dangerous() {
690 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--delete", "origin", "feature",])));
691 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-d", "origin", "feature",])));
692 }
693
694 #[test]
695 fn git_push_delete_refspec_is_dangerous() {
696 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", ":feature",])));
697 let script = "git push origin :feature";
699 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
700 for sub_cmd in sub_commands {
701 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
702 }
703 }
704 }
705
706 #[test]
707 fn git_push_without_force_is_not_dangerous() {
708 assert!(!command_might_be_dangerous(&vec_str(&["git", "push", "origin", "main",])));
709 }
710
711 #[test]
714 fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
715 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-fdx",])));
716 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-xdf",])));
717 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "--force",])));
718 }
719}