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 ["-command", "-c", "-encodedcommand", "-encoded", "-enc", "-e"]
179 .iter()
180 .any(|option| argument.eq_ignore_ascii_case(option))
181 }),
182 _ => false,
183 }
184}
185
186fn is_encoded_powershell_invocation(command: &[String]) -> bool {
187 let Some(executable) = command.first() else {
188 return false;
189 };
190 let executable = extract_command_name(executable).to_ascii_lowercase();
191 if !matches!(executable.as_str(), "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe") {
192 return false;
193 }
194
195 command.iter().skip(1).any(|argument| {
196 ["-encodedcommand", "-encoded", "-enc", "-e"]
197 .iter()
198 .any(|option| argument.eq_ignore_ascii_case(option))
199 })
200}
201
202fn is_git_global_option_with_value(arg: &str) -> bool {
204 matches!(
205 arg,
206 "-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
207 )
208}
209
210fn is_git_global_option_with_inline_value(arg: &str) -> bool {
212 matches!(
213 arg,
214 s if s.starts_with("--config-env=")
215 || s.starts_with("--exec-path=")
216 || s.starts_with("--git-dir=")
217 || s.starts_with("--namespace=")
218 || s.starts_with("--super-prefix=")
219 || s.starts_with("--work-tree=")
220 ) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2)
221}
222
223pub fn git_global_option_requires_prompt(arg: &str) -> bool {
226 matches!(
227 arg,
228 "-C" | "-c" | "--config-env" | "--exec-path" | "--git-dir" | "--namespace" | "--super-prefix" | "--work-tree"
229 ) || matches!(
230 arg,
231 s if (s.starts_with("-C") && s.len() > 2)
232 || (s.starts_with("-c") && s.len() > 2)
233 || s.starts_with("--config-env=")
234 || s.starts_with("--exec-path=")
235 || s.starts_with("--git-dir=")
236 || s.starts_with("--namespace=")
237 || s.starts_with("--super-prefix=")
238 || s.starts_with("--work-tree=")
239 )
240}
241
242pub(crate) fn find_git_subcommand<'a>(command: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
247 let cmd0 = command.first().map(String::as_str)?;
248 if !cmd0.ends_with("git") {
249 return None;
250 }
251
252 let mut skip_next = false;
253 for (idx, arg) in command.iter().enumerate().skip(1) {
254 if skip_next {
255 skip_next = false;
256 continue;
257 }
258
259 let arg = arg.as_str();
260
261 if is_git_global_option_with_inline_value(arg) {
262 continue;
263 }
264
265 if is_git_global_option_with_value(arg) {
266 skip_next = true;
267 continue;
268 }
269
270 if arg == "--" || arg.starts_with('-') {
271 continue;
272 }
273
274 if subcommands.contains(&arg) {
275 return Some((idx, arg));
276 }
277
278 return None;
282 }
283
284 None
285}
286
287fn short_flag_group_contains(arg: &str, target: char) -> bool {
289 arg.starts_with('-') && !arg.starts_with("--") && arg.chars().skip(1).any(|c| c == target)
290}
291
292fn git_push_is_dangerous(push_args: &[String]) -> bool {
294 push_args.iter().map(String::as_str).any(|arg| {
295 matches!(arg, "--force" | "--force-with-lease" | "--force-if-includes" | "--delete" | "-f" | "-d")
296 || arg.starts_with("--force-with-lease=")
297 || arg.starts_with("--force-if-includes=")
298 || arg.starts_with("--delete=")
299 || short_flag_group_contains(arg, 'f')
300 || short_flag_group_contains(arg, 'd')
301 || git_push_refspec_is_dangerous(arg)
302 })
303}
304
305fn git_push_refspec_is_dangerous(arg: &str) -> bool {
307 (arg.starts_with('+') || arg.starts_with(':')) && arg.len() > 1
309}
310
311fn git_clean_is_force(clean_args: &[String]) -> bool {
313 clean_args.iter().map(String::as_str).any(|arg| {
314 matches!(arg, "--force" | "-f") || arg.starts_with("--force=") || short_flag_group_contains(arg, 'f')
315 })
316}
317
318const GIT_GUARDED_SUBCOMMANDS: &[&str] = &["reset", "rm", "branch", "push", "clean"];
323
324fn git_reset_is_destructive(reset_args: &[String]) -> bool {
327 reset_args
328 .iter()
329 .any(|arg| matches!(arg.as_str(), "--hard" | "--merge" | "--keep"))
330}
331
332fn git_rm_is_destructive(rm_args: &[String]) -> bool {
335 !rm_args.iter().any(|arg| arg == "--cached")
336}
337
338fn git_branch_is_force_delete(branch_args: &[String]) -> bool {
343 let mut deletes = false;
344 let mut forces = false;
345 for arg in branch_args {
346 match arg.as_str() {
347 "-D" => {
348 deletes = true;
349 forces = true;
350 }
351 "-d" | "--delete" => deletes = true,
352 "-f" | "--force" => forces = true,
353 _ => {
354 if arg.starts_with("--delete=") {
355 deletes = true;
356 }
357 if arg.starts_with("--force=") {
358 forces = true;
359 }
360 if short_flag_group_contains(arg, 'd') {
361 deletes = true;
362 }
363 if short_flag_group_contains(arg, 'D') {
364 deletes = true;
365 forces = true;
366 }
367 if short_flag_group_contains(arg, 'f') {
368 forces = true;
369 }
370 }
371 }
372 }
373 deletes && forces
374}
375
376fn classify_git_subcommand(subcommand: &str, args: &[String]) -> Option<&'static str> {
381 let scan_args = if subcommand == "push" {
390 args
391 } else {
392 match args.iter().position(|arg| arg == "--") {
393 Some(end) => &args[..end],
394 None => args,
395 }
396 };
397 match subcommand {
398 "reset" if git_reset_is_destructive(scan_args) => {
399 Some("git reset --hard/--merge/--keep discards uncommitted changes; use `git stash` or `git reset --soft`")
400 }
401 "rm" if git_rm_is_destructive(scan_args) => {
402 Some("git rm deletes working-tree files; unstage with `git rm --cached` instead")
403 }
404 "branch" if git_branch_is_force_delete(scan_args) => {
405 Some("git branch -D/--delete --force skips the unmerged-branch guard; use `-d` for merged branches")
406 }
407 "push" if git_push_is_dangerous(scan_args) => Some("git push force-updates or deletes remote refs"),
408 "clean" if git_clean_is_force(scan_args) => Some("git clean --force deletes untracked files"),
409 _ => None,
410 }
411}
412
413pub fn dangerous_command_reason(command: &[String]) -> Option<&'static str> {
418 let command = unwrap_command_prefix(command)?;
419 let cmd0 = command.first().map(String::as_str);
420 let (idx, subcommand) = if extract_command_name(cmd0.unwrap_or("")) == "git" {
421 find_git_subcommand(command, GIT_GUARDED_SUBCOMMANDS)?
422 } else {
423 let (idx, subcommand) = find_git_subcommand_from_args(command, GIT_GUARDED_SUBCOMMANDS)?;
427 if subcommand == "rm" {
428 return None;
429 }
430 (idx, subcommand)
431 };
432 classify_git_subcommand(subcommand, &command[idx + 1..])
433}
434
435fn is_dangerous_git_subcommand(command: &[String]) -> bool {
438 find_git_subcommand_from_args(command, GIT_GUARDED_SUBCOMMANDS)
439 .and_then(|(idx, subcommand)| classify_git_subcommand(subcommand, &command[idx + 1..]))
440 .is_some()
441}
442
443fn find_git_subcommand_from_args<'a>(args: &'a [String], subcommands: &[&str]) -> Option<(usize, &'a str)> {
445 let mut skip_next = false;
446 for (idx, arg) in args.iter().enumerate() {
447 if skip_next {
448 skip_next = false;
449 continue;
450 }
451
452 let arg = arg.as_str();
453
454 if is_git_global_option_with_inline_value(arg) {
455 continue;
456 }
457
458 if is_git_global_option_with_value(arg) {
459 skip_next = true;
460 continue;
461 }
462
463 if arg == "--" || arg.starts_with('-') {
464 continue;
465 }
466
467 if subcommands.contains(&arg) {
468 return Some((idx, arg));
469 }
470
471 return None;
473 }
474
475 None
476}
477
478fn is_dangerous_to_call_with_exec(command: &[String]) -> bool {
480 if command.is_empty() {
481 return false;
482 }
483
484 let cmd0 = command.first().map(String::as_str);
485 let base_cmd = extract_command_name(cmd0.unwrap_or(""));
486
487 match base_cmd {
488 "git" => {
490 let Some((subcommand_idx, subcommand)) = find_git_subcommand(command, GIT_GUARDED_SUBCOMMANDS) else {
491 return false;
492 };
493
494 classify_git_subcommand(subcommand, &command[subcommand_idx + 1..]).is_some()
495 }
496
497 "rm" => matches!(command.get(1).map(String::as_str), Some("-f" | "-rf" | "-fr" | "-r")),
499
500 _ if base_cmd == "mkfs" || base_cmd.starts_with("mkfs.") => true,
502 "dd" | "shutdown" | "reboot" | "init" => true,
503
504 _ if base_cmd.ends_with(':') && command.len() >= 2 => command[1] == "(){:|:&};:",
506
507 "sudo" => {
509 if command.len() > 1 {
510 is_dangerous_to_call_with_exec(&command[1..])
511 } else {
512 false
513 }
514 }
515
516 _ => is_dangerous_git_subcommand(command),
518 }
519}
520
521fn extract_command_name(cmd: &str) -> &str {
523 std::path::Path::new(cmd)
524 .file_name()
525 .and_then(|osstr| osstr.to_str())
526 .unwrap_or(cmd)
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 fn vec_str(args: &[&str]) -> Vec<String> {
534 args.iter().map(|s| s.to_string()).collect()
535 }
536
537 #[test]
538 fn powershell_encoded_options_remain_hard_denials_in_any_ascii_case() {
539 for executable in ["powershell", "PowerShell.exe", "pwsh", "PWSH.EXE", "/usr/bin/pwsh"] {
540 for option in ["-encodedcommand", "-encoded", "-enc", "-e"] {
541 for variant in [option.to_string(), option.to_ascii_uppercase()] {
542 let command = vec_str(&[executable, "-NoProfile", &variant, "ZQ=="]);
543 assert!(command_might_be_dangerous(&command), "hard denial: {command:?}");
544 assert!(command_requires_approval(&command), "approval classification: {command:?}");
545 }
546 }
547 }
548 let wrapped = vec_str(&["env", "MODE=test", "pwsh", "-EnCoDeDcOmMaNd", "ZQ=="]);
549 assert!(command_might_be_dangerous(&wrapped));
550 assert!(command_requires_approval(&wrapped));
551 }
552
553 #[test]
554 fn powershell_inline_options_require_approval_without_hard_denial() {
555 for executable in ["powershell", "powershell.exe", "pwsh", "pwsh.exe"] {
556 for option in ["-command", "-c", "-Command", "-COMMAND", "-C"] {
557 let command = vec_str(&[executable, "-NoProfile", option, "Write-Output ok"]);
558 assert!(!command_might_be_dangerous(&command), "inline code: {command:?}");
559 assert!(command_requires_approval(&command), "approval: {command:?}");
560 }
561 }
562 }
563
564 #[test]
565 fn powershell_option_near_misses_and_non_powershell_programs_stay_distinct() {
566 for option in [
567 "-commands",
568 "-encodedcommandx",
569 "--enc",
570 "enc",
571 "-en",
572 "-e=ZQ==",
573 "-command ",
574 " -command",
575 "-commánd",
576 "-enç",
577 ] {
578 let command = vec_str(&["pwsh", option, "example.ps1"]);
579 assert!(!command_might_be_dangerous(&command), "near miss: {command:?}");
580 assert!(!command_requires_approval(&command), "near miss: {command:?}");
581 }
582 for command in [
583 vec_str(&["pwsh", "-File", "example.ps1"]),
584 vec_str(&["echo", "-ENCODEDCOMMAND", "ZQ=="]),
585 ] {
586 assert!(!command_might_be_dangerous(&command), "ordinary arguments: {command:?}");
587 assert!(!command_requires_approval(&command), "ordinary arguments: {command:?}");
588 }
589 }
590
591 #[test]
592 fn git_reset_recoverable_modes_pass_preflight() {
593 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset"])));
596 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--soft", "HEAD~1"])));
597 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--mixed", "HEAD~1"])));
598 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "HEAD~1", "--", "file.txt"])));
599 }
600
601 #[test]
602 fn git_reset_destructive_modes_still_preflight_blocked() {
603 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--hard"])));
604 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--merge"])));
605 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--keep"])));
606 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "-C", "sub", "reset", "--hard"])));
608 assert!(command_might_be_dangerous(&vec_str(&["sudo", "git", "reset", "--hard"])));
609 assert!(
610 command_might_be_dangerous(&vec_str(&["FOO=bar", "env", "git", "reset", "--hard"])),
611 "env-prefixed destructive reset must stay blocked"
612 );
613 }
614
615 #[test]
616 fn git_rm_index_only_passes_preflight_and_working_tree_delete_stays_blocked() {
617 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "file.txt"])));
618 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "-rf", "dir"])));
619 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--cached", "file.txt"])));
620 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "-r", "--cached", "dir"])));
621 assert!(
622 is_dangerous_to_call_with_exec(&vec_str(&["sudo", "git", "rm", "file.txt"])),
623 "sudo-wrapped working-tree delete must stay blocked"
624 );
625 }
626
627 #[test]
628 fn git_branch_force_delete_is_blocked_but_merged_delete_passes() {
629 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-d", "feature"])));
631 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "--delete", "feature"])));
632 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-D", "feature"])));
633 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "--delete", "--force", "feature"])));
634 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-d", "-f", "feature"])));
635 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-df", "feature"])));
636 }
637
638 #[test]
639 fn end_of_options_separator_cannot_hide_working_tree_git_rm() {
640 assert!(
644 is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--", "--cached", "file.txt"])),
645 "`--cached` after `--` is a path; the invocation deletes working-tree files"
646 );
647 assert!(
648 is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--ignore-unmatch", "--", "--cached", "file.txt"])),
649 "--ignore-unmatch must not let a missing `--cached` path mask the deletion"
650 );
651 assert!(is_dangerous_to_call_with_exec(&vec_str(&["sudo", "git", "rm", "--", "--cached", "file.txt"])));
652 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "rm", "--cached", "--", "file.txt"])));
654 let reason = dangerous_command_reason(&vec_str(&["git", "rm", "--", "--cached", "file.txt"]))
656 .expect("end-of-options git rm carries a reason");
657 assert!(reason.contains("git rm"), "reason should name the pattern: {reason}");
658 }
659
660 #[test]
661 fn end_of_options_separator_stops_flag_classification_for_other_guarded_subcommands() {
662 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--", "--hard"])));
665 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "--", "-D", "feature"])));
666 assert!(!is_dangerous_to_call_with_exec(&vec_str(&["git", "clean", "--", "-fd"])));
667 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "reset", "--hard", "--", "file.txt"])));
669 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "branch", "-D", "--", "feature"])));
670 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "clean", "-fd", "--", "dir"])));
671 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "push", "origin", "--", "--force"])));
674 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "push", "origin", "--", ":refs/heads/main"])));
675 assert!(is_dangerous_to_call_with_exec(&vec_str(&["git", "push", "--force", "--", "origin", "main"])));
676 }
677
678 #[test]
679 fn dangerous_command_reason_names_git_pattern_and_skips_safe_commands() {
680 let destructive = vec_str(&["git", "reset", "--hard"]);
681 let reason = dangerous_command_reason(&destructive).expect("git reset --hard carries a reason");
682 assert!(reason.contains("git reset"), "reason should name the pattern: {reason}");
683
684 let recoverable = vec_str(&["git", "reset", "--soft", "HEAD~1"]);
685 assert!(dangerous_command_reason(&recoverable).is_none());
686
687 let safe = vec_str(&["git", "status"]);
688 assert!(dangerous_command_reason(&safe).is_none());
689
690 let pushed = vec_str(&["sudo", "git", "rm", "file.txt"]);
691 let sudo_reason = dangerous_command_reason(&pushed).expect("sudo-wrapped git rm carries a reason");
692 assert!(sudo_reason.contains("git rm"), "sudo prefix must be unwrapped: {sudo_reason}");
693 }
694
695 #[test]
696 fn dangerous_command_reason_does_not_label_plain_rm_as_git_rm() {
697 for cmd in [
701 vec_str(&["rm", "-f", "build.log"]),
702 vec_str(&["rm", "-rf", "dir"]),
703 vec_str(&["sudo", "rm", "-rf", "/tmp/x"]),
704 ] {
705 assert!(command_might_be_dangerous(&cmd), "plain rm must stay classified dangerous: {cmd:?}");
706 assert_eq!(dangerous_command_reason(&cmd), None, "plain rm must not receive the git rm remedy: {cmd:?}");
707 }
708 }
709
710 #[test]
711 fn git_status_is_safe() {
712 let cmd = vec!["git".to_string(), "status".to_string()];
713 assert!(!is_dangerous_to_call_with_exec(&cmd));
714 }
715
716 #[test]
717 fn git_log_is_safe() {
718 let cmd = vec!["git".to_string(), "log".to_string()];
719 assert!(!is_dangerous_to_call_with_exec(&cmd));
720 }
721
722 #[test]
723 fn rm_f_is_dangerous() {
724 let cmd = vec!["rm".to_string(), "-f".to_string(), "file.txt".to_string()];
725 assert!(is_dangerous_to_call_with_exec(&cmd));
726 }
727
728 #[test]
729 fn rm_rf_is_dangerous() {
730 let cmd = vec!["rm".to_string(), "-rf".to_string(), "/".to_string()];
731 assert!(is_dangerous_to_call_with_exec(&cmd));
732 }
733
734 #[test]
735 fn rm_without_flags_is_safe() {
736 let cmd = vec!["rm".to_string()];
737 assert!(!is_dangerous_to_call_with_exec(&cmd));
738 }
739
740 #[test]
741 fn mkfs_is_dangerous() {
742 let cmd = vec!["mkfs".to_string()];
743 assert!(is_dangerous_to_call_with_exec(&cmd));
744 }
745
746 #[test]
747 fn mkfs_variants_are_dangerous() {
748 let cmd = vec!["mkfs.ext4".to_string(), "/dev/sda1".to_string()];
749 assert!(is_dangerous_to_call_with_exec(&cmd));
750 }
751
752 #[test]
753 fn dd_is_dangerous() {
754 let cmd = vec!["dd".to_string(), "if=/dev/zero".to_string()];
755 assert!(is_dangerous_to_call_with_exec(&cmd));
756 }
757
758 #[test]
759 fn shutdown_is_dangerous() {
760 let cmd = vec!["shutdown".to_string()];
761 assert!(is_dangerous_to_call_with_exec(&cmd));
762 }
763
764 #[test]
765 fn sudo_git_reset_is_dangerous() {
766 let cmd = vec![
767 "sudo".to_string(),
768 "git".to_string(),
769 "reset".to_string(),
770 "--hard".to_string(),
771 ];
772 assert!(is_dangerous_to_call_with_exec(&cmd));
773 }
774
775 #[test]
776 fn sudo_git_status_is_safe() {
777 let cmd = vec!["sudo".to_string(), "git".to_string(), "status".to_string()];
778 assert!(!is_dangerous_to_call_with_exec(&cmd));
779 }
780
781 #[test]
782 fn absolute_path_git_reset_hard_is_dangerous() {
783 let cmd = vec!["/usr/bin/git".to_string(), "reset".to_string(), "--hard".to_string()];
784 assert!(is_dangerous_to_call_with_exec(&cmd));
785 let bare = vec!["/usr/bin/git".to_string(), "reset".to_string()];
787 assert!(!is_dangerous_to_call_with_exec(&bare));
788 }
789
790 #[test]
791 fn empty_command_is_safe() {
792 let cmd: Vec<String> = vec![];
793 assert!(!is_dangerous_to_call_with_exec(&cmd));
794 }
795
796 #[test]
797 fn command_might_be_dangerous_detects_git_reset_hard() {
798 let cmd = vec!["git".to_string(), "reset".to_string(), "--hard".to_string()];
799 assert!(command_might_be_dangerous(&cmd));
800 let bare = vec!["git".to_string(), "reset".to_string()];
802 assert!(!command_might_be_dangerous(&bare));
803 }
804
805 #[test]
806 fn command_might_be_dangerous_allows_git_status() {
807 let cmd = vec!["git".to_string(), "status".to_string()];
808 assert!(!command_might_be_dangerous(&cmd));
809 }
810
811 #[test]
812 fn wrappers_and_absolute_executables_do_not_hide_dangerous_commands() {
813 assert!(command_might_be_dangerous(&vec_str(&[
814 "env",
815 "MODE=test",
816 "sudo",
817 "-u",
818 "root",
819 "/usr/bin/git",
820 "reset",
821 "--hard",
822 ])));
823 assert!(command_might_be_dangerous(&vec_str(&["MODE=test", "/bin/sh", "-c", "rm -rf /",])));
824 }
825
826 #[test]
827 fn inline_interpreter_programs_are_code_execution_boundaries() {
828 for command in [
829 vec_str(&["/usr/bin/python3", "-c", "print('ok')"]),
830 vec_str(&["node", "-e", "console.log('ok')"]),
831 vec_str(&["ruby", "-e", "puts 'ok'"]),
832 vec_str(&["perl", "-e", "print 'ok'"]),
833 vec_str(&["php", "-r", "echo 'ok';"]),
834 vec_str(&["osascript", "-e", "return 1"]),
835 vec_str(&["pwsh", "-Command", "Write-Output ok"]),
836 ] {
837 assert!(!command_might_be_dangerous(&command), "inline code is not forbidden outright: {command:?}");
838 assert!(command_requires_approval(&command), "inline code should require policy admission: {command:?}");
839 }
840
841 let nested = vec_str(&["bash", "-lc", "python3 -c 'print(1)'"]);
842 assert!(command_requires_approval(&nested));
843 assert!(!command_might_be_dangerous(&nested));
844 }
845
846 #[test]
847 fn dynamic_or_unknown_wrapped_executables_fail_closed() {
848 assert!(command_might_be_dangerous(&vec_str(&["$TOOL", "status"])));
849 assert!(command_might_be_dangerous(&vec_str(&["env", "--unknown", "git", "status"])));
850 assert!(command_might_be_dangerous(&vec_str(&["sudo", "--unknown", "git", "status"])));
851 }
852
853 #[test]
856 fn git_branch_delete_is_dangerous_only_when_forced() {
857 assert!(!command_might_be_dangerous(&vec_str(&["git", "branch", "-d", "feature",])));
859 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-D", "feature",])));
860 let script = "git branch --delete --force feature";
862 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
863 for sub_cmd in sub_commands {
864 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
865 }
866 }
867 }
868
869 #[test]
870 fn git_branch_delete_with_stacked_short_flags_is_dangerous_only_when_forced() {
871 assert!(!command_might_be_dangerous(&vec_str(&["git", "branch", "-dv", "feature",])));
874 assert!(!command_might_be_dangerous(&vec_str(&["git", "branch", "-vd", "feature",])));
875 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-vD", "feature",])));
876 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-Dvv", "feature",])));
877 assert!(command_might_be_dangerous(&vec_str(&["git", "branch", "-df", "feature",])));
878 }
879
880 #[test]
881 fn git_branch_delete_with_global_options_is_dangerous_only_when_forced() {
882 assert!(!command_might_be_dangerous(&vec_str(&["git", "-C", ".", "branch", "-d", "feature",])));
883 assert!(command_might_be_dangerous(&vec_str(&["git", "-c", "color.ui=false", "branch", "-D", "feature",])));
884 let script = "git -C . branch -D feature";
886 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
887 for sub_cmd in sub_commands {
888 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
889 }
890 }
891 }
892
893 #[test]
894 fn git_checkout_reset_is_not_dangerous() {
895 assert!(!command_might_be_dangerous(&vec_str(&["git", "checkout", "reset",])));
898 }
899
900 #[test]
903 fn git_push_force_is_dangerous() {
904 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--force", "origin", "main",])));
905 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-f", "origin", "main",])));
906 assert!(command_might_be_dangerous(&vec_str(&[
907 "git",
908 "-C",
909 ".",
910 "push",
911 "--force-with-lease",
912 "origin",
913 "main",
914 ])));
915 }
916
917 #[test]
918 fn git_push_plus_refspec_is_dangerous() {
919 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", "+main",])));
920 assert!(command_might_be_dangerous(&vec_str(
921 &["git", "push", "origin", "+refs/heads/main:refs/heads/main",]
922 )));
923 }
924
925 #[test]
926 fn git_push_delete_flag_is_dangerous() {
927 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "--delete", "origin", "feature",])));
928 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "-d", "origin", "feature",])));
929 }
930
931 #[test]
932 fn git_push_delete_refspec_is_dangerous() {
933 assert!(command_might_be_dangerous(&vec_str(&["git", "push", "origin", ":feature",])));
934 let script = "git push origin :feature";
936 if let Ok(sub_commands) = crate::command_safety::shell_parser::parse_shell_commands(script) {
937 for sub_cmd in sub_commands {
938 assert!(command_might_be_dangerous(&sub_cmd), "sub-command should be dangerous: {sub_cmd:?}");
939 }
940 }
941 }
942
943 #[test]
944 fn git_push_without_force_is_not_dangerous() {
945 assert!(!command_might_be_dangerous(&vec_str(&["git", "push", "origin", "main",])));
946 }
947
948 #[test]
951 fn git_clean_force_is_dangerous_even_when_f_is_not_first_flag() {
952 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-fdx",])));
953 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "-xdf",])));
954 assert!(command_might_be_dangerous(&vec_str(&["git", "clean", "--force",])));
955 }
956}