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_push_is_dangerous(push_args: &[String]) -> bool {
293 push_args.iter().map(String::as_str).any(|arg| {
294 matches!(arg, "--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d")
295 || arg.starts_with("--force-with-lease=")
296 || arg.starts_with("--force-if-includes=")
297 || arg.starts_with("--delete=")
298 || short_flag_group_contains(arg, 'f')
299 || short_flag_group_contains(arg, 'd')
300 || git_push_refspec_is_dangerous(arg)
301 })
302}
303
304fn git_push_refspec_is_dangerous(arg: &str) -> bool {
306 (arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
308}
309
310fn git_clean_is_force(clean_args: &[String]) -> bool {
312 clean_args.iter().map(String::as_str).any(|arg| {
313 matches!(arg, "--force" | "-f") || arg.starts_with("--force=") || short_flag_group_contains(arg, 'f')
314 })
315}
316
317const GIT_GUARDED_SUBCOMMANDS: &[&str] = &["reset", "rm", "branch", "push", "clean"];
322
323fn git_reset_is_destructive(reset_args: &[String]) -> bool {
326 reset_args
327 .iter()
328 .any(|arg| matches!(arg.as_str(), "--hard" | "--merge" | "--keep"))
329}
330
331fn git_rm_is_destructive(rm_args: &[String]) -> bool {
334 !rm_args.iter().any(|arg| arg == "--cached")
335}
336
337fn git_branch_is_force_delete(branch_args: &[String]) -> bool {
342 let mut deletes = false;
343 let mut forces = false;
344 for arg in branch_args {
345 match arg.as_str() {
346 "-D" => {
347 deletes = true;
348 forces = true;
349 }
350 "-d" | "--delete" => deletes = true,
351 "-f" | "--force" => forces = true,
352 _ => {
353 if arg.starts_with("--delete=") {
354 deletes = true;
355 }
356 if arg.starts_with("--force=") {
357 forces = true;
358 }
359 if short_flag_group_contains(arg, 'd') {
360 deletes = true;
361 }
362 if short_flag_group_contains(arg, 'D') {
363 deletes = true;
364 forces = true;
365 }
366 if short_flag_group_contains(arg, 'f') {
367 forces = true;
368 }
369 }
370 }
371 }
372 deletes && forces
373}
374
375fn classify_git_subcommand(subcommand: &str, args: &[String]) -> Option<&'static str> {
380 let scan_args = if subcommand == "push" {
389 args
390 } else {
391 match args.iter().position(|arg| arg == "--") {
392 Some(end) => &args[..end],
393 None => args,
394 }
395 };
396 match subcommand {
397 "reset" if git_reset_is_destructive(scan_args) => {
398 Some("git reset --hard/--merge/--keep discards uncommitted changes; use `git stash` or `git reset --soft`")
399 }
400 "rm" if git_rm_is_destructive(scan_args) => {
401 Some("git rm deletes working-tree files; unstage with `git rm --cached` instead")
402 }
403 "branch" if git_branch_is_force_delete(scan_args) => {
404 Some("git branch -D/--delete --force skips the unmerged-branch guard; use `-d` for merged branches")
405 }
406 "push" if git_push_is_dangerous(scan_args) => Some("git push force-updates or deletes remote refs"),
407 "clean" if git_clean_is_force(scan_args) => Some("git clean --force deletes untracked files"),
408 _ => None,
409 }
410}
411
412pub fn dangerous_command_reason(command: &[String]) -> Option<&'static str> {
417 let command = unwrap_command_prefix(command)?;
418 let cmd0 = command.first().map(String::as_str);
419 let (idx, subcommand) = if extract_command_name(cmd0.unwrap_or("")) == "git" {
420 find_git_subcommand(command, GIT_GUARDED_SUBCOMMANDS)?
421 } else {
422 let (idx, subcommand) = find_git_subcommand_from_args(command, GIT_GUARDED_SUBCOMMANDS)?;
426 if subcommand == "rm" {
427 return None;
428 }
429 (idx, subcommand)
430 };
431 classify_git_subcommand(subcommand, &command[idx + 1..])
432}
433
434fn is_dangerous_git_subcommand(command: &[String]) -> bool {
437 find_git_subcommand_from_args(command, GIT_GUARDED_SUBCOMMANDS)
438 .and_then(|(idx, subcommand)| classify_git_subcommand(subcommand, &command[idx + 1..]))
439 .is_some()
440}
441
442fn find_git_subcommand_from_args<'a>(args: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
444 let mut skip_next = false;
445 for (idx, arg) in args.iter().enumerate() {
446 if skip_next {
447 skip_next = false;
448 continue;
449 }
450
451 let arg = arg.as_str();
452
453 if is_git_global_option_with_inline_value(arg) {
454 continue;
455 }
456
457 if is_git_global_option_with_value(arg) {
458 skip_next = true;
459 continue;
460 }
461
462 if arg == "--" || arg.starts_with('-') {
463 continue;
464 }
465
466 if subcommands.contains(&arg) {
467 return Some((idx, arg));
468 }
469
470 return None;
472 }
473
474 None
475}
476
477fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
479 if command.is_empty() {
480 return false;
481 }
482
483 let cmd0 = command.first().map(String::as_str);
484 let base_cmd = extract_command_name(cmd0.unwrap_or(""));
485
486 match base_cmd {
487 "git" => {
489 let Some((subcommand_idx, subcommand)) = find_git_subcommand(command, GIT_GUARDED_SUBCOMMANDS) else {
490 return false;
491 };
492
493 classify_git_subcommand(subcommand, &command[subcommand_idx + 1..]).is_some()
494 }
495
496 "rm" => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf" | "-fr" | "-r")),
498
499 _ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
501 "dd" | "shutdown" | "reboot" | "init" => true,
502
503 _ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
505
506 "sudo" => {
508 if command.len() > 1 {
509 is_dangerous_to_call_with_exec(&command[1..])
510 } else {
511 false
512 }
513 }
514
515 _ => is_dangerous_git_subcommand(command),
517 }
518}
519
520fn extract_command_name(cmd: &str) -> &str {
522 std::path::Path::new(cmd)
523 .file_name()
524 .and_then(|osstr| osstr.to_str())
525 .unwrap_or(cmd)
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 fn vec_str(args: &[&str]) -> Vec<String> {
533 args.iter().map(|s| s.to_string()).collect()
534 }
535
536 #[test]
537 fn git_reset_recoverable_modes_pass_preflight() {
538 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset"])));
541 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--soft", "HEAD~1"])));
542 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--mixed", "HEAD~1"])));
543 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "HEAD~1", "--", "file.txt"])));
544 }
545
546 #[test]
547 fn git_reset_destructive_modes_still_preflight_blocked() {
548 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--hard"])));
549 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--merge"])));
550 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--keep"])));
551 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "-C", "sub", "reset", "--hard"])));
553 assert!(command_might_be_dangerous(&vec_str(&["sudo", "git", "reset", "--hard"])));
554 assert!(
555 command_might_be_dangerous(&vec_str(&["FOO=bar", "env", "git", "reset", "--hard"])),
556 "env-prefixed destructive reset must stay blocked"
557 );
558 }
559
560 #[test]
561 fn git_rm_index_only_passes_preflight_and_working_tree_delete_stays_blocked() {
562 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "file.txt"])));
563 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "-rf", "dir"])));
564 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--cached", "file.txt"])));
565 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "-r", "--cached", "dir"])));
566 assert!(
567 is_dangerous_to_call_with_exec(&vec_str(&["sudo", "git", "rm", "file.txt"])),
568 "sudo-wrapped working-tree delete must stay blocked"
569 );
570 }
571
572 #[test]
573 fn git_branch_force_delete_is_blocked_but_merged_delete_passes() {
574 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-d", "feature"])));
576 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "--delete", "feature"])));
577 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-D", "feature"])));
578 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "--delete", "--force", "feature"])));
579 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-d", "-f", "feature"])));
580 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-df", "feature"])));
581 }
582
583 #[test]
584 fn end_of_options_separator_cannot_hide_working_tree_git_rm() {
585 assert!(
589 is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--", "--cached", "file.txt"])),
590 "`--cached` after `--` is a path; the invocation deletes working-tree files"
591 );
592 assert!(
593 is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--ignore-unmatch", "--", "--cached", "file.txt"])),
594 "--ignore-unmatch must not let a missing `--cached` path mask the deletion"
595 );
596 assert!(is_dangerous_to_call_with_exec(&vec_str(&["sudo", "git", "rm", "--", "--cached", "file.txt"])));
597 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--cached", "--", "file.txt"])));
599 let reason = dangerous_command_reason(&vec_str(&["git", "rm", "--", "--cached", "file.txt"]))
601 .expect("end-of-options git rm carries a reason");
602 assert!(reason.contains("git rm"), "reason should name the pattern: {reason}");
603 }
604
605 #[test]
606 fn end_of_options_separator_stops_flag_classification_for_other_guarded_subcommands() {
607 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--", "--hard"])));
610 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "--", "-D", "feature"])));
611 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "clean", "--", "-fd"])));
612 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--hard", "--", "file.txt"])));
614 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-D", "--", "feature"])));
615 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "clean", "-fd", "--", "dir"])));
616 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "push", "origin", "--", "--force"])));
619 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "push", "origin", "--", ":refs/heads/main"])));
620 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "push", "--force", "--", "origin", "main"])));
621 }
622
623 #[test]
624 fn dangerous_command_reason_names_git_pattern_and_skips_safe_commands() {
625 let destructive = vec_str(&["git", "reset", "--hard"]);
626 let reason = dangerous_command_reason(&destructive).expect("git reset --hard carries a reason");
627 assert!(reason.contains("git reset"), "reason should name the pattern: {reason}");
628
629 let recoverable = vec_str(&["git", "reset", "--soft", "HEAD~1"]);
630 assert!(dangerous_command_reason(&recoverable).is_none());
631
632 let safe = vec_str(&["git", "status"]);
633 assert!(dangerous_command_reason(&safe).is_none());
634
635 let pushed = vec_str(&["sudo", "git", "rm", "file.txt"]);
636 let sudo_reason = dangerous_command_reason(&pushed).expect("sudo-wrapped git rm carries a reason");
637 assert!(sudo_reason.contains("git rm"), "sudo prefix must be unwrapped: {sudo_reason}");
638 }
639
640 #[test]
641 fn dangerous_command_reason_does_not_label_plain_rm_as_git_rm() {
642 for cmd in [
646 vec_str(&["rm", "-f", "build.log"]),
647 vec_str(&["rm", "-rf", "dir"]),
648 vec_str(&["sudo", "rm", "-rf", "/tmp/x"]),
649 ] {
650 assert!(command_might_be_dangerous(&cmd), "plain rm must stay classified dangerous: {cmd:?}");
651 assert_eq!(dangerous_command_reason(&cmd), None, "plain rm must not receive the git rm remedy: {cmd:?}");
652 }
653 }
654
655 #[test]
656 fn git_status_is_safe() {
657 let cmd = vec!["git".to_string(), "status".to_string()];
658 assert!(!is_dangerous_to_call_with_exec(&cmd));
659 }
660
661 #[test]
662 fn git_log_is_safe() {
663 let cmd = vec!["git".to_string(), "log".to_string()];
664 assert!(!is_dangerous_to_call_with_exec(&cmd));
665 }
666
667 #[test]
668 fn rm_f_is_dangerous() {
669 let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
670 assert!(is_dangerous_to_call_with_exec(&cmd));
671 }
672
673 #[test]
674 fn rm_rf_is_dangerous() {
675 let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
676 assert!(is_dangerous_to_call_with_exec(&cmd));
677 }
678
679 #[test]
680 fn rm_without_flags_is_safe() {
681 let cmd = vec!["rm".to_string()];
682 assert!(!is_dangerous_to_call_with_exec(&cmd));
683 }
684
685 #[test]
686 fn mkfs_is_dangerous() {
687 let cmd = vec!["mkfs".to_string()];
688 assert!(is_dangerous_to_call_with_exec(&cmd));
689 }
690
691 #[test]
692 fn mkfs_variants_are_dangerous() {
693 let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
694 assert!(is_dangerous_to_call_with_exec(&cmd));
695 }
696
697 #[test]
698 fn dd_is_dangerous() {
699 let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
700 assert!(is_dangerous_to_call_with_exec(&cmd));
701 }
702
703 #[test]
704 fn shutdown_is_dangerous() {
705 let cmd = vec!["shutdown".to_string()];
706 assert!(is_dangerous_to_call_with_exec(&cmd));
707 }
708
709 #[test]
710 fn sudo_git_reset_is_dangerous() {
711 let cmd = vec![
712 "sudo".to_string(),
713 "git".to_string(),
714 "reset".to_string(),
715 "--hard".to_string(),
716 ];
717 assert!(is_dangerous_to_call_with_exec(&cmd));
718 }
719
720 #[test]
721 fn sudo_git_status_is_safe() {
722 let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
723 assert!(!is_dangerous_to_call_with_exec(&cmd));
724 }
725
726 #[test]
727 fn absolute_path_git_reset_hard_is_dangerous() {
728 let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string(), "--hard".to_string()];
729 assert!(is_dangerous_to_call_with_exec(&cmd));
730 let bare = vec!["/usr/bin/git".to_string(), "reset".to_string()];
732 assert!(!is_dangerous_to_call_with_exec(&bare));
733 }
734
735 #[test]
736 fn empty_command_is_safe() {
737 let cmd: Vec<String> = vec![];
738 assert!(!is_dangerous_to_call_with_exec(&cmd));
739 }
740
741 #[test]
742 fn command_might_be_dangerous_detects_git_reset_hard() {
743 let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
744 assert!(command_might_be_dangerous(&cmd));
745 let bare = vec!["git".to_string(), "reset".to_string()];
747 assert!(!command_might_be_dangerous(&bare));
748 }
749
750 #[test]
751 fn command_might_be_dangerous_allows_git_status() {
752 let cmd = vec!["git".to_string(), "status".to_string()];
753 assert!(!command_might_be_dangerous(&cmd));
754 }
755
756 #[test]
757 fn wrappers_and_absolute_executables_do_not_hide_dangerous_commands() {
758 assert!(command_might_be_dangerous(&vec_str(&[
759 "env",
760 "MODE=test",
761 "sudo",
762 "-u",
763 "root",
764 "/usr/bin/git",
765 "reset",
766 "--hard",
767 ])));
768 assert!(command_might_be_dangerous(&vec_str(&["MODE=test", "/bin/sh", "-c", "rm -rf /",])));
769 }
770
771 #[test]
772 fn inline_interpreter_programs_are_code_execution_boundaries() {
773 for command in [
774 vec_str(&["/usr/bin/python3", "-c", "print('ok')"]),
775 vec_str(&["node", "-e", "console.log('ok')"]),
776 vec_str(&["ruby", "-e", "puts 'ok'"]),
777 vec_str(&["perl", "-e", "print 'ok'"]),
778 vec_str(&["php", "-r", "echo 'ok';"]),
779 vec_str(&["osascript", "-e", "return 1"]),
780 vec_str(&["pwsh", "-Command", "Write-Output ok"]),
781 ] {
782 assert!(!command_might_be_dangerous(&command), "inline code is not forbidden outright: {command:?}");
783 assert!(command_requires_approval(&command), "inline code should require policy admission: {command:?}");
784 }
785
786 let nested = vec_str(&["bash", "-lc", "python3 -c 'print(1)'"]);
787 assert!(command_requires_approval(&nested));
788 assert!(!command_might_be_dangerous(&nested));
789 }
790
791 #[test]
792 fn dynamic_or_unknown_wrapped_executables_fail_closed() {
793 assert!(command_might_be_dangerous(&vec_str(&["$TOOL", "status"])));
794 assert!(command_might_be_dangerous(&vec_str(&["env", "--unknown", "git", "status"])));
795 assert!(command_might_be_dangerous(&vec_str(&["sudo", "--unknown", "git", "status"])));
796 }
797
798 #[test]
801 fn git_branch_delete_is_dangerous_only_when_forced() {
802 assert!(!command_might_be_dangerous(&vec_str(&["git", "branch", "-d", "feature",])));
804 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-D", "feature",])));
805 let script = "git branch --delete --force feature";
807 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
808 for sub_cmd in sub_commands {
809 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
810 }
811 }
812 }
813
814 #[test]
815 fn git_branch_delete_with_stacked_short_flags_is_dangerous_only_when_forced() {
816 assert!(!command_might_be_dangerous(&vec_str(&["git", "branch", "-dv", "feature",])));
819 assert!(!command_might_be_dangerous(&vec_str(&["git", "branch", "-vd", "feature",])));
820 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vD", "feature",])));
821 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-Dvv", "feature",])));
822 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-df", "feature",])));
823 }
824
825 #[test]
826 fn git_branch_delete_with_global_options_is_dangerous_only_when_forced() {
827 assert!(!command_might_be_dangerous(&vec_str(&["git", "-C", ".", "branch", "-d", "feature",])));
828 assert!(command_might_be_dangerous(&vec_str(&["git", "-c", "color.ui=false", "branch", "-D", "feature",])));
829 let script = "git -C . branch -D feature";
831 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
832 for sub_cmd in sub_commands {
833 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
834 }
835 }
836 }
837
838 #[test]
839 fn git_checkout_reset_is_not_dangerous() {
840 assert!(!command_might_be_dangerous(&vec_str(&["git", "checkout", "reset",])));
843 }
844
845 #[test]
848 fn git_push_force_is_dangerous() {
849 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--force", "origin", "main",])));
850 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-f", "origin", "main",])));
851 assert!(command_might_be_dangerous(&vec_str(&[
852 "git",
853 "-C",
854 ".",
855 "push",
856 "--force-with-lease",
857 "origin",
858 "main",
859 ])));
860 }
861
862 #[test]
863 fn git_push_plus_refspec_is_dangerous() {
864 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", "+main",])));
865 assert!(command_might_be_dangerous(&vec_str(
866 &["git", "push", "origin", "+refs/heads/main:refs/heads/main",]
867 )));
868 }
869
870 #[test]
871 fn git_push_delete_flag_is_dangerous() {
872 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--delete", "origin", "feature",])));
873 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-d", "origin", "feature",])));
874 }
875
876 #[test]
877 fn git_push_delete_refspec_is_dangerous() {
878 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", ":feature",])));
879 let script = "git push origin :feature";
881 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
882 for sub_cmd in sub_commands {
883 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
884 }
885 }
886 }
887
888 #[test]
889 fn git_push_without_force_is_not_dangerous() {
890 assert!(!command_might_be_dangerous(&vec_str(&["git", "push", "origin", "main",])));
891 }
892
893 #[test]
896 fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
897 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-fdx",])));
898 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-xdf",])));
899 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "--force",])));
900 }
901}