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    "env",
63    "printenv",
64    // Version checks
65    "rustc --version",
66    "node --version",
67    "npm --version",
68    "python --version",
69    "python3 --version",
70    // Git read-only
71    "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 read-only
84    "docker ps",
85    "docker images",
86    "docker logs",
87    "docker compose ps",
88    "docker compose logs",
89    // Text processing (stdout-only; sed -i caught in DANGER_CHECKS)
90    "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    // Misc
105    "tput ",
106    "true",
107    "false",
108    "test ",
109    "[ ",
110    // GitHub CLI read-only (#518, #525)
111    "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// ── Token-level danger checks ─────────────────────────────────────────────────
129
130/// Structured representation of a dangerous command pattern.
131///
132/// Each variant is checked against the shlex token array for a pipeline
133/// segment, avoiding the substring-matching false-positives of the old design.
134#[derive(Debug, Clone, Copy)]
135enum DangerCheck {
136    /// Any invocation of this command is Destructive: `rm`, `sudo`, …
137    Cmd(&'static str),
138    /// Command with a specific flag anywhere in args: `sed -i`, `python -c`, …
139    CmdFlag(&'static str, &'static str),
140    /// Command with an exact subcommand: `npm publish`, `cargo publish`, …
141    CmdSub(&'static str, &'static str),
142    /// Command + subcommand + flag anywhere in remaining args: `git push -f`
143    CmdSubFlag(&'static str, &'static str, &'static str),
144    /// Command + subcommand + exact second token: `gh pr merge`, …
145    CmdSubSub(&'static str, &'static str, &'static str),
146}
147
148/// Returns `true` if token `t` matches `flag` — exact, long-flag, or
149/// combined short-flag (e.g. `-f` matches `-fd`, `-fdc`).
150fn flag_matches(t: &str, flag: &str) -> bool {
151    if t == flag {
152        return true;
153    }
154    // Combined short flags: `-fd` should match flag `-f`
155    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    // ── Destructive file operations ──────────────────────────────────────────
188    DangerCheck::Cmd("rm"),
189    DangerCheck::Cmd("rmdir"),
190    // ── Privilege escalation ─────────────────────────────────────────────────
191    DangerCheck::Cmd("sudo"),
192    DangerCheck::Cmd("su"),
193    // ── Low-level disk operations ────────────────────────────────────────────
194    DangerCheck::Cmd("dd"),
195    DangerCheck::Cmd("mkfs"),
196    DangerCheck::Cmd("fdisk"),
197    // ── Permission / ownership changes ───────────────────────────────────────
198    DangerCheck::Cmd("chmod"),
199    DangerCheck::Cmd("chown"),
200    // ── Process control ──────────────────────────────────────────────────────
201    DangerCheck::Cmd("kill"),
202    DangerCheck::Cmd("killall"),
203    DangerCheck::Cmd("pkill"),
204    // ── Arbitrary code execution ─────────────────────────────────────────────
205    DangerCheck::Cmd("eval"),
206    // ── System control ───────────────────────────────────────────────────────
207    DangerCheck::Cmd("reboot"),
208    DangerCheck::Cmd("shutdown"),
209    DangerCheck::Cmd("halt"),
210    // ── In-place file edits ──────────────────────────────────────────────────
211    DangerCheck::CmdFlag("sed", "-i"),
212    DangerCheck::CmdFlag("sed", "--in-place"),
213    // ── Interpreter inline execution (prompt-injection vector) ───────────────
214    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    // ── Nested shells (bypass classifier) ────────────────────────────────────
220    DangerCheck::CmdFlag("sh", "-c"),
221    DangerCheck::CmdFlag("bash", "-c"),
222    DangerCheck::CmdFlag("zsh", "-c"),
223    // ── Package publishing ────────────────────────────────────────────────────
224    DangerCheck::CmdSub("npm", "publish"),
225    DangerCheck::CmdSub("cargo", "publish"),
226    // ── Destructive git ───────────────────────────────────────────────────────
227    DangerCheck::CmdSubFlag("git", "push", "-f"),
228    DangerCheck::CmdSubFlag("git", "push", "--force"),
229    DangerCheck::CmdSubFlag("git", "reset", "--hard"),
230    DangerCheck::CmdSubFlag("git", "clean", "-f"), // also matches -fd, -fdc, …
231    // ── GitHub CLI destructive (#518, #525) ───────────────────────────────────
232    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
240// ── Raw structural patterns ───────────────────────────────────────────────────
241
242/// Shell metacharacters that shlex cannot represent as distinct tokens.
243///
244/// Matched against [`strip_quoted_strings`] output so patterns inside quoted
245/// arguments are ignored (`grep "| sh" file` is safe).
246const RAW_DANGER_PATTERNS: &[&str] = &[
247    "$(",   // command substitution
248    "`",    // backtick command substitution
249    "| sh", // pipe to shell interpreter
250    "| bash", "| zsh", "> /dev/", // device writes (>/dev/null is exempted in Phase 2)
251    "(){",     // fork bomb
252    "() {",
253];
254
255// ── Main classifier ───────────────────────────────────────────────────────────
256
257/// Classify a bash command by its side-effect severity.
258///
259/// Returns the *most dangerous* effect found across all pipeline/chain segments:
260/// 1. Raw structural patterns on quote-stripped string → Destructive
261/// 2. Write side-effects (`>`, `>>`, `| tee`) → LocalMutation
262/// 3. Per-segment shlex tokenisation vs `DANGER_CHECKS` → Destructive
263/// 4. Per-segment allowlist vs `READ_ONLY_PREFIXES` → ReadOnly / LocalMutation
264///
265/// Segments that fail shlex tokenisation fail-open to `LocalMutation`.
266///
267/// # Examples
268///
269/// ```
270/// use koda_core::bash_safety::classify_bash_command;
271/// use koda_core::tools::ToolEffect;
272///
273/// assert_eq!(classify_bash_command("ls -la"), ToolEffect::ReadOnly);
274/// assert_eq!(classify_bash_command("git status"), ToolEffect::ReadOnly);
275/// assert_eq!(classify_bash_command("cargo build"), ToolEffect::LocalMutation);
276/// assert_eq!(classify_bash_command("rm -rf /"), ToolEffect::Destructive);
277/// ```
278pub fn classify_bash_command(command: &str) -> ToolEffect {
279    let trimmed = command.trim();
280    if trimmed.is_empty() {
281        return ToolEffect::ReadOnly;
282    }
283
284    // Phase 1 — raw structural patterns on quote-stripped text.
285    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    // Phase 2 — write side-effects.
293    if has_write_side_effect(trimmed) {
294        return ToolEffect::LocalMutation;
295    }
296
297    // Phase 3 — per-segment token-level classification.
298    // We must check ALL segments before short-circuiting on LocalMutation
299    // because a later segment may be Destructive.
300    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
315/// Classify a single pipeline/chain segment using shlex tokenisation.
316///
317/// Returns `LocalMutation` on parse failure (fail-open — the user sees the
318/// prompt and can decide; we never auto-approve unparseable syntax).
319fn 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    // Tokenise with POSIX shlex. Returns None for unterminated quotes,
329    // complex bash syntax, etc. → fail-open.
330    let tokens = match shlex::split(&seg) {
331        Some(t) if !t.is_empty() => t,
332        _ => return ToolEffect::LocalMutation,
333    };
334
335    // Check against structured danger patterns.
336    for check in DANGER_CHECKS {
337        if check.matches(&tokens) {
338            return ToolEffect::Destructive;
339        }
340    }
341
342    // Fall back to read-only allowlist.
343    // Join tokens with single spaces to normalise irregular whitespace.
344    let canonical = tokens.join(" ");
345    if matches_prefix_list(&canonical, READ_ONLY_PREFIXES) {
346        ToolEffect::ReadOnly
347    } else {
348        ToolEffect::LocalMutation
349    }
350}
351
352// ── Write side-effect detection ───────────────────────────────────────────────
353
354/// Detect write side-effects: `>`, `>>` (except `>/dev/null`, `2>&1`), `| tee`.
355fn 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    // `| tee` check
388    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
401// ── Public helpers (also used by bash_path_lint) ──────────────────────────────
402
403/// Check if a segment matches any entry in a prefix list.
404fn 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
420/// Split a command into segments on `|`, `&&`, `||`, `;`.
421/// Respects single and double quotes.
422///
423/// # Examples
424///
425/// ```
426/// use koda_core::bash_safety::split_command_segments;
427///
428/// assert_eq!(split_command_segments("ls | grep foo"), vec!["ls ", " grep foo"]);
429/// assert_eq!(split_command_segments("a && b || c"), vec!["a ", " b ", " c"]);
430/// ```
431pub 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 // || or &&
448            } 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
468/// Replace content inside single and double quotes with spaces.
469///
470/// Used before `RAW_DANGER_PATTERNS` matching to suppress false positives
471/// from quoted arguments (e.g. `grep "cargo publish" .`).
472pub 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
514/// Strip leading environment variable assignments (`FOO=bar command`).
515///
516/// # Examples
517///
518/// ```
519/// use koda_core::bash_safety::strip_env_vars;
520///
521/// assert_eq!(strip_env_vars("FOO=bar cargo build"), "cargo build");
522/// assert_eq!(strip_env_vars("ls -la"), "ls -la");
523/// ```
524pub 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
546/// Strip common shell redirections so they don't confuse the allowlist matcher.
547fn 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
555/// Position of the first unquoted space in `s`.
556fn 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// ── Internal unit tests ─────────────────────────────────────────────────────
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    // flag_matches
577
578    #[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    // DangerCheck::matches
595
596    #[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    // split_command_segments
630
631    #[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    // strip_quoted_strings
653
654    #[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    // strip_env_vars
665
666    #[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}