Skip to main content

harness/
shell_risk.rs

1//! Static risk classification for shell commands.
2//!
3//! [`classify_shell_command`] inspects a bash command *before* it is
4//! dispatched to the sandbox and assigns one of four risk levels:
5//!
6//!   * [`ShellRiskLevel::SafeRead`] — provably read-only (display/inspect
7//!     commands, read-only git, pipelines/lists composed solely of such
8//!     commands). Safe to auto-approve as a read.
9//!   * [`ShellRiskLevel::BoundedWrite`] — writes, but only to well-known
10//!     project-local artifact locations (build/test caches: `cargo test`,
11//!     `go build`, `npm test`, …).
12//!   * [`ShellRiskLevel::NeedsApproval`] — everything we cannot statically
13//!     prove safe. This is the *default*: any parse failure, any shell
14//!     metacharacter we don't model, any unknown command lands here.
15//!   * [`ShellRiskLevel::Blocked`] — a short hard-deny list of operations
16//!     that would destroy the sandbox session itself (recursive deletion of
17//!     critical system paths, raw block-device writes, shutdown/reboot,
18//!     killing PID 1, fork bombs). These are rejected before dispatch.
19//!
20//! Design notes:
21//!
22//!   * Commands run inside a disposable sandbox, so the deny list is NOT a
23//!     host-security boundary (sandbox isolation is). It exists to stop a
24//!     weak model from accidentally killing its own session with an
25//!     unrecoverable command. It is deliberately short and precise; anything
26//!     ambiguous falls to `NeedsApproval` instead of `Blocked`.
27//!   * Classification is purely static and conservative. The safe-side
28//!     analysis refuses anything it cannot fully parse (command
29//!     substitution, globs, redirects, `||` chains — whose right side only
30//!     runs on failure and therefore can't be proven read-only statically).
31//!   * The deny-side analysis is the opposite: it scans best-effort through
32//!     constructs the safe side refuses (subshells, `&&`/`||`/`;`/`|`
33//!     chains, `sh -c '…'` wrappers) so destructive segments can't hide
34//!     behind syntax that merely fails the safe parse.
35
36/// Environment variable used by [`classify_shell_command`] to select the shell
37/// risk policy.
38///
39/// Set to `relaxed`, `lenient`, or `permissive` to bypass conservative
40/// read-only classification while still enforcing hard-deny checks. Any other
41/// value, or an unset variable, uses [`ShellRiskPolicy::Strict`].
42pub const SHELL_RISK_POLICY_ENV: &str = "AGENT_HARNESS_SHELL_RISK_POLICY";
43
44/// Policy used for static shell-risk classification.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ShellRiskPolicy {
47    /// Preserve the conservative static classifier.
48    Strict,
49    /// Temporarily bypass conservative read-only checks while keeping hard-deny
50    /// protection for session-destroying commands.
51    Relaxed,
52}
53
54impl ShellRiskPolicy {
55    /// Resolve the policy from [`SHELL_RISK_POLICY_ENV`].
56    pub fn from_env() -> Self {
57        match std::env::var(SHELL_RISK_POLICY_ENV) {
58            Ok(value) if shell_risk_policy_value_is_relaxed(&value) => ShellRiskPolicy::Relaxed,
59            _ => ShellRiskPolicy::Strict,
60        }
61    }
62}
63
64/// Risk level assigned to a shell command by [`classify_shell_command`].
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum ShellRiskLevel {
67    /// Provably read-only; safe to auto-approve.
68    SafeRead,
69    /// Writes only to well-known project-local build/test artifacts.
70    BoundedWrite,
71    /// Cannot be statically proven safe (the default).
72    NeedsApproval,
73    /// On the hard-deny list; must not be dispatched.
74    Blocked,
75}
76
77impl ShellRiskLevel {
78    pub fn as_str(&self) -> &'static str {
79        match self {
80            ShellRiskLevel::SafeRead => "safe_read",
81            ShellRiskLevel::BoundedWrite => "bounded_write",
82            ShellRiskLevel::NeedsApproval => "needs_approval",
83            ShellRiskLevel::Blocked => "blocked",
84        }
85    }
86}
87
88/// Classification result: a level plus a human-readable reason suitable for
89/// logging and for surfacing to the model when a command is blocked.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ShellRiskDecision {
92    pub level: ShellRiskLevel,
93    pub reason: String,
94}
95
96fn safe_read(reason: &str) -> ShellRiskDecision {
97    ShellRiskDecision {
98        level: ShellRiskLevel::SafeRead,
99        reason: reason.to_string(),
100    }
101}
102
103fn bounded_write(reason: String) -> ShellRiskDecision {
104    ShellRiskDecision {
105        level: ShellRiskLevel::BoundedWrite,
106        reason,
107    }
108}
109
110fn needs_approval(reason: &str) -> ShellRiskDecision {
111    ShellRiskDecision {
112        level: ShellRiskLevel::NeedsApproval,
113        reason: reason.to_string(),
114    }
115}
116
117fn blocked(reason: &str) -> ShellRiskDecision {
118    ShellRiskDecision {
119        level: ShellRiskLevel::Blocked,
120        reason: reason.to_string(),
121    }
122}
123
124/// Classify a shell command's static risk. See module docs for the levels.
125pub fn classify_shell_command(command: &str) -> ShellRiskDecision {
126    classify_shell_command_with_policy(command, ShellRiskPolicy::from_env())
127}
128
129/// Classify a shell command's static risk with an explicit policy.
130pub fn classify_shell_command_with_policy(
131    command: &str,
132    policy: ShellRiskPolicy,
133) -> ShellRiskDecision {
134    if let Some(decision) = hard_deny(command) {
135        return decision;
136    }
137    match policy {
138        ShellRiskPolicy::Strict => classify_allowable(command),
139        ShellRiskPolicy::Relaxed => safe_read("shell risk policy relaxed"),
140    }
141}
142
143fn shell_risk_policy_value_is_relaxed(value: &str) -> bool {
144    matches!(
145        value.trim().to_ascii_lowercase().as_str(),
146        "relaxed" | "lenient" | "permissive"
147    )
148}
149
150// ---------------------------------------------------------------------------
151// Hard deny: session-destroying operations
152// ---------------------------------------------------------------------------
153
154/// System paths whose recursive removal (or permission sweep) kills the
155/// sandbox session outright.
156const CRITICAL_SYSTEM_PATHS: &[&str] = &[
157    "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/proc", "/sbin", "/sys", "/usr", "/var",
158];
159
160/// Raw block/memory device prefixes a redirect or `dd of=` must never target.
161const RAW_DEVICE_PREFIXES: &[&str] = &[
162    "/dev/sd",
163    "/dev/vd",
164    "/dev/xvd",
165    "/dev/hd",
166    "/dev/nvme",
167    "/dev/mem",
168    "/dev/kmem",
169    "/dev/port",
170];
171
172/// Device paths that are always fine as a write target.
173const SAFE_DEVICE_SINKS: &[&str] = &["/dev/null", "/dev/stdout", "/dev/stderr"];
174
175fn hard_deny(command: &str) -> Option<ShellRiskDecision> {
176    if looks_like_fork_bomb(command) {
177        return Some(blocked(
178            "fork bomb pattern would exhaust sandbox PIDs and make the session unresponsive",
179        ));
180    }
181    for segment in deny_scan_segments(command) {
182        if let Some(decision) = deny_scan_segment(&segment) {
183            return Some(decision);
184        }
185    }
186    None
187}
188
189/// Best-effort split of a raw command line into candidate simple commands for
190/// deny scanning. Unlike the conservative safe-side splitters, this one keeps
191/// going through subshells, command substitution, `&`, and newlines: a
192/// destructive segment must be found wherever it hides, even in syntax the
193/// safe side refuses to parse.
194fn deny_scan_segments(command: &str) -> Vec<String> {
195    let mut segments = Vec::new();
196    let mut current = String::new();
197    let mut quote: Option<char> = None;
198    let mut escaped = false;
199    let flush = |current: &mut String, segments: &mut Vec<String>| {
200        let part = current.trim();
201        if !part.is_empty() {
202            segments.push(part.to_string());
203        }
204        current.clear();
205    };
206    for ch in command.chars() {
207        if let Some(q) = quote {
208            // Inside quotes the content is preserved verbatim into the
209            // current segment (the word splitter strips quotes later, so a
210            // `sh -c '…'` body stays scannable).
211            current.push(ch);
212            if q == '"' {
213                if escaped {
214                    escaped = false;
215                } else if ch == '\\' {
216                    escaped = true;
217                } else if ch == '"' {
218                    quote = None;
219                }
220            } else if ch == q {
221                quote = None;
222            }
223            continue;
224        }
225        if escaped {
226            escaped = false;
227            current.push(ch);
228            continue;
229        }
230        match ch {
231            '\\' => {
232                escaped = true;
233                current.push(ch);
234            }
235            '\'' | '"' => {
236                quote = Some(ch);
237                current.push(ch);
238            }
239            ';' | '|' | '&' | '\n' | '(' | ')' | '`' => flush(&mut current, &mut segments),
240            _ => current.push(ch),
241        }
242    }
243    flush(&mut current, &mut segments);
244    segments
245}
246
247/// Split a deny-scan segment into words. Quote-aware (quotes are stripped,
248/// content kept); unquoted `>` runs become standalone `">"` tokens so
249/// redirect targets show up as the following word.
250fn deny_scan_words(segment: &str) -> Vec<String> {
251    let mut words = Vec::new();
252    let mut word = String::new();
253    let mut in_word = false;
254    let mut quote: Option<char> = None;
255    let mut escaped = false;
256    let chars: Vec<char> = segment.chars().collect();
257    let mut i = 0;
258    while i < chars.len() {
259        let ch = chars[i];
260        if let Some(q) = quote {
261            if q == '"' {
262                if escaped {
263                    escaped = false;
264                    word.push(ch);
265                } else if ch == '\\' {
266                    escaped = true;
267                } else if ch == '"' {
268                    quote = None;
269                } else {
270                    word.push(ch);
271                }
272            } else if ch == q {
273                quote = None;
274            } else {
275                word.push(ch);
276            }
277            i += 1;
278            continue;
279        }
280        if escaped {
281            escaped = false;
282            word.push(ch);
283            i += 1;
284            continue;
285        }
286        match ch {
287            '\\' => escaped = true,
288            '\'' | '"' => {
289                quote = Some(ch);
290                in_word = true;
291            }
292            ' ' | '\t' => {
293                if in_word {
294                    words.push(std::mem::take(&mut word));
295                    in_word = false;
296                }
297            }
298            '>' => {
299                if in_word {
300                    words.push(std::mem::take(&mut word));
301                    in_word = false;
302                }
303                words.push(">".to_string());
304                // Collapse `>>` into a single redirect token.
305                if i + 1 < chars.len() && chars[i + 1] == '>' {
306                    i += 1;
307                }
308            }
309            _ => {
310                in_word = true;
311                word.push(ch);
312            }
313        }
314        i += 1;
315    }
316    if in_word {
317        words.push(word);
318    }
319    words
320}
321
322/// Drop leading wrappers (`sudo`, `env VAR=… `, `nohup`, `timeout 30`, …) so
323/// the real command name lands at index 0.
324fn strip_command_wrappers(words: &[String]) -> Vec<String> {
325    let mut rest: &[String] = words;
326    loop {
327        let Some(first) = rest.first() else {
328            return Vec::new();
329        };
330        match command_basename(first).as_str() {
331            "sudo" | "doas" => {
332                rest = &rest[1..];
333                while rest.first().is_some_and(|w| w.starts_with('-')) {
334                    rest = &rest[1..];
335                }
336            }
337            "env" => {
338                rest = &rest[1..];
339                while rest
340                    .first()
341                    .is_some_and(|w| w.contains('=') || w.starts_with('-'))
342                {
343                    rest = &rest[1..];
344                }
345            }
346            "nohup" | "command" | "exec" | "time" | "nice" | "ionice" | "stdbuf" => {
347                rest = &rest[1..];
348                while rest.first().is_some_and(|w| w.starts_with('-')) {
349                    rest = &rest[1..];
350                }
351            }
352            "timeout" => {
353                rest = &rest[1..];
354                while rest.first().is_some_and(|w| w.starts_with('-')) {
355                    rest = &rest[1..];
356                }
357                // Skip the duration operand.
358                if !rest.is_empty() {
359                    rest = &rest[1..];
360                }
361            }
362            _ => return rest.to_vec(),
363        }
364    }
365}
366
367fn command_basename(word: &str) -> String {
368    word.rsplit('/').next().unwrap_or(word).to_lowercase()
369}
370
371fn deny_scan_segment(segment: &str) -> Option<ShellRiskDecision> {
372    let words = strip_command_wrappers(&deny_scan_words(segment));
373    let cmd = command_basename(words.first()?);
374    let args = &words[1..];
375
376    // A `sh -c '…'` wrapper executes its quoted argument: scan it too.
377    if matches!(cmd.as_str(), "sh" | "bash" | "zsh" | "dash" | "ksh") {
378        let mut iter = args.iter();
379        while let Some(arg) = iter.next() {
380            if arg.starts_with('-') && arg.contains('c') {
381                if let Some(script) = iter.next() {
382                    if let Some(decision) = hard_deny(script) {
383                        return Some(decision);
384                    }
385                }
386                break;
387            }
388        }
389    }
390
391    // Redirect straight into a raw device bricks the filesystem.
392    let mut expect_redirect_target = false;
393    for word in &words {
394        if word == ">" {
395            expect_redirect_target = true;
396            continue;
397        }
398        if std::mem::take(&mut expect_redirect_target) && is_raw_device_path(word) {
399            return Some(blocked(
400                "redirecting output to a raw device would corrupt the sandbox filesystem",
401            ));
402        }
403    }
404
405    match cmd.as_str() {
406        "rm" => deny_check_rm(args),
407        "chmod" | "chown" | "chgrp" => deny_check_permission_sweep(&cmd, args),
408        "mkswap" | "wipefs" | "blkdiscard" => Some(blocked(
409            "filesystem/block-device destruction would brick the sandbox",
410        )),
411        "fdisk" | "parted" | "sgdisk" => {
412            let listing_only = args.iter().any(|a| a == "-l" || a == "--list");
413            if listing_only {
414                None
415            } else {
416                Some(blocked(
417                    "partition-table manipulation would brick the sandbox",
418                ))
419            }
420        }
421        "dd" => {
422            for arg in args {
423                if let Some(target) = arg.strip_prefix("of=") {
424                    if target.starts_with("/dev/") && !SAFE_DEVICE_SINKS.contains(&target) {
425                        return Some(blocked(
426                            "dd writing to a raw device would corrupt the sandbox filesystem",
427                        ));
428                    }
429                }
430            }
431            None
432        }
433        "shutdown" | "reboot" | "halt" | "poweroff" | "telinit" => {
434            Some(blocked("shutting down the sandbox terminates the session"))
435        }
436        "init" => {
437            if args.iter().any(|a| a == "0" || a == "6") {
438                Some(blocked(
439                    "changing the runlevel to halt/reboot terminates the session",
440                ))
441            } else {
442                None
443            }
444        }
445        "systemctl" => {
446            let sub = args.iter().find(|a| !a.starts_with('-'));
447            if sub.is_some_and(|s| matches!(s.as_str(), "reboot" | "poweroff" | "halt" | "kexec")) {
448                Some(blocked("shutting down the sandbox terminates the session"))
449            } else {
450                None
451            }
452        }
453        "kill" => deny_check_kill(args),
454        "killall5" => Some(blocked(
455            "signalling every process kills the sandbox session",
456        )),
457        _ => {
458            if cmd.starts_with("mkfs") {
459                return Some(blocked(
460                    "creating a filesystem over an existing device would brick the sandbox",
461                ));
462            }
463            None
464        }
465    }
466}
467
468fn deny_check_rm(args: &[String]) -> Option<ShellRiskDecision> {
469    let mut recursive = false;
470    let mut no_preserve_root = false;
471    let mut operands: Vec<&String> = Vec::new();
472    let mut end_of_options = false;
473    for arg in args {
474        if end_of_options {
475            operands.push(arg);
476            continue;
477        }
478        if arg == "--" {
479            end_of_options = true;
480        } else if arg == "--recursive" {
481            recursive = true;
482        } else if arg == "--no-preserve-root" {
483            no_preserve_root = true;
484        } else if let Some(short) = arg.strip_prefix('-') {
485            if !short.starts_with('-') && short.chars().any(|c| c == 'r' || c == 'R') {
486                recursive = true;
487            }
488        } else {
489            operands.push(arg);
490        }
491    }
492    if !recursive {
493        return None;
494    }
495    if no_preserve_root {
496        return Some(blocked(
497            "rm --no-preserve-root with recursion would destroy the sandbox session",
498        ));
499    }
500    if operands.iter().any(|p| is_critical_system_path(p)) {
501        return Some(blocked(
502            "recursive deletion of a critical system path would destroy the sandbox session",
503        ));
504    }
505    None
506}
507
508fn deny_check_permission_sweep(cmd: &str, args: &[String]) -> Option<ShellRiskDecision> {
509    let mut recursive = false;
510    let mut operands: Vec<&String> = Vec::new();
511    let mut end_of_options = false;
512    for arg in args {
513        if end_of_options {
514            operands.push(arg);
515            continue;
516        }
517        if arg == "--" {
518            end_of_options = true;
519        } else if arg == "--recursive" {
520            recursive = true;
521        } else if let Some(short) = arg.strip_prefix('-') {
522            if !short.starts_with('-') && short.chars().any(|c| c == 'R' || c == 'r') {
523                recursive = true;
524            }
525        } else {
526            operands.push(arg);
527        }
528    }
529    if recursive && operands.iter().any(|p| is_critical_system_path(p)) {
530        return Some(blocked(
531            // chmod -R 000 /usr is as fatal as deleting it.
532            match cmd {
533                "chmod" => "recursive permission sweep over a critical system path would destroy the sandbox session",
534                _ => "recursive ownership sweep over a critical system path would destroy the sandbox session",
535            },
536        ));
537    }
538    None
539}
540
541fn deny_check_kill(args: &[String]) -> Option<ShellRiskDecision> {
542    let mut saw_signal = false;
543    let mut end_of_options = false;
544    for arg in args {
545        if !end_of_options && arg == "--" {
546            end_of_options = true;
547            continue;
548        }
549        if !end_of_options && arg.starts_with('-') {
550            // The first dash argument is the signal spec (`-9`, `-TERM`,
551            // `-s`); a later `-1` is process group "everything".
552            if saw_signal && arg == "-1" {
553                return Some(blocked(
554                    "kill -1 signals every process and kills the sandbox session",
555                ));
556            }
557            saw_signal = true;
558            continue;
559        }
560        if arg == "1" || arg == "-1" {
561            return Some(blocked("killing PID 1 terminates the sandbox session"));
562        }
563    }
564    None
565}
566
567fn is_critical_system_path(path: &str) -> bool {
568    let trimmed = path.trim();
569    // `/usr/`, `/usr/*` and `/usr` are all the same target.
570    let stripped = trimmed.strip_suffix("/*").unwrap_or(trimmed);
571    let normalized = if stripped.len() > 1 {
572        stripped.trim_end_matches('/')
573    } else {
574        stripped
575    };
576    if normalized == "/" || normalized == "/*" || trimmed == "/*" {
577        return true;
578    }
579    CRITICAL_SYSTEM_PATHS.contains(&normalized)
580}
581
582fn is_raw_device_path(path: &str) -> bool {
583    RAW_DEVICE_PREFIXES
584        .iter()
585        .any(|prefix| path.starts_with(prefix))
586}
587
588/// Detect the classic fork-bomb shape: a function whose body pipes itself
589/// into itself in the background (`:(){ :|:& };:` and renamed variants).
590fn looks_like_fork_bomb(command: &str) -> bool {
591    let compact: String = command.chars().filter(|c| !c.is_whitespace()).collect();
592    let Some(def_at) = compact.find("(){") else {
593        return false;
594    };
595    let name: String = compact[..def_at]
596        .chars()
597        .rev()
598        .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
599        .collect::<String>()
600        .chars()
601        .rev()
602        .collect();
603    if name.is_empty() {
604        return false;
605    }
606    let body = &compact[def_at + 3..];
607    body.contains(&format!("{name}|{name}")) && body.contains('&')
608}
609
610// ---------------------------------------------------------------------------
611// Conservative safe-side classification
612// ---------------------------------------------------------------------------
613
614/// Command prefixes (matched word-by-word) that are read-only by nature.
615const READ_ONLY_PREFIXES: &[&str] = &[
616    "ls",
617    "pwd",
618    "echo",
619    "cat",
620    "head",
621    "tail",
622    "wc",
623    "file",
624    "tree",
625    "find",
626    "grep",
627    "rg",
628    "uptime",
629    "cal",
630    "free",
631    "df",
632    "du",
633    "locale",
634    "groups",
635    "nproc",
636    "stat",
637    "strings",
638    "hexdump",
639    "od",
640    "nl",
641    "basename",
642    "dirname",
643    "realpath",
644    "readlink",
645    "cut",
646    "paste",
647    "tr",
648    "column",
649    "tac",
650    "rev",
651    "fold",
652    "expand",
653    "unexpand",
654    "comm",
655    "cmp",
656    "numfmt",
657    "true",
658    "false",
659    "type",
660    "expr",
661    "test",
662    "getconf",
663    "seq",
664    "tsort",
665    "pr",
666    "go version",
667    "rustc --version",
668    "python --version",
669    "python3 --version",
670    "node --version",
671    "npm --version",
672    "npx --version",
673    "cargo --version",
674    "deno --version",
675    "bun --version",
676];
677
678fn classify_allowable(command: &str) -> ShellRiskDecision {
679    // A trailing stderr redirect to stdout or /dev/null doesn't change the
680    // risk of the underlying command.
681    if let Some(base) = strip_trailing_safe_stderr_redirect(command) {
682        return classify_allowable(&base);
683    }
684    if let Some(parts) = split_sequence(command) {
685        return classify_all_safe_read(&parts, "; list");
686    }
687    if let Some(parts) = split_and_list(command) {
688        return classify_all_safe_read(&parts, "&& list");
689    }
690    if let Some(parts) = split_pipeline(command) {
691        return classify_all_safe_read(&parts, "pipeline");
692    }
693    let Some(argv) = parse_simple_command(command) else {
694        return needs_approval("command is not a simple shell command");
695    };
696    let lower: Vec<String> = argv.iter().map(|a| a.to_lowercase()).collect();
697    if has_unsafe_args(&lower) {
698        return needs_approval(
699            "command contains arguments that may mutate files or execute arbitrary code",
700        );
701    }
702    if make_bounded_target_has_extra_args(&lower) {
703        return needs_approval("make bounded-write targets must not include extra targets or args");
704    }
705    if let Some(decision) = classify_builtin_read_only(&argv, &lower) {
706        return decision;
707    }
708    if lower[0] == "git" {
709        if git_command_read_only(&argv) {
710            return safe_read("git read-only command");
711        }
712        return needs_approval("git command is not classified as read-only");
713    }
714    if let Some(decision) = classify_bounded_write(&lower) {
715        return decision;
716    }
717    for prefix in READ_ONLY_PREFIXES {
718        if argv_has_prefix(&lower, prefix) {
719            return safe_read("built-in read-only command");
720        }
721    }
722    needs_approval("command is not classified as safe read-only or bounded-write")
723}
724
725/// `;`/`&&`/`|` lists are only safe when every element independently is.
726fn classify_all_safe_read(parts: &[String], kind: &str) -> ShellRiskDecision {
727    for part in parts {
728        let decision = classify_shell_command(part);
729        if decision.level == ShellRiskLevel::Blocked {
730            return decision;
731        }
732        if decision.level != ShellRiskLevel::SafeRead {
733            return needs_approval(&format!(
734                "{kind} contains a command that is not safe read-only"
735            ));
736        }
737    }
738    safe_read(&format!("{kind} of read-only commands"))
739}
740
741// --- splitters -------------------------------------------------------------
742
743/// Shared quote-aware scanner: splits `command` at unquoted occurrences of
744/// the separator. Returns `None` when the input has unbalanced quotes, a
745/// dangling escape, no separator at all, or an empty element.
746fn split_unquoted(command: &str, separator: &str) -> Option<Vec<String>> {
747    let mut parts = Vec::new();
748    let mut current = String::new();
749    let mut quote: Option<char> = None;
750    let mut escaped = false;
751    let mut saw_separator = false;
752    let runes: Vec<char> = command.trim().chars().collect();
753    let sep: Vec<char> = separator.chars().collect();
754    let mut i = 0;
755    while i < runes.len() {
756        let r = runes[i];
757        if quote == Some('\'') {
758            if r == '\'' {
759                quote = None;
760            }
761            current.push(r);
762            i += 1;
763            continue;
764        }
765        if escaped {
766            escaped = false;
767            current.push(r);
768            i += 1;
769            continue;
770        }
771        match r {
772            '\\' => {
773                if quote == Some('"') {
774                    escaped = true;
775                }
776                current.push(r);
777            }
778            '"' => {
779                if quote.is_none() {
780                    quote = Some('"');
781                } else if quote == Some('"') {
782                    quote = None;
783                }
784                current.push(r);
785            }
786            '\'' => {
787                if quote.is_none() {
788                    quote = Some('\'');
789                }
790                current.push(r);
791            }
792            _ if r == sep[0] && quote.is_none() => {
793                // Multi-char separators (`&&`) must match in full; a lone
794                // `&` stays part of the element (and later fails the simple
795                // parse, landing the whole command at NeedsApproval).
796                if sep.len() == 2 {
797                    if i + 1 >= runes.len() || runes[i + 1] != sep[1] {
798                        current.push(r);
799                        i += 1;
800                        continue;
801                    }
802                    i += 1;
803                }
804                let part = current.trim().to_string();
805                current.clear();
806                if part.is_empty() {
807                    return None;
808                }
809                parts.push(part);
810                saw_separator = true;
811            }
812            _ => current.push(r),
813        }
814        i += 1;
815    }
816    if quote.is_some() || escaped || !saw_separator {
817        return None;
818    }
819    let part = current.trim().to_string();
820    if part.is_empty() {
821        return None;
822    }
823    parts.push(part);
824    Some(parts)
825}
826
827/// Split on `;` only.
828fn split_sequence(command: &str) -> Option<Vec<String>> {
829    split_unquoted(command, ";")
830}
831
832/// Split on `&&` only. `||` is intentionally not handled: its right side
833/// runs only when the left side *fails*, so an `||` chain can't be proven
834/// read-only statically.
835fn split_and_list(command: &str) -> Option<Vec<String>> {
836    split_unquoted(command, "&&")
837}
838
839/// Split on `|`. Note `a || b` splits into an empty middle element and is
840/// rejected, falling through to the (failing) simple parse — by design.
841fn split_pipeline(command: &str) -> Option<Vec<String>> {
842    split_unquoted(command, "|")
843}
844
845// --- trailing stderr redirect ----------------------------------------------
846
847fn strip_trailing_safe_stderr_redirect(command: &str) -> Option<String> {
848    let trimmed = command.trim();
849    for redirect in ["2>&1", "2>/dev/null", "2> /dev/null"] {
850        if let Some(base) = strip_trailing_redirect(trimmed, redirect) {
851            return Some(base);
852        }
853    }
854    None
855}
856
857fn strip_trailing_redirect(command: &str, redirect: &str) -> Option<String> {
858    let base = command.strip_suffix(redirect)?;
859    if base.is_empty() || !base.ends_with([' ', '\t']) {
860        return None;
861    }
862    if !offset_outside_quotes(command, base.len()) {
863        return None;
864    }
865    let base = base.trim();
866    if base.is_empty() {
867        return None;
868    }
869    Some(base.to_string())
870}
871
872/// True when byte offset `offset` sits outside any quoted region of
873/// `command` (double-quote backslash escapes respected).
874fn offset_outside_quotes(command: &str, offset: usize) -> bool {
875    let mut quote: Option<char> = None;
876    let mut escaped = false;
877    for (i, r) in command.char_indices() {
878        if i >= offset {
879            break;
880        }
881        if quote == Some('\'') {
882            if r == '\'' {
883                quote = None;
884            }
885            continue;
886        }
887        if escaped {
888            escaped = false;
889            continue;
890        }
891        match r {
892            '\\' => {
893                if quote == Some('"') {
894                    escaped = true;
895                }
896            }
897            '"' => {
898                if quote.is_none() {
899                    quote = Some('"');
900                } else if quote == Some('"') {
901                    quote = None;
902                }
903            }
904            '\'' if quote.is_none() => {
905                quote = Some('\'');
906            }
907            _ => {}
908        }
909    }
910    quote.is_none() && !escaped
911}
912
913// --- simple-command parsing -------------------------------------------------
914
915/// Parse a command into argv, refusing anything that isn't a plain word
916/// list: any unquoted shell metacharacter (globs, redirects, expansion,
917/// subshells, comments) fails the parse and the command defaults to
918/// NeedsApproval.
919fn parse_simple_command(command: &str) -> Option<Vec<String>> {
920    let mut argv = Vec::new();
921    let mut word = String::new();
922    let mut in_word = false;
923    let mut quote: Option<char> = None;
924    for r in command.trim().chars() {
925        match quote {
926            Some('\'') => {
927                if r == '\'' {
928                    quote = None;
929                    continue;
930                }
931                word.push(r);
932                continue;
933            }
934            Some('"') => {
935                match r {
936                    '"' => quote = None,
937                    // Expansion inside double quotes can run arbitrary code.
938                    '\\' | '$' | '`' => return None,
939                    _ => word.push(r),
940                }
941                continue;
942            }
943            _ => {}
944        }
945        match r {
946            ' ' | '\t' => {
947                if in_word {
948                    argv.push(std::mem::take(&mut word));
949                    in_word = false;
950                }
951            }
952            '\'' | '"' => {
953                quote = Some(r);
954                in_word = true;
955            }
956            _ if rejected_simple_command_char(r) => return None,
957            _ => {
958                in_word = true;
959                word.push(r);
960            }
961        }
962    }
963    if quote.is_some() {
964        return None;
965    }
966    if in_word {
967        argv.push(word);
968    }
969    if argv.is_empty() {
970        None
971    } else {
972        Some(argv)
973    }
974}
975
976fn rejected_simple_command_char(r: char) -> bool {
977    matches!(
978        r,
979        '\\' | '$'
980            | '`'
981            | ';'
982            | '|'
983            | '&'
984            | '<'
985            | '>'
986            | '\n'
987            | '\r'
988            | '('
989            | ')'
990            | '{'
991            | '}'
992            | '#'
993            | '*'
994            | '?'
995            | '['
996            | ']'
997    )
998}
999
1000// --- unsafe argument detection ----------------------------------------------
1001
1002fn has_unsafe_args(argv: &[String]) -> bool {
1003    for field in &argv[1..] {
1004        if field.contains(['$', '`', '&', '<', '>', '\n', '\r']) {
1005            return true;
1006        }
1007    }
1008    if argv_has_prefix(argv, "find") {
1009        for field in argv {
1010            match field.as_str() {
1011                "-delete" | "-exec" | "-execdir" | "-ok" | "-okdir" | "-fls" => return true,
1012                _ => {}
1013            }
1014            if field.starts_with("-fprint") {
1015                return true;
1016            }
1017        }
1018    } else if argv_has_prefix(argv, "git diff")
1019        || argv_has_prefix(argv, "git show")
1020        || argv_has_prefix(argv, "git log")
1021    {
1022        for field in argv {
1023            if field == "--output"
1024                || field.starts_with("--output=")
1025                || field == "--ext-diff"
1026                || field == "--external-diff"
1027                || field == "--textconv"
1028            {
1029                return true;
1030            }
1031        }
1032    } else if argv_has_prefix(argv, "rg") {
1033        for field in argv {
1034            if field == "--pre" || field.starts_with("--pre=") {
1035                return true;
1036            }
1037        }
1038    }
1039    for field in argv {
1040        match field.as_str() {
1041            "--fix" | "--write" | "--update" | "--update-snapshot" | "--updatesnapshot" => {
1042                return true
1043            }
1044            _ => {}
1045        }
1046        if field.starts_with("--fix=")
1047            || field.starts_with("--write=")
1048            || field.starts_with("--update=")
1049            || field.starts_with("--update-snapshot=")
1050            || field.starts_with("--updatesnapshot=")
1051        {
1052            return true;
1053        }
1054    }
1055    if (argv_has_prefix(argv, "npx jest") || argv_has_prefix(argv, "npx vitest"))
1056        && argv.iter().any(|a| a == "-u")
1057    {
1058        return true;
1059    }
1060    false
1061}
1062
1063/// Bounded `make` targets must be invoked bare (`make test`), with no extra
1064/// targets or variable overrides.
1065const MAKE_BOUNDED_TARGETS: &[&str] =
1066    &["build", "test", "check", "lint", "fmt", "fmt-check", "vet"];
1067
1068fn make_bounded_target_has_extra_args(argv: &[String]) -> bool {
1069    if argv.len() < 2 || argv[0] != "make" {
1070        return false;
1071    }
1072    if MAKE_BOUNDED_TARGETS.contains(&argv[1].as_str()) {
1073        return argv.len() != 2;
1074    }
1075    false
1076}
1077
1078// --- builtin read-only commands with option allowlists -----------------------
1079
1080fn classify_builtin_read_only(argv: &[String], lower: &[String]) -> Option<ShellRiskDecision> {
1081    match lower[0].as_str() {
1082        "date" => Some(classify_date(argv, lower)),
1083        "uname" => Some(classify_uname(lower)),
1084        "whoami" => Some(classify_whoami(lower)),
1085        "id" => Some(classify_id(lower)),
1086        "which" => Some(classify_command_lookup(&lower[1..])),
1087        "command" => {
1088            if lower.len() >= 2 && lower[1] == "-v" {
1089                Some(classify_command_lookup(&lower[2..]))
1090            } else {
1091                None
1092            }
1093        }
1094        "sed" => Some(classify_sed_read_only(argv)),
1095        "sort" => Some(classify_sort(argv)),
1096        "uniq" => Some(classify_uniq(argv)),
1097        "printf" => Some(classify_printf(lower)),
1098        _ => None,
1099    }
1100}
1101
1102fn classify_date(argv: &[String], lower: &[String]) -> ShellRiskDecision {
1103    const FLAGS_WITH_VALUES: &[&str] = &["-d", "--date", "-r", "--reference", "--rfc-3339"];
1104    const SAFE_NO_VALUE_FLAGS: &[&str] = &[
1105        "-u",
1106        "--utc",
1107        "--universal",
1108        "-I",
1109        "-R",
1110        "--iso-8601",
1111        "--rfc-email",
1112        "--debug",
1113        "--help",
1114        "--version",
1115    ];
1116    let mut i = 1;
1117    while i < lower.len() {
1118        let raw = argv[i].as_str();
1119        let arg = lower[i].as_str();
1120        if raw == "-s"
1121            || arg == "--set"
1122            || arg.starts_with("--set=")
1123            || raw == "-f"
1124            || arg == "--file"
1125            || arg.starts_with("--file=")
1126        {
1127            return needs_approval("date can set system time or read batch dates with this option");
1128        }
1129        if raw.starts_with('+') {
1130            i += 1;
1131            continue;
1132        }
1133        if FLAGS_WITH_VALUES.contains(&raw)
1134            || (raw.starts_with("--") && FLAGS_WITH_VALUES.contains(&arg))
1135        {
1136            i += 1;
1137            if i >= lower.len() {
1138                return needs_approval("date flag requires a value");
1139            }
1140            i += 1;
1141            continue;
1142        }
1143        if arg.starts_with("--date=")
1144            || arg.starts_with("--reference=")
1145            || arg.starts_with("--iso-8601=")
1146            || arg.starts_with("--rfc-3339=")
1147        {
1148            i += 1;
1149            continue;
1150        }
1151        if SAFE_NO_VALUE_FLAGS.contains(&raw) || SAFE_NO_VALUE_FLAGS.contains(&arg) {
1152            i += 1;
1153            continue;
1154        }
1155        if raw.starts_with('-') {
1156            return needs_approval("date option is not on the safe display allowlist");
1157        }
1158        return needs_approval("date positional arguments can set system time");
1159    }
1160    safe_read("date display command")
1161}
1162
1163fn classify_uname(lower: &[String]) -> ShellRiskDecision {
1164    const SAFE_LONG: &[&str] = &[
1165        "--all",
1166        "--kernel-name",
1167        "--nodename",
1168        "--kernel-release",
1169        "--kernel-version",
1170        "--machine",
1171        "--processor",
1172        "--hardware-platform",
1173        "--operating-system",
1174        "--help",
1175        "--version",
1176    ];
1177    for arg in &lower[1..] {
1178        if SAFE_LONG.contains(&arg.as_str()) {
1179            continue;
1180        }
1181        if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") {
1182            if arg[1..].chars().all(|r| "asnrvmpio".contains(r)) {
1183                continue;
1184            }
1185            return needs_approval("uname option is not on the safe display allowlist");
1186        }
1187        return needs_approval("uname only supports safe display flags in auto-allow");
1188    }
1189    safe_read("uname display command")
1190}
1191
1192fn classify_whoami(lower: &[String]) -> ShellRiskDecision {
1193    for arg in &lower[1..] {
1194        if arg != "--help" && arg != "--version" {
1195            return needs_approval("whoami only supports help/version args in auto-allow");
1196        }
1197    }
1198    safe_read("whoami display command")
1199}
1200
1201fn classify_id(lower: &[String]) -> ShellRiskDecision {
1202    const SAFE_LONG: &[&str] = &[
1203        "--user",
1204        "--group",
1205        "--groups",
1206        "--name",
1207        "--real",
1208        "--zero",
1209        "--help",
1210        "--version",
1211    ];
1212    for arg in &lower[1..] {
1213        if SAFE_LONG.contains(&arg.as_str()) || is_command_name(arg) {
1214            continue;
1215        }
1216        if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") {
1217            if arg[1..].chars().all(|r| "uggnrz".contains(r)) {
1218                continue;
1219            }
1220            return needs_approval("id option is not on the safe display allowlist");
1221        }
1222        return needs_approval("id argument is not safe for auto-allow");
1223    }
1224    safe_read("id display command")
1225}
1226
1227fn classify_command_lookup(args: &[String]) -> ShellRiskDecision {
1228    if args.is_empty() {
1229        return needs_approval("command lookup requires at least one command name");
1230    }
1231    for arg in args {
1232        if !is_command_name(arg) {
1233            return needs_approval("command lookup operands must be simple command names");
1234        }
1235    }
1236    safe_read("command lookup")
1237}
1238
1239fn classify_printf(lower: &[String]) -> ShellRiskDecision {
1240    for arg in &lower[1..] {
1241        if arg.contains('/') && arg.starts_with('-') {
1242            return needs_approval("printf option is not on the safe display allowlist");
1243        }
1244    }
1245    safe_read("printf display command")
1246}
1247
1248fn is_command_name(v: &str) -> bool {
1249    let v = v.trim();
1250    if v.is_empty() || v.contains('/') || v.starts_with('-') {
1251        return false;
1252    }
1253    v.chars()
1254        .all(|r| r.is_alphanumeric() || matches!(r, '_' | '.' | '-' | '+'))
1255}
1256
1257// --- sed: stream-only substitution / range print -----------------------------
1258
1259fn classify_sed_read_only(argv: &[String]) -> ShellRiskDecision {
1260    if sed_print_range_read_only(argv) {
1261        return safe_read("sed range print command");
1262    }
1263    if sed_substitution_read_only(argv) {
1264        return safe_read("sed stream substitution command");
1265    }
1266    needs_approval("sed command is not classified as read-only")
1267}
1268
1269fn sed_substitution_read_only(argv: &[String]) -> bool {
1270    if argv.len() < 2 || argv[0] != "sed" {
1271        return false;
1272    }
1273    let mut i = 1;
1274    while i < argv.len() {
1275        match argv[i].as_str() {
1276            "-E" | "-r" | "--regexp-extended" | "-n" | "--quiet" | "--silent" => i += 1,
1277            "--" => {
1278                i += 1;
1279                break;
1280            }
1281            _ => break,
1282        }
1283    }
1284    if i >= argv.len() || !sed_substitution_script_read_only(&argv[i]) {
1285        return false;
1286    }
1287    i += 1;
1288    // Remaining operands must be plain input files, not more options.
1289    argv[i..].iter().all(|a| !a.starts_with('-'))
1290}
1291
1292fn sed_substitution_script_read_only(script: &str) -> bool {
1293    if script.is_empty() || !script.starts_with('s') {
1294        return false;
1295    }
1296    let runes: Vec<char> = script.chars().collect();
1297    if runes.len() < 4 {
1298        return false;
1299    }
1300    let delim = runes[1];
1301    if delim == '\\' || delim == '\n' || delim == '\r' {
1302        return false;
1303    }
1304    let mut parts = 0;
1305    let mut escaped = false;
1306    let mut i = 2;
1307    while i < runes.len() {
1308        let r = runes[i];
1309        if escaped {
1310            escaped = false;
1311            i += 1;
1312            continue;
1313        }
1314        if r == '\\' {
1315            escaped = true;
1316            i += 1;
1317            continue;
1318        }
1319        if r == delim {
1320            parts += 1;
1321            if parts == 2 {
1322                let flags: String = runes[i + 1..].iter().collect();
1323                return sed_substitution_flags_read_only(&flags);
1324            }
1325        }
1326        i += 1;
1327    }
1328    false
1329}
1330
1331fn sed_substitution_flags_read_only(flags: &str) -> bool {
1332    flags
1333        .chars()
1334        .all(|r| r.is_ascii_digit() || matches!(r, 'g' | 'p' | 'I' | 'i' | 'M' | 'm'))
1335}
1336
1337fn sed_print_range_read_only(argv: &[String]) -> bool {
1338    if argv.len() < 3 || argv[0] != "sed" {
1339        return false;
1340    }
1341    let mut i = 1;
1342    let mut saw_quiet = false;
1343    while i < argv.len() {
1344        match argv[i].as_str() {
1345            "-n" | "--quiet" | "--silent" => {
1346                saw_quiet = true;
1347                i += 1;
1348            }
1349            "--" => {
1350                i += 1;
1351                break;
1352            }
1353            _ => break,
1354        }
1355    }
1356    if !saw_quiet || i >= argv.len() || !sed_range_print_script(&argv[i]) {
1357        return false;
1358    }
1359    i += 1;
1360    argv[i..].iter().all(|a| !a.starts_with('-'))
1361}
1362
1363fn sed_range_print_script(script: &str) -> bool {
1364    let Some(addr) = script.strip_suffix('p') else {
1365        return false;
1366    };
1367    if script.is_empty() {
1368        return false;
1369    }
1370    let parts: Vec<&str> = addr.split(',').collect();
1371    if parts.len() > 2 {
1372        return false;
1373    }
1374    for part in parts {
1375        if part == "$" {
1376            continue;
1377        }
1378        if part.is_empty() || !part.chars().all(|r| r.is_ascii_digit()) {
1379            return false;
1380        }
1381    }
1382    true
1383}
1384
1385// --- sort / uniq: display-only option allowlists -----------------------------
1386
1387fn classify_sort(argv: &[String]) -> ShellRiskDecision {
1388    let mut end_options = false;
1389    let mut i = 1;
1390    while i < argv.len() {
1391        let arg = argv[i].as_str();
1392        if end_options || !arg.starts_with('-') || arg == "-" {
1393            i += 1;
1394            continue;
1395        }
1396        if arg == "--" {
1397            end_options = true;
1398            i += 1;
1399            continue;
1400        }
1401        if arg.starts_with("--") {
1402            if arg == "--output" || arg.starts_with("--output=") {
1403                return needs_approval(
1404                    "sort can write to an explicit output path with this option",
1405                );
1406            }
1407            if arg == "--compress-program" || arg.starts_with("--compress-program=") {
1408                return needs_approval("sort can execute an external compressor with this option");
1409            }
1410            if arg == "--temporary-directory" || arg.starts_with("--temporary-directory=") {
1411                return needs_approval(
1412                    "sort can write temporary files outside the input stream with this option",
1413                );
1414            }
1415            if sort_long_option_consumes_next(arg) && !arg.contains('=') {
1416                i += 1;
1417            }
1418            if !sort_long_option_safe(arg) {
1419                return needs_approval("sort option is not on the safe display allowlist");
1420            }
1421            i += 1;
1422            continue;
1423        }
1424        if !sort_short_options_safe(arg) {
1425            return needs_approval("sort option is not on the safe display allowlist");
1426        }
1427        i += 1;
1428    }
1429    safe_read("sort display command")
1430}
1431
1432fn sort_long_option_safe(arg: &str) -> bool {
1433    let name = arg.split('=').next().unwrap_or(arg);
1434    matches!(
1435        name,
1436        "--ignore-leading-blanks"
1437            | "--dictionary-order"
1438            | "--ignore-nonprinting"
1439            | "--ignore-case"
1440            | "--general-numeric-sort"
1441            | "--human-numeric-sort"
1442            | "--month-sort"
1443            | "--numeric-sort"
1444            | "--reverse"
1445            | "--unique"
1446            | "--stable"
1447            | "--version-sort"
1448            | "--zero-terminated"
1449            | "--check"
1450            | "--key"
1451            | "--field-separator"
1452    )
1453}
1454
1455fn sort_long_option_consumes_next(arg: &str) -> bool {
1456    let name = arg.split('=').next().unwrap_or(arg);
1457    matches!(name, "--key" | "--field-separator")
1458}
1459
1460/// Short sort options: display-only flags pass; `-o` (output file) and `-T`
1461/// (temp dir) fail; `-k`/`-t` take a key/separator value, which is harmless.
1462fn sort_short_options_safe(arg: &str) -> bool {
1463    let chars: Vec<char> = arg.chars().collect();
1464    for r in chars.iter().skip(1) {
1465        match r {
1466            'b' | 'c' | 'C' | 'd' | 'f' | 'g' | 'h' | 'i' | 'M' | 'm' | 'n' | 'r' | 's' | 'u'
1467            | 'V' | 'z' => continue,
1468            'k' | 't' => return true,
1469            _ => return false,
1470        }
1471    }
1472    chars.len() > 1
1473}
1474
1475fn classify_uniq(argv: &[String]) -> ShellRiskDecision {
1476    let mut operands = 0;
1477    let mut end_options = false;
1478    let mut i = 1;
1479    while i < argv.len() {
1480        let arg = argv[i].as_str();
1481        if end_options || !arg.starts_with('-') || arg == "-" {
1482            operands += 1;
1483            if operands > 1 {
1484                return needs_approval(
1485                    "uniq can write to an output file when given a second operand",
1486                );
1487            }
1488            i += 1;
1489            continue;
1490        }
1491        if arg == "--" {
1492            end_options = true;
1493            i += 1;
1494            continue;
1495        }
1496        if arg.starts_with("--") {
1497            if uniq_long_option_consumes_next(arg) && !arg.contains('=') {
1498                i += 1;
1499            }
1500            if !uniq_long_option_safe(arg) {
1501                return needs_approval("uniq option is not on the safe display allowlist");
1502            }
1503            i += 1;
1504            continue;
1505        }
1506        let Some(consumes_next) = uniq_short_options_safe(arg) else {
1507            return needs_approval("uniq option is not on the safe display allowlist");
1508        };
1509        if consumes_next {
1510            i += 1;
1511        }
1512        i += 1;
1513    }
1514    safe_read("uniq display command")
1515}
1516
1517fn uniq_long_option_safe(arg: &str) -> bool {
1518    let name = arg.split('=').next().unwrap_or(arg);
1519    matches!(
1520        name,
1521        "--count"
1522            | "--repeated"
1523            | "--all-repeated"
1524            | "--unique"
1525            | "--ignore-case"
1526            | "--zero-terminated"
1527            | "--group"
1528            | "--skip-fields"
1529            | "--skip-chars"
1530            | "--check-chars"
1531    )
1532}
1533
1534fn uniq_long_option_consumes_next(arg: &str) -> bool {
1535    let name = arg.split('=').next().unwrap_or(arg);
1536    matches!(name, "--skip-fields" | "--skip-chars" | "--check-chars")
1537}
1538
1539/// Returns `Some(consumes_next)` when every flag in the cluster is safe,
1540/// `None` otherwise. `-f`/`-s`/`-w` take a value and must end the cluster.
1541fn uniq_short_options_safe(arg: &str) -> Option<bool> {
1542    let chars: Vec<char> = arg.chars().collect();
1543    for (idx, r) in chars.iter().enumerate().skip(1) {
1544        match r {
1545            'c' | 'd' | 'u' | 'i' | 'z' => continue,
1546            'f' | 's' | 'w' => {
1547                if idx == chars.len() - 1 {
1548                    return Some(true);
1549                }
1550                return Some(false);
1551            }
1552            _ => return None,
1553        }
1554    }
1555    if chars.len() > 1 {
1556        Some(false)
1557    } else {
1558        None
1559    }
1560}
1561
1562// --- git read-only whitelist --------------------------------------------------
1563
1564fn git_command_read_only(argv: &[String]) -> bool {
1565    if argv.len() < 2 || argv[0] != "git" {
1566        return false;
1567    }
1568    if argv[1..].iter().any(|f| arg_contains_unsafe_meta(f)) {
1569        return false;
1570    }
1571    let mut subcommand_index = 1;
1572    while subcommand_index < argv.len() {
1573        let arg = argv[subcommand_index].as_str();
1574        if arg == "-c" || arg == "--config-env" || arg.starts_with("--config-env=") {
1575            return false;
1576        }
1577        if arg == "-C" {
1578            if subcommand_index + 1 >= argv.len()
1579                || !git_relative_path_allowed(&argv[subcommand_index + 1], false)
1580            {
1581                return false;
1582            }
1583            subcommand_index += 2;
1584            continue;
1585        }
1586        if let Some(path) = arg.strip_prefix("-C") {
1587            if !git_relative_path_allowed(path, false) {
1588                return false;
1589            }
1590            subcommand_index += 1;
1591            continue;
1592        }
1593        if arg.starts_with("-c") {
1594            // Inline config can change command behaviour arbitrarily.
1595            return false;
1596        }
1597        if arg.starts_with('-') {
1598            return false;
1599        }
1600        break;
1601    }
1602    if subcommand_index >= argv.len() {
1603        return false;
1604    }
1605    let subcommand = argv[subcommand_index].as_str();
1606    let args = &argv[subcommand_index + 1..];
1607    match subcommand {
1608        "status" | "rev-parse" => git_args_are_read_only(args),
1609        "symbolic-ref" => git_args_are_read_only(args) && git_symbolic_ref_args_read_only(args),
1610        "branch" => git_args_are_read_only(args) && git_branch_args_read_only(args),
1611        "remote" => git_args_are_read_only(args) && git_remote_args_read_only(args),
1612        "config" => !args.is_empty() && args[0] == "--get" && git_args_are_read_only(args),
1613        "diff" => git_args_are_read_only(args) && git_diff_args_read_only(args),
1614        "show" | "log" | "shortlog" | "ls-files" => git_args_are_read_only(args),
1615        _ => false,
1616    }
1617}
1618
1619fn git_symbolic_ref_args_read_only(args: &[String]) -> bool {
1620    if args.is_empty() {
1621        return false;
1622    }
1623    let mut refs = 0;
1624    for arg in args {
1625        match arg.as_str() {
1626            "--short" | "-q" | "--quiet" => continue,
1627            _ => {
1628                if arg.starts_with('-') {
1629                    return false;
1630                }
1631                refs += 1;
1632            }
1633        }
1634    }
1635    refs == 1
1636}
1637
1638fn git_branch_args_read_only(args: &[String]) -> bool {
1639    let mut saw_list = false;
1640    for arg in args {
1641        match arg.as_str() {
1642            "--show-current" | "--all" | "--remotes" | "--list" | "--verbose" | "--color"
1643            | "--no-color" | "-a" | "-r" | "-l" | "-v" | "-vv" => {
1644                if arg == "--list" || arg == "-l" {
1645                    saw_list = true;
1646                }
1647                continue;
1648            }
1649            _ => {
1650                if arg.starts_with("--color=") {
1651                    continue;
1652                }
1653                if saw_list && !arg.starts_with('-') {
1654                    continue;
1655                }
1656                return false;
1657            }
1658        }
1659    }
1660    true
1661}
1662
1663fn git_remote_args_read_only(args: &[String]) -> bool {
1664    if args.is_empty() {
1665        return true;
1666    }
1667    if args.len() == 1 && args[0] == "-v" {
1668        return true;
1669    }
1670    args.len() >= 2 && args[0] == "get-url"
1671}
1672
1673fn git_args_are_read_only(args: &[String]) -> bool {
1674    for arg in args {
1675        if arg.starts_with("--output=") {
1676            return false;
1677        }
1678        match arg.as_str() {
1679            "--output" | "--ext-diff" | "--external-diff" | "--textconv" => return false,
1680            _ => {}
1681        }
1682    }
1683    true
1684}
1685
1686fn git_diff_args_read_only(args: &[String]) -> bool {
1687    if !args.iter().any(|a| a == "--no-index") {
1688        return true;
1689    }
1690    let paths = git_diff_no_index_paths(args);
1691    if paths.len() != 2 {
1692        return false;
1693    }
1694    git_relative_path_allowed(paths[0], true) && git_relative_path_allowed(paths[1], false)
1695}
1696
1697fn git_diff_no_index_paths(args: &[String]) -> Vec<&String> {
1698    let mut paths = Vec::with_capacity(2);
1699    let mut end_of_options = false;
1700    let mut i = 0;
1701    while i < args.len() {
1702        let arg = &args[i];
1703        if !end_of_options && arg == "--" {
1704            end_of_options = true;
1705            i += 1;
1706            continue;
1707        }
1708        if !end_of_options && arg.starts_with('-') {
1709            if git_diff_flag_consumes_next_arg(arg) && !arg.contains('=') {
1710                i += 1;
1711            }
1712            i += 1;
1713            continue;
1714        }
1715        paths.push(arg);
1716        i += 1;
1717    }
1718    paths
1719}
1720
1721fn git_diff_flag_consumes_next_arg(arg: &str) -> bool {
1722    // Conservative list of read-only git diff flags that consume the next
1723    // arg. If this list misses an option, --no-index parsing may reject the
1724    // command rather than accidentally treating an option value as a path.
1725    matches!(
1726        arg,
1727        "--relative"
1728            | "--diff-filter"
1729            | "--word-diff-regex"
1730            | "--color-words"
1731            | "--ws-error-highlight"
1732            | "--abbrev"
1733            | "--break-rewrites"
1734            | "--find-renames"
1735            | "--find-copies"
1736            | "--diff-algorithm"
1737            | "--inter-hunk-context"
1738            | "-S"
1739            | "-G"
1740            | "-O"
1741    )
1742}
1743
1744fn git_relative_path_allowed(path: &str, allow_dev_null: bool) -> bool {
1745    let path = path.trim();
1746    if path.is_empty() {
1747        return false;
1748    }
1749    if allow_dev_null && path == "/dev/null" {
1750        return true;
1751    }
1752    if path.starts_with('/') || path.starts_with('~') || path.starts_with('-') {
1753        return false;
1754    }
1755    path.split('/')
1756        .all(|part| !part.is_empty() && part != "." && part != "..")
1757}
1758
1759fn arg_contains_unsafe_meta(arg: &str) -> bool {
1760    arg.contains(['$', '`', ';', '&', '|', '<', '>', '\n', '\r'])
1761}
1762
1763// --- bounded-write build/test commands ----------------------------------------
1764
1765fn classify_bounded_write(lower: &[String]) -> Option<ShellRiskDecision> {
1766    match lower[0].as_str() {
1767        "go" => {
1768            if lower.len() >= 2 {
1769                match lower[1].as_str() {
1770                    "test" => {
1771                        if has_any_flag_prefix(&lower[2..], &["-exec", "-toolexec"]) {
1772                            return Some(needs_approval(
1773                                "go test can run an execution wrapper with this option",
1774                            ));
1775                        }
1776                        if has_any_flag_prefix(&lower[2..], &["-c"]) {
1777                            return Some(needs_approval("go test -c emits a test binary"));
1778                        }
1779                        if has_any_flag_prefix(
1780                            &lower[2..],
1781                            &[
1782                                "-coverprofile",
1783                                "-cpuprofile",
1784                                "-memprofile",
1785                                "-blockprofile",
1786                                "-mutexprofile",
1787                                "-trace",
1788                                "-o",
1789                            ],
1790                        ) {
1791                            return Some(needs_approval(
1792                                "go test writes to an explicit output path with this option",
1793                            ));
1794                        }
1795                        return Some(bounded_write(
1796                            "go test may write build and test cache files".into(),
1797                        ));
1798                    }
1799                    "build" => {
1800                        if has_any_flag_prefix(&lower[2..], &["-o"]) {
1801                            return Some(needs_approval(
1802                                "go build writes to an explicit output path with this option",
1803                            ));
1804                        }
1805                        return Some(needs_approval("go build may emit a workspace binary"));
1806                    }
1807                    "vet" => {
1808                        return Some(bounded_write("go vet may write build cache files".into()))
1809                    }
1810                    _ => {}
1811                }
1812            }
1813        }
1814        "make" => {
1815            if lower.len() == 2 && MAKE_BOUNDED_TARGETS.contains(&lower[1].as_str()) {
1816                return Some(bounded_write(format!(
1817                    "make {} may write project-local build or test artifacts",
1818                    lower[1]
1819                )));
1820            }
1821        }
1822        "cargo" => {
1823            if lower.len() >= 2 {
1824                match lower[1].as_str() {
1825                    "build" | "test" | "check" | "clippy" | "fmt" => {
1826                        if has_any_flag_prefix(&lower[2..], &["--target-dir"]) {
1827                            return Some(needs_approval(
1828                                "cargo writes to an explicit target directory with this option",
1829                            ));
1830                        }
1831                        return Some(bounded_write(format!(
1832                            "cargo {} may write target build artifacts",
1833                            lower[1]
1834                        )));
1835                    }
1836                    _ => {}
1837                }
1838            }
1839        }
1840        "npm" | "pnpm" => {
1841            if lower.len() >= 2 {
1842                if lower[1] == "test" {
1843                    return Some(bounded_write(format!(
1844                        "{} test may write project-local test artifacts",
1845                        lower[0]
1846                    )));
1847                }
1848                if lower.len() >= 3 && lower[1] == "run" && npm_bounded_script(&lower[2]) {
1849                    return Some(bounded_write(format!(
1850                        "{} run {} may write project-local build or test artifacts",
1851                        lower[0], lower[2]
1852                    )));
1853                }
1854            }
1855        }
1856        "npx" => {
1857            if lower.len() >= 2 {
1858                match lower[1].as_str() {
1859                    "jest" | "vitest" => {
1860                        if has_known_test_output_flag(&lower[2..]) {
1861                            return Some(needs_approval(
1862                                "test runner writes to an explicit output path with this option",
1863                            ));
1864                        }
1865                        return Some(bounded_write(format!(
1866                            "npx {} may write project-local test artifacts",
1867                            lower[1]
1868                        )));
1869                    }
1870                    "tsc" if lower.len() >= 3 && lower[2] == "--noemit" => {
1871                        return Some(bounded_write(
1872                            "npx tsc --noEmit may write compiler cache files".into(),
1873                        ));
1874                    }
1875                    _ => {}
1876                }
1877            }
1878        }
1879        "pytest" => {
1880            if has_known_test_output_flag(&lower[1..]) {
1881                return Some(needs_approval(
1882                    "pytest writes to an explicit output path with this option",
1883                ));
1884            }
1885            return Some(bounded_write(
1886                "pytest may write project-local test artifacts".into(),
1887            ));
1888        }
1889        "python" | "python3" => {
1890            if lower.len() >= 3 && lower[1] == "-m" && lower[2] == "pytest" {
1891                if has_known_test_output_flag(&lower[3..]) {
1892                    return Some(needs_approval(
1893                        "pytest writes to an explicit output path with this option",
1894                    ));
1895                }
1896                return Some(bounded_write(format!(
1897                    "{} -m pytest may write project-local test artifacts",
1898                    lower[0]
1899                )));
1900            }
1901        }
1902        "deno" | "bun" if lower.len() >= 2 && lower[1] == "test" => {
1903            return Some(bounded_write(format!(
1904                "{} test may write project-local test artifacts",
1905                lower[0]
1906            )));
1907        }
1908        _ => {}
1909    }
1910    None
1911}
1912
1913fn has_known_test_output_flag(args: &[String]) -> bool {
1914    args.iter().any(|arg| {
1915        arg == "--outputfile"
1916            || arg == "--output-file"
1917            || arg.starts_with("--outputfile=")
1918            || arg.starts_with("--output-file=")
1919            || arg == "--junitxml"
1920            || arg == "--junit-xml"
1921            || arg.starts_with("--junitxml=")
1922            || arg.starts_with("--junit-xml=")
1923            || arg == "--html"
1924            || arg.starts_with("--html=")
1925            || arg.starts_with("--cov-report=xml:")
1926            || arg.starts_with("--cov-report=html:")
1927            || arg.starts_with("--cov-report=lcov:")
1928            || arg.starts_with("--cov-report=json:")
1929    })
1930}
1931
1932fn has_any_flag_prefix(args: &[String], prefixes: &[&str]) -> bool {
1933    args.iter().any(|arg| {
1934        prefixes
1935            .iter()
1936            .any(|prefix| arg == prefix || arg.starts_with(&format!("{prefix}=")))
1937    })
1938}
1939
1940fn npm_bounded_script(script: &str) -> bool {
1941    matches!(script, "build" | "test" | "lint" | "typecheck")
1942}
1943
1944fn argv_has_prefix(argv: &[String], prefix: &str) -> bool {
1945    let prefix_argv: Vec<&str> = prefix.split_whitespace().collect();
1946    if argv.len() < prefix_argv.len() {
1947        return false;
1948    }
1949    prefix_argv
1950        .iter()
1951        .enumerate()
1952        .all(|(i, want)| argv[i] == *want)
1953}
1954
1955#[cfg(test)]
1956mod tests {
1957    use super::*;
1958
1959    fn level(command: &str) -> ShellRiskLevel {
1960        classify_shell_command(command).level
1961    }
1962
1963    // --- safe reads ---------------------------------------------------------
1964
1965    #[test]
1966    fn read_only_commands_are_safe() {
1967        for cmd in [
1968            "ls -la",
1969            "pwd",
1970            "cat src/main.rs",
1971            "grep -rn pattern src",
1972            "rg TODO",
1973            "head -n 20 file.txt",
1974            "wc -l file.txt",
1975            "which cargo",
1976            "uname -a",
1977            "whoami",
1978            "date -u",
1979            "printf hello",
1980            "go version",
1981            "rustc --version",
1982        ] {
1983            assert_eq!(level(cmd), ShellRiskLevel::SafeRead, "command: {cmd}");
1984        }
1985    }
1986
1987    #[test]
1988    fn pipelines_of_read_only_commands_are_safe() {
1989        assert_eq!(
1990            level("cat file.txt | grep foo | wc -l"),
1991            ShellRiskLevel::SafeRead
1992        );
1993        assert_eq!(level("ls && pwd"), ShellRiskLevel::SafeRead);
1994        assert_eq!(level("pwd; ls"), ShellRiskLevel::SafeRead);
1995    }
1996
1997    #[test]
1998    fn trailing_stderr_redirect_is_transparent() {
1999        assert_eq!(level("ls -la 2>/dev/null"), ShellRiskLevel::SafeRead);
2000        assert_eq!(level("cat file 2>&1"), ShellRiskLevel::SafeRead);
2001    }
2002
2003    #[test]
2004    fn git_read_only_commands_are_safe() {
2005        for cmd in [
2006            "git status",
2007            "git log --oneline",
2008            "git diff HEAD~1",
2009            "git branch --show-current",
2010            "git remote -v",
2011            "git config --get user.name",
2012        ] {
2013            assert_eq!(level(cmd), ShellRiskLevel::SafeRead, "command: {cmd}");
2014        }
2015    }
2016
2017    #[test]
2018    fn git_mutating_commands_need_approval() {
2019        for cmd in [
2020            "git push origin main",
2021            "git commit -m x",
2022            "git checkout -b f",
2023            "git diff --output=/tmp/d.patch",
2024            "git -c core.editor=vim log",
2025        ] {
2026            assert_eq!(level(cmd), ShellRiskLevel::NeedsApproval, "command: {cmd}");
2027        }
2028    }
2029
2030    #[test]
2031    fn sed_stream_substitution_is_safe_but_in_place_is_not() {
2032        assert_eq!(level("sed s/foo/bar/g file.txt"), ShellRiskLevel::SafeRead);
2033        assert_eq!(level("sed -n 1,20p file.txt"), ShellRiskLevel::SafeRead);
2034        assert_eq!(
2035            level("sed -i s/foo/bar/ file.txt"),
2036            ShellRiskLevel::NeedsApproval
2037        );
2038    }
2039
2040    #[test]
2041    fn sort_uniq_display_safe_output_flags_not() {
2042        assert_eq!(level("sort -u file.txt"), ShellRiskLevel::SafeRead);
2043        assert_eq!(level("uniq -c file.txt"), ShellRiskLevel::SafeRead);
2044        assert_eq!(
2045            level("sort -o out.txt file.txt"),
2046            ShellRiskLevel::NeedsApproval
2047        );
2048        assert_eq!(
2049            level("uniq file.txt out.txt"),
2050            ShellRiskLevel::NeedsApproval
2051        );
2052    }
2053
2054    // --- bounded writes -------------------------------------------------------
2055
2056    #[test]
2057    fn build_test_commands_are_bounded_writes() {
2058        for cmd in [
2059            "cargo test",
2060            "cargo check",
2061            "cargo clippy",
2062            "go test ./...",
2063            "go vet ./...",
2064            "npm test",
2065            "pnpm run build",
2066            "pytest",
2067            "python -m pytest tests",
2068            "make test",
2069        ] {
2070            assert_eq!(level(cmd), ShellRiskLevel::BoundedWrite, "command: {cmd}");
2071        }
2072    }
2073
2074    #[test]
2075    fn bounded_write_with_explicit_output_needs_approval() {
2076        for cmd in [
2077            "go test -coverprofile=cover.out ./...",
2078            "cargo build --target-dir /tmp/x",
2079            "pytest --junitxml=report.xml",
2080            "make test EXTRA=1",
2081        ] {
2082            assert_eq!(level(cmd), ShellRiskLevel::NeedsApproval, "command: {cmd}");
2083        }
2084    }
2085
2086    // --- needs approval (default) ---------------------------------------------
2087
2088    #[test]
2089    fn unparseable_or_unknown_commands_need_approval() {
2090        for cmd in [
2091            "curl https://example.com -o out.html",
2092            "echo $(whoami)",
2093            "ls > listing.txt",
2094            "ls *.rs",
2095            "foo || bar",
2096            "rm file.txt",
2097            "npm install",
2098            "pip install requests",
2099        ] {
2100            assert_eq!(level(cmd), ShellRiskLevel::NeedsApproval, "command: {cmd}");
2101        }
2102    }
2103
2104    #[test]
2105    fn unsafe_expansion_args_need_approval() {
2106        assert_eq!(level("echo `id`"), ShellRiskLevel::NeedsApproval);
2107        assert_eq!(
2108            level("find . -name x -delete"),
2109            ShellRiskLevel::NeedsApproval
2110        );
2111        assert_eq!(level("find . -exec rm {} +"), ShellRiskLevel::NeedsApproval);
2112        assert_eq!(level("rg --pre cat TODO"), ShellRiskLevel::NeedsApproval);
2113        assert_eq!(level("npx jest -u"), ShellRiskLevel::NeedsApproval);
2114    }
2115
2116    #[test]
2117    fn pipeline_with_non_read_only_stage_needs_approval() {
2118        assert_eq!(
2119            level("cat file.txt | tee out.txt"),
2120            ShellRiskLevel::NeedsApproval
2121        );
2122        assert_eq!(level("ls && cargo test"), ShellRiskLevel::NeedsApproval);
2123    }
2124
2125    #[test]
2126    fn relaxed_policy_bypasses_conservative_read_only_checks() {
2127        let command = "curl -sS https://example.com 2>&1 | head -80";
2128        assert_eq!(
2129            classify_shell_command_with_policy(command, ShellRiskPolicy::Strict).level,
2130            ShellRiskLevel::NeedsApproval
2131        );
2132        assert_eq!(
2133            classify_shell_command_with_policy(command, ShellRiskPolicy::Relaxed).level,
2134            ShellRiskLevel::SafeRead
2135        );
2136        assert_eq!(
2137            classify_shell_command_with_policy("rm -rf /", ShellRiskPolicy::Relaxed).level,
2138            ShellRiskLevel::Blocked
2139        );
2140    }
2141
2142    // --- hard deny --------------------------------------------------------------
2143
2144    #[test]
2145    fn recursive_delete_of_critical_paths_is_blocked() {
2146        for cmd in [
2147            "rm -rf /",
2148            "rm -rf /*",
2149            "rm -fr /usr",
2150            "rm -r /etc",
2151            "rm -rf /var/",
2152            "rm --recursive --force /bin",
2153            "rm --no-preserve-root -rf /",
2154            "sudo rm -rf /usr",
2155        ] {
2156            assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2157        }
2158    }
2159
2160    #[test]
2161    fn workspace_recursive_delete_is_not_blocked() {
2162        for cmd in ["rm -rf target", "rm -rf ./build", "rm -rf /tmp/scratch"] {
2163            assert_eq!(level(cmd), ShellRiskLevel::NeedsApproval, "command: {cmd}");
2164        }
2165    }
2166
2167    #[test]
2168    fn raw_device_writes_are_blocked() {
2169        for cmd in [
2170            "dd if=/dev/zero of=/dev/sda",
2171            "mkfs.ext4 /dev/sda1",
2172            "mkswap /dev/sda2",
2173            "wipefs -a /dev/sda",
2174            "echo x > /dev/sda",
2175            "cat data >> /dev/nvme0n1",
2176            "fdisk /dev/sda",
2177        ] {
2178            assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2179        }
2180    }
2181
2182    #[test]
2183    fn benign_device_usage_is_not_blocked() {
2184        assert_eq!(
2185            level("dd if=/dev/zero of=test.img bs=1M count=10"),
2186            ShellRiskLevel::NeedsApproval
2187        );
2188        assert_eq!(level("ls /dev/sda"), ShellRiskLevel::SafeRead);
2189        assert_eq!(level("fdisk -l"), ShellRiskLevel::NeedsApproval);
2190    }
2191
2192    #[test]
2193    fn system_lifecycle_commands_are_blocked() {
2194        for cmd in [
2195            "shutdown -h now",
2196            "reboot",
2197            "halt",
2198            "poweroff",
2199            "init 0",
2200            "telinit 6",
2201            "systemctl reboot",
2202            "systemctl poweroff",
2203        ] {
2204            assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2205        }
2206        // Non-lifecycle systemctl and init usage is not on the deny list.
2207        assert_eq!(
2208            level("systemctl status nginx"),
2209            ShellRiskLevel::NeedsApproval
2210        );
2211    }
2212
2213    #[test]
2214    fn killing_pid_one_is_blocked() {
2215        for cmd in [
2216            "kill 1",
2217            "kill -9 1",
2218            "kill -TERM 1",
2219            "kill -9 -1",
2220            "killall5",
2221        ] {
2222            assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2223        }
2224        assert_eq!(level("kill -9 12345"), ShellRiskLevel::NeedsApproval);
2225        assert_eq!(level("kill -1 12345"), ShellRiskLevel::NeedsApproval); // SIGHUP
2226    }
2227
2228    #[test]
2229    fn fork_bomb_is_blocked() {
2230        assert_eq!(level(":(){ :|:& };:"), ShellRiskLevel::Blocked);
2231        assert_eq!(level("bomb(){ bomb|bomb& };bomb"), ShellRiskLevel::Blocked);
2232    }
2233
2234    #[test]
2235    fn permission_sweep_on_system_paths_is_blocked() {
2236        assert_eq!(level("chmod -R 777 /"), ShellRiskLevel::Blocked);
2237        assert_eq!(level("chmod -R 000 /usr"), ShellRiskLevel::Blocked);
2238        assert_eq!(level("chown -R nobody /etc"), ShellRiskLevel::Blocked);
2239        // Project-local sweeps are fine (well, approval-gated).
2240        assert_eq!(
2241            level("chmod -R 755 ./scripts"),
2242            ShellRiskLevel::NeedsApproval
2243        );
2244    }
2245
2246    #[test]
2247    fn deny_scan_sees_through_compound_syntax() {
2248        for cmd in [
2249            "ls; rm -rf /usr",
2250            "true && rm -rf /etc",
2251            "false || rm -rf /var",
2252            "echo hi | tee log; reboot",
2253            "(rm -rf /usr)",
2254            "echo $(rm -rf /etc)",
2255            "bash -c 'rm -rf /usr'",
2256            "sudo sh -c \"rm -rf /etc\"",
2257            "env FOO=1 rm -rf /usr",
2258            "nohup reboot",
2259            "timeout 30 rm -rf /etc",
2260        ] {
2261            assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2262        }
2263    }
2264
2265    #[test]
2266    fn quoted_destructive_text_is_not_blocked() {
2267        // The dangerous string is data, not a command.
2268        assert_eq!(level("echo 'rm -rf /usr'"), ShellRiskLevel::SafeRead);
2269        assert_eq!(level("grep 'rm -rf /' README.md"), ShellRiskLevel::SafeRead);
2270    }
2271
2272    #[test]
2273    fn decisions_carry_reasons() {
2274        let decision = classify_shell_command("rm -rf /");
2275        assert_eq!(decision.level, ShellRiskLevel::Blocked);
2276        assert!(!decision.reason.is_empty());
2277        assert_eq!(decision.level.as_str(), "blocked");
2278    }
2279}