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 "env",
63 "printenv",
64 "rustc --version",
66 "node --version",
67 "npm --version",
68 "python --version",
69 "python3 --version",
70 "git status",
72 "git log",
73 "git diff",
74 "git branch",
75 "git show",
76 "git remote",
77 "git stash list",
78 "git tag",
79 "git describe",
80 "git rev-parse",
81 "git ls-files",
82 "git blame",
83 "docker ps",
85 "docker images",
86 "docker logs",
87 "docker compose ps",
88 "docker compose logs",
89 "sort ",
91 "uniq ",
92 "cut ",
93 "awk ",
94 "sed ",
95 "tr ",
96 "diff ",
97 "jq ",
98 "yq ",
99 "xargs ",
100 "dirname ",
101 "basename ",
102 "realpath ",
103 "readlink ",
104 "tput ",
106 "true",
107 "false",
108 "test ",
109 "[ ",
110 "gh issue view",
112 "gh issue list",
113 "gh issue status",
114 "gh pr view",
115 "gh pr list",
116 "gh pr status",
117 "gh pr checks",
118 "gh pr diff",
119 "gh repo view",
120 "gh repo clone",
121 "gh release list",
122 "gh release view",
123 "gh run view",
124 "gh run list",
125 "gh run watch",
126];
127
128#[derive(Debug, Clone, Copy)]
135enum DangerCheck {
136 Cmd(&'static str),
138 CmdFlag(&'static str, &'static str),
140 CmdSub(&'static str, &'static str),
142 CmdSubFlag(&'static str, &'static str, &'static str),
144 CmdSubSub(&'static str, &'static str, &'static str),
146}
147
148fn flag_matches(t: &str, flag: &str) -> bool {
151 if t == flag {
152 return true;
153 }
154 if flag.len() == 2 && flag.starts_with('-') && t.starts_with('-') && !t.starts_with("--") {
156 let ch = flag.chars().nth(1).unwrap();
157 return t[1..].contains(ch);
158 }
159 false
160}
161
162impl DangerCheck {
163 fn matches(&self, tokens: &[String]) -> bool {
164 use DangerCheck::*;
165 let Some(cmd) = tokens.first() else {
166 return false;
167 };
168 match *self {
169 Cmd(c) => cmd == c,
170 CmdFlag(c, flag) => cmd == c && tokens[1..].iter().any(|t| flag_matches(t, flag)),
171 CmdSub(c, sub) => cmd == c && tokens.get(1).map(|s| s.as_str()) == Some(sub),
172 CmdSubFlag(c, sub, flag) => {
173 cmd == c
174 && tokens.get(1).map(|s| s.as_str()) == Some(sub)
175 && tokens[2..].iter().any(|t| flag_matches(t, flag))
176 }
177 CmdSubSub(c, sub, sub2) => {
178 cmd == c
179 && tokens.get(1).map(|s| s.as_str()) == Some(sub)
180 && tokens.get(2).map(|s| s.as_str()) == Some(sub2)
181 }
182 }
183 }
184}
185
186const DANGER_CHECKS: &[DangerCheck] = &[
187 DangerCheck::Cmd("rm"),
189 DangerCheck::Cmd("rmdir"),
190 DangerCheck::Cmd("sudo"),
192 DangerCheck::Cmd("su"),
193 DangerCheck::Cmd("dd"),
195 DangerCheck::Cmd("mkfs"),
196 DangerCheck::Cmd("fdisk"),
197 DangerCheck::Cmd("chmod"),
199 DangerCheck::Cmd("chown"),
200 DangerCheck::Cmd("kill"),
202 DangerCheck::Cmd("killall"),
203 DangerCheck::Cmd("pkill"),
204 DangerCheck::Cmd("eval"),
206 DangerCheck::Cmd("reboot"),
208 DangerCheck::Cmd("shutdown"),
209 DangerCheck::Cmd("halt"),
210 DangerCheck::CmdFlag("sed", "-i"),
212 DangerCheck::CmdFlag("sed", "--in-place"),
213 DangerCheck::CmdFlag("python", "-c"),
215 DangerCheck::CmdFlag("python3", "-c"),
216 DangerCheck::CmdFlag("perl", "-e"),
217 DangerCheck::CmdFlag("ruby", "-e"),
218 DangerCheck::CmdFlag("node", "-e"),
219 DangerCheck::CmdFlag("sh", "-c"),
221 DangerCheck::CmdFlag("bash", "-c"),
222 DangerCheck::CmdFlag("zsh", "-c"),
223 DangerCheck::CmdSub("npm", "publish"),
225 DangerCheck::CmdSub("cargo", "publish"),
226 DangerCheck::CmdSubFlag("git", "push", "-f"),
228 DangerCheck::CmdSubFlag("git", "push", "--force"),
229 DangerCheck::CmdSubFlag("git", "reset", "--hard"),
230 DangerCheck::CmdSubFlag("git", "clean", "-f"), DangerCheck::CmdSubSub("gh", "pr", "merge"),
233 DangerCheck::CmdSubSub("gh", "issue", "delete"),
234 DangerCheck::CmdSubSub("gh", "repo", "delete"),
235 DangerCheck::CmdSubSub("gh", "release", "delete"),
236 DangerCheck::CmdSub("gh", "api"),
237 DangerCheck::CmdSub("gh", "auth"),
238];
239
240const RAW_DANGER_PATTERNS: &[&str] = &[
247 "$(", "`", "| sh", "| bash", "| zsh", "> /dev/", "(){", "() {",
253];
254
255pub fn classify_bash_command(command: &str) -> ToolEffect {
279 let trimmed = command.trim();
280 if trimmed.is_empty() {
281 return ToolEffect::ReadOnly;
282 }
283
284 let unquoted = strip_quoted_strings(trimmed);
286 for pat in RAW_DANGER_PATTERNS {
287 if unquoted.contains(pat) {
288 return ToolEffect::Destructive;
289 }
290 }
291
292 if has_write_side_effect(trimmed) {
294 return ToolEffect::LocalMutation;
295 }
296
297 let segments = split_command_segments(trimmed);
301 let mut worst = ToolEffect::ReadOnly;
302
303 for seg in &segments {
304 let effect = classify_segment(seg);
305 match effect {
306 ToolEffect::Destructive => return ToolEffect::Destructive,
307 ToolEffect::LocalMutation => worst = ToolEffect::LocalMutation,
308 _ => {}
309 }
310 }
311
312 worst
313}
314
315fn classify_segment(segment: &str) -> ToolEffect {
320 let seg = strip_env_vars(segment.trim());
321 let seg = strip_redirections(&seg);
322 let seg = seg.trim().to_string();
323
324 if seg.is_empty() {
325 return ToolEffect::ReadOnly;
326 }
327
328 let tokens = match shlex::split(&seg) {
331 Some(t) if !t.is_empty() => t,
332 _ => return ToolEffect::LocalMutation,
333 };
334
335 for check in DANGER_CHECKS {
337 if check.matches(&tokens) {
338 return ToolEffect::Destructive;
339 }
340 }
341
342 let canonical = tokens.join(" ");
345 if matches_prefix_list(&canonical, READ_ONLY_PREFIXES) {
346 ToolEffect::ReadOnly
347 } else {
348 ToolEffect::LocalMutation
349 }
350}
351
352fn has_write_side_effect(command: &str) -> bool {
356 let chars: Vec<char> = command.chars().collect();
357 let mut in_sq = false;
358 let mut in_dq = false;
359 let mut i = 0;
360
361 while i < chars.len() {
362 let c = chars[i];
363 if c == '\'' && !in_dq {
364 in_sq = !in_sq;
365 } else if c == '"' && !in_sq {
366 in_dq = !in_dq;
367 } else if !in_sq && !in_dq && c == '>' {
368 let before = if i > 0 { chars[i - 1] } else { ' ' };
369 if before == '&' {
370 i += 1;
371 continue;
372 }
373 let after: String = chars[i + 1..].iter().collect();
374 let after_trimmed = after.trim_start();
375 if after_trimmed.starts_with("/dev/null")
376 || after_trimmed.starts_with("&1")
377 || after_trimmed.starts_with("&2")
378 {
379 i += 1;
380 continue;
381 }
382 return true;
383 }
384 i += 1;
385 }
386
387 let segments = split_command_segments(command);
389 for (idx, seg) in segments.iter().enumerate() {
390 if idx > 0 {
391 let t = seg.trim();
392 if t.starts_with("tee ") || t == "tee" {
393 return true;
394 }
395 }
396 }
397
398 false
399}
400
401fn matches_prefix_list(seg: &str, prefixes: &[&str]) -> bool {
405 for prefix in prefixes {
406 if prefix.ends_with(' ') {
407 if seg.starts_with(prefix) {
408 return true;
409 }
410 } else if seg == *prefix
411 || seg.starts_with(&format!("{prefix} "))
412 || seg.starts_with(&format!("{prefix}\t"))
413 {
414 return true;
415 }
416 }
417 false
418}
419
420pub fn split_command_segments(command: &str) -> Vec<&str> {
432 let mut segments = Vec::new();
433 let mut start = 0;
434 let chars: Vec<char> = command.chars().collect();
435 let mut i = 0;
436 let mut in_single_quote = false;
437 let mut in_double_quote = false;
438
439 while i < chars.len() {
440 let c = chars[i];
441 if c == '\'' && !in_double_quote {
442 in_single_quote = !in_single_quote;
443 } else if c == '"' && !in_single_quote {
444 in_double_quote = !in_double_quote;
445 } else if !in_single_quote && !in_double_quote {
446 let sep_len = if (c == '|' || c == '&') && i + 1 < chars.len() && chars[i + 1] == c {
447 2 } else if c == '|' || c == ';' {
449 1
450 } else {
451 0
452 };
453 if sep_len > 0 {
454 segments.push(&command[start..i]);
455 i += sep_len;
456 start = i;
457 continue;
458 }
459 }
460 i += 1;
461 }
462 if start < chars.len() {
463 segments.push(&command[start..]);
464 }
465 segments
466}
467
468pub fn strip_quoted_strings(s: &str) -> String {
473 let mut result = String::with_capacity(s.len());
474 let mut chars = s.chars().peekable();
475 while let Some(c) = chars.next() {
476 if c == '\'' {
477 result.push(c);
478 let mut found_close = false;
479 for inner in chars.by_ref() {
480 if inner == '\'' {
481 result.push(c);
482 found_close = true;
483 break;
484 }
485 result.push(' ');
486 }
487 let _ = found_close;
488 } else if c == '"' {
489 result.push(c);
490 let mut found_close = false;
491 while let Some(inner) = chars.next() {
492 if inner == '\\' {
493 result.push(' ');
494 if chars.next().is_some() {
495 result.push(' ');
496 }
497 continue;
498 }
499 if inner == '"' {
500 result.push(c);
501 found_close = true;
502 break;
503 }
504 result.push(' ');
505 }
506 let _ = found_close;
507 } else {
508 result.push(c);
509 }
510 }
511 result
512}
513
514pub fn strip_env_vars(segment: &str) -> String {
525 let mut rest = segment;
526 loop {
527 let trimmed = rest.trim_start();
528 if let Some(eq_pos) = trimmed.find('=') {
529 let before_eq = &trimmed[..eq_pos];
530 if !before_eq.is_empty()
531 && before_eq
532 .chars()
533 .all(|c| c.is_ascii_alphanumeric() || c == '_')
534 {
535 let after_eq = &trimmed[eq_pos + 1..];
536 if let Some(space_pos) = find_unquoted_space(after_eq) {
537 rest = &after_eq[space_pos..];
538 continue;
539 }
540 }
541 }
542 return trimmed.to_string();
543 }
544}
545
546fn strip_redirections(segment: &str) -> String {
548 let mut result = segment.to_string();
549 for pat in ["2>&1", "2>/dev/null", ">/dev/null", "</dev/null"] {
550 result = result.replace(pat, "");
551 }
552 result
553}
554
555fn find_unquoted_space(s: &str) -> Option<usize> {
557 let mut in_sq = false;
558 let mut in_dq = false;
559 for (i, c) in s.chars().enumerate() {
560 match c {
561 '\'' if !in_dq => in_sq = !in_sq,
562 '"' if !in_sq => in_dq = !in_dq,
563 ' ' | '\t' if !in_sq && !in_dq => return Some(i),
564 _ => {}
565 }
566 }
567 None
568}
569
570#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
579 fn test_flag_matches_exact() {
580 assert!(flag_matches("-i", "-i"));
581 assert!(flag_matches("--force", "--force"));
582 assert!(!flag_matches("-n", "-i"));
583 assert!(!flag_matches("--force", "-f"));
584 }
585
586 #[test]
587 fn test_flag_matches_combined_short() {
588 assert!(flag_matches("-fd", "-f"));
589 assert!(flag_matches("-fdc", "-f"));
590 assert!(!flag_matches("-nd", "-f"));
591 assert!(!flag_matches("--force", "-f"));
592 }
593
594 #[test]
597 fn test_danger_check_cmd() {
598 let t = |s: &str| s.to_string();
599 let rm = vec![t("rm"), t("-rf"), t("/")];
600 assert!(DangerCheck::Cmd("rm").matches(&rm));
601 assert!(!DangerCheck::Cmd("ls").matches(&rm));
602 }
603
604 #[test]
605 fn test_danger_check_cmd_flag() {
606 let t = |s: &str| s.to_string();
607 let sed_i = vec![t("sed"), t("-i"), t("s/a/b/")];
608 assert!(DangerCheck::CmdFlag("sed", "-i").matches(&sed_i));
609 assert!(!DangerCheck::CmdFlag("sed", "--in-place").matches(&sed_i));
610 }
611
612 #[test]
613 fn test_danger_check_combined_flag() {
614 let t = |s: &str| s.to_string();
615 let git_clean_fd = vec![t("git"), t("clean"), t("-fd")];
616 assert!(DangerCheck::CmdSubFlag("git", "clean", "-f").matches(&git_clean_fd));
617 let git_clean_n = vec![t("git"), t("clean"), t("-nd")];
618 assert!(!DangerCheck::CmdSubFlag("git", "clean", "-f").matches(&git_clean_n));
619 }
620
621 #[test]
622 fn test_danger_check_cmd_sub_sub() {
623 let t = |s: &str| s.to_string();
624 let merge = vec![t("gh"), t("pr"), t("merge"), t("42")];
625 assert!(DangerCheck::CmdSubSub("gh", "pr", "merge").matches(&merge));
626 assert!(!DangerCheck::CmdSubSub("gh", "pr", "view").matches(&merge));
627 }
628
629 #[test]
632 fn test_split_pipe() {
633 let segs = split_command_segments("cat file | grep pattern");
634 assert_eq!(segs.len(), 2);
635 assert_eq!(segs[0].trim(), "cat file");
636 assert_eq!(segs[1].trim(), "grep pattern");
637 }
638
639 #[test]
640 fn test_split_chain_and_semicolon() {
641 assert_eq!(split_command_segments("cargo build && cargo test").len(), 2);
642 assert_eq!(split_command_segments("echo a; echo b; echo c").len(), 3);
643 }
644
645 #[test]
646 fn test_split_respects_quotes() {
647 let segs = split_command_segments("echo 'a | b' | grep x");
648 assert_eq!(segs.len(), 2);
649 assert!(segs[0].contains("'a | b'"));
650 }
651
652 #[test]
655 fn test_strip_quoted_backslash_escaped() {
656 assert_eq!(
657 strip_quoted_strings(r#"echo "it\"s fine" ; ls"#),
658 r#"echo " " ; ls"#,
659 );
660 let stripped = strip_quoted_strings(r#"echo "safe\" ; rm -rf /""#);
661 assert!(!stripped.contains("rm -rf"));
662 }
663
664 #[test]
667 fn test_strip_env_vars_basic() {
668 assert_eq!(strip_env_vars("FOO=bar cargo build"), "cargo build");
669 assert_eq!(strip_env_vars("ls -la"), "ls -la");
670 }
671}