1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum ShellRiskLevel {
39 SafeRead,
41 BoundedWrite,
43 NeedsApproval,
45 Blocked,
47}
48
49impl ShellRiskLevel {
50 pub fn as_str(&self) -> &'static str {
51 match self {
52 ShellRiskLevel::SafeRead => "safe_read",
53 ShellRiskLevel::BoundedWrite => "bounded_write",
54 ShellRiskLevel::NeedsApproval => "needs_approval",
55 ShellRiskLevel::Blocked => "blocked",
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ShellRiskDecision {
64 pub level: ShellRiskLevel,
65 pub reason: String,
66}
67
68fn safe_read(reason: &str) -> ShellRiskDecision {
69 ShellRiskDecision {
70 level: ShellRiskLevel::SafeRead,
71 reason: reason.to_string(),
72 }
73}
74
75fn bounded_write(reason: String) -> ShellRiskDecision {
76 ShellRiskDecision {
77 level: ShellRiskLevel::BoundedWrite,
78 reason,
79 }
80}
81
82fn needs_approval(reason: &str) -> ShellRiskDecision {
83 ShellRiskDecision {
84 level: ShellRiskLevel::NeedsApproval,
85 reason: reason.to_string(),
86 }
87}
88
89fn blocked(reason: &str) -> ShellRiskDecision {
90 ShellRiskDecision {
91 level: ShellRiskLevel::Blocked,
92 reason: reason.to_string(),
93 }
94}
95
96pub fn classify_shell_command(command: &str) -> ShellRiskDecision {
98 if let Some(decision) = hard_deny(command) {
99 return decision;
100 }
101 classify_allowable(command)
102}
103
104const CRITICAL_SYSTEM_PATHS: &[&str] = &[
111 "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64", "/proc", "/sbin", "/sys", "/usr", "/var",
112];
113
114const RAW_DEVICE_PREFIXES: &[&str] = &[
116 "/dev/sd",
117 "/dev/vd",
118 "/dev/xvd",
119 "/dev/hd",
120 "/dev/nvme",
121 "/dev/mem",
122 "/dev/kmem",
123 "/dev/port",
124];
125
126const SAFE_DEVICE_SINKS: &[&str] = &["/dev/null", "/dev/stdout", "/dev/stderr"];
128
129fn hard_deny(command: &str) -> Option<ShellRiskDecision> {
130 if looks_like_fork_bomb(command) {
131 return Some(blocked(
132 "fork bomb pattern would exhaust sandbox PIDs and make the session unresponsive",
133 ));
134 }
135 for segment in deny_scan_segments(command) {
136 if let Some(decision) = deny_scan_segment(&segment) {
137 return Some(decision);
138 }
139 }
140 None
141}
142
143fn deny_scan_segments(command: &str) -> Vec<String> {
149 let mut segments = Vec::new();
150 let mut current = String::new();
151 let mut quote: Option<char> = None;
152 let mut escaped = false;
153 let flush = |current: &mut String, segments: &mut Vec<String>| {
154 let part = current.trim();
155 if !part.is_empty() {
156 segments.push(part.to_string());
157 }
158 current.clear();
159 };
160 for ch in command.chars() {
161 if let Some(q) = quote {
162 current.push(ch);
166 if q == '"' {
167 if escaped {
168 escaped = false;
169 } else if ch == '\\' {
170 escaped = true;
171 } else if ch == '"' {
172 quote = None;
173 }
174 } else if ch == q {
175 quote = None;
176 }
177 continue;
178 }
179 if escaped {
180 escaped = false;
181 current.push(ch);
182 continue;
183 }
184 match ch {
185 '\\' => {
186 escaped = true;
187 current.push(ch);
188 }
189 '\'' | '"' => {
190 quote = Some(ch);
191 current.push(ch);
192 }
193 ';' | '|' | '&' | '\n' | '(' | ')' | '`' => flush(&mut current, &mut segments),
194 _ => current.push(ch),
195 }
196 }
197 flush(&mut current, &mut segments);
198 segments
199}
200
201fn deny_scan_words(segment: &str) -> Vec<String> {
205 let mut words = Vec::new();
206 let mut word = String::new();
207 let mut in_word = false;
208 let mut quote: Option<char> = None;
209 let mut escaped = false;
210 let chars: Vec<char> = segment.chars().collect();
211 let mut i = 0;
212 while i < chars.len() {
213 let ch = chars[i];
214 if let Some(q) = quote {
215 if q == '"' {
216 if escaped {
217 escaped = false;
218 word.push(ch);
219 } else if ch == '\\' {
220 escaped = true;
221 } else if ch == '"' {
222 quote = None;
223 } else {
224 word.push(ch);
225 }
226 } else if ch == q {
227 quote = None;
228 } else {
229 word.push(ch);
230 }
231 i += 1;
232 continue;
233 }
234 if escaped {
235 escaped = false;
236 word.push(ch);
237 i += 1;
238 continue;
239 }
240 match ch {
241 '\\' => escaped = true,
242 '\'' | '"' => {
243 quote = Some(ch);
244 in_word = true;
245 }
246 ' ' | '\t' => {
247 if in_word {
248 words.push(std::mem::take(&mut word));
249 in_word = false;
250 }
251 }
252 '>' => {
253 if in_word {
254 words.push(std::mem::take(&mut word));
255 in_word = false;
256 }
257 words.push(">".to_string());
258 if i + 1 < chars.len() && chars[i + 1] == '>' {
260 i += 1;
261 }
262 }
263 _ => {
264 in_word = true;
265 word.push(ch);
266 }
267 }
268 i += 1;
269 }
270 if in_word {
271 words.push(word);
272 }
273 words
274}
275
276fn strip_command_wrappers(words: &[String]) -> Vec<String> {
279 let mut rest: &[String] = words;
280 loop {
281 let Some(first) = rest.first() else {
282 return Vec::new();
283 };
284 match command_basename(first).as_str() {
285 "sudo" | "doas" => {
286 rest = &rest[1..];
287 while rest.first().is_some_and(|w| w.starts_with('-')) {
288 rest = &rest[1..];
289 }
290 }
291 "env" => {
292 rest = &rest[1..];
293 while rest
294 .first()
295 .is_some_and(|w| w.contains('=') || w.starts_with('-'))
296 {
297 rest = &rest[1..];
298 }
299 }
300 "nohup" | "command" | "exec" | "time" | "nice" | "ionice" | "stdbuf" => {
301 rest = &rest[1..];
302 while rest.first().is_some_and(|w| w.starts_with('-')) {
303 rest = &rest[1..];
304 }
305 }
306 "timeout" => {
307 rest = &rest[1..];
308 while rest.first().is_some_and(|w| w.starts_with('-')) {
309 rest = &rest[1..];
310 }
311 if !rest.is_empty() {
313 rest = &rest[1..];
314 }
315 }
316 _ => return rest.to_vec(),
317 }
318 }
319}
320
321fn command_basename(word: &str) -> String {
322 word.rsplit('/').next().unwrap_or(word).to_lowercase()
323}
324
325fn deny_scan_segment(segment: &str) -> Option<ShellRiskDecision> {
326 let words = strip_command_wrappers(&deny_scan_words(segment));
327 let cmd = command_basename(words.first()?);
328 let args = &words[1..];
329
330 if matches!(cmd.as_str(), "sh" | "bash" | "zsh" | "dash" | "ksh") {
332 let mut iter = args.iter();
333 while let Some(arg) = iter.next() {
334 if arg.starts_with('-') && arg.contains('c') {
335 if let Some(script) = iter.next() {
336 if let Some(decision) = hard_deny(script) {
337 return Some(decision);
338 }
339 }
340 break;
341 }
342 }
343 }
344
345 let mut expect_redirect_target = false;
347 for word in &words {
348 if word == ">" {
349 expect_redirect_target = true;
350 continue;
351 }
352 if std::mem::take(&mut expect_redirect_target) && is_raw_device_path(word) {
353 return Some(blocked(
354 "redirecting output to a raw device would corrupt the sandbox filesystem",
355 ));
356 }
357 }
358
359 match cmd.as_str() {
360 "rm" => deny_check_rm(args),
361 "chmod" | "chown" | "chgrp" => deny_check_permission_sweep(&cmd, args),
362 "mkswap" | "wipefs" | "blkdiscard" => Some(blocked(
363 "filesystem/block-device destruction would brick the sandbox",
364 )),
365 "fdisk" | "parted" | "sgdisk" => {
366 let listing_only = args.iter().any(|a| a == "-l" || a == "--list");
367 if listing_only {
368 None
369 } else {
370 Some(blocked(
371 "partition-table manipulation would brick the sandbox",
372 ))
373 }
374 }
375 "dd" => {
376 for arg in args {
377 if let Some(target) = arg.strip_prefix("of=") {
378 if target.starts_with("/dev/") && !SAFE_DEVICE_SINKS.contains(&target) {
379 return Some(blocked(
380 "dd writing to a raw device would corrupt the sandbox filesystem",
381 ));
382 }
383 }
384 }
385 None
386 }
387 "shutdown" | "reboot" | "halt" | "poweroff" | "telinit" => {
388 Some(blocked("shutting down the sandbox terminates the session"))
389 }
390 "init" => {
391 if args.iter().any(|a| a == "0" || a == "6") {
392 Some(blocked(
393 "changing the runlevel to halt/reboot terminates the session",
394 ))
395 } else {
396 None
397 }
398 }
399 "systemctl" => {
400 let sub = args.iter().find(|a| !a.starts_with('-'));
401 if sub.is_some_and(|s| matches!(s.as_str(), "reboot" | "poweroff" | "halt" | "kexec")) {
402 Some(blocked("shutting down the sandbox terminates the session"))
403 } else {
404 None
405 }
406 }
407 "kill" => deny_check_kill(args),
408 "killall5" => Some(blocked(
409 "signalling every process kills the sandbox session",
410 )),
411 _ => {
412 if cmd.starts_with("mkfs") {
413 return Some(blocked(
414 "creating a filesystem over an existing device would brick the sandbox",
415 ));
416 }
417 None
418 }
419 }
420}
421
422fn deny_check_rm(args: &[String]) -> Option<ShellRiskDecision> {
423 let mut recursive = false;
424 let mut no_preserve_root = false;
425 let mut operands: Vec<&String> = Vec::new();
426 let mut end_of_options = false;
427 for arg in args {
428 if end_of_options {
429 operands.push(arg);
430 continue;
431 }
432 if arg == "--" {
433 end_of_options = true;
434 } else if arg == "--recursive" {
435 recursive = true;
436 } else if arg == "--no-preserve-root" {
437 no_preserve_root = true;
438 } else if let Some(short) = arg.strip_prefix('-') {
439 if !short.starts_with('-') && short.chars().any(|c| c == 'r' || c == 'R') {
440 recursive = true;
441 }
442 } else {
443 operands.push(arg);
444 }
445 }
446 if !recursive {
447 return None;
448 }
449 if no_preserve_root {
450 return Some(blocked(
451 "rm --no-preserve-root with recursion would destroy the sandbox session",
452 ));
453 }
454 if operands.iter().any(|p| is_critical_system_path(p)) {
455 return Some(blocked(
456 "recursive deletion of a critical system path would destroy the sandbox session",
457 ));
458 }
459 None
460}
461
462fn deny_check_permission_sweep(cmd: &str, args: &[String]) -> Option<ShellRiskDecision> {
463 let mut recursive = false;
464 let mut operands: Vec<&String> = Vec::new();
465 let mut end_of_options = false;
466 for arg in args {
467 if end_of_options {
468 operands.push(arg);
469 continue;
470 }
471 if arg == "--" {
472 end_of_options = true;
473 } else if arg == "--recursive" {
474 recursive = true;
475 } else if let Some(short) = arg.strip_prefix('-') {
476 if !short.starts_with('-') && short.chars().any(|c| c == 'R' || c == 'r') {
477 recursive = true;
478 }
479 } else {
480 operands.push(arg);
481 }
482 }
483 if recursive && operands.iter().any(|p| is_critical_system_path(p)) {
484 return Some(blocked(
485 match cmd {
487 "chmod" => "recursive permission sweep over a critical system path would destroy the sandbox session",
488 _ => "recursive ownership sweep over a critical system path would destroy the sandbox session",
489 },
490 ));
491 }
492 None
493}
494
495fn deny_check_kill(args: &[String]) -> Option<ShellRiskDecision> {
496 let mut saw_signal = false;
497 let mut end_of_options = false;
498 for arg in args {
499 if !end_of_options && arg == "--" {
500 end_of_options = true;
501 continue;
502 }
503 if !end_of_options && arg.starts_with('-') {
504 if saw_signal && arg == "-1" {
507 return Some(blocked(
508 "kill -1 signals every process and kills the sandbox session",
509 ));
510 }
511 saw_signal = true;
512 continue;
513 }
514 if arg == "1" || arg == "-1" {
515 return Some(blocked("killing PID 1 terminates the sandbox session"));
516 }
517 }
518 None
519}
520
521fn is_critical_system_path(path: &str) -> bool {
522 let trimmed = path.trim();
523 let stripped = trimmed.strip_suffix("/*").unwrap_or(trimmed);
525 let normalized = if stripped.len() > 1 {
526 stripped.trim_end_matches('/')
527 } else {
528 stripped
529 };
530 if normalized == "/" || normalized == "/*" || trimmed == "/*" {
531 return true;
532 }
533 CRITICAL_SYSTEM_PATHS.contains(&normalized)
534}
535
536fn is_raw_device_path(path: &str) -> bool {
537 RAW_DEVICE_PREFIXES
538 .iter()
539 .any(|prefix| path.starts_with(prefix))
540}
541
542fn looks_like_fork_bomb(command: &str) -> bool {
545 let compact: String = command.chars().filter(|c| !c.is_whitespace()).collect();
546 let Some(def_at) = compact.find("(){") else {
547 return false;
548 };
549 let name: String = compact[..def_at]
550 .chars()
551 .rev()
552 .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
553 .collect::<String>()
554 .chars()
555 .rev()
556 .collect();
557 if name.is_empty() {
558 return false;
559 }
560 let body = &compact[def_at + 3..];
561 body.contains(&format!("{name}|{name}")) && body.contains('&')
562}
563
564const READ_ONLY_PREFIXES: &[&str] = &[
570 "ls",
571 "pwd",
572 "echo",
573 "cat",
574 "head",
575 "tail",
576 "wc",
577 "file",
578 "tree",
579 "find",
580 "grep",
581 "rg",
582 "uptime",
583 "cal",
584 "free",
585 "df",
586 "du",
587 "locale",
588 "groups",
589 "nproc",
590 "stat",
591 "strings",
592 "hexdump",
593 "od",
594 "nl",
595 "basename",
596 "dirname",
597 "realpath",
598 "readlink",
599 "cut",
600 "paste",
601 "tr",
602 "column",
603 "tac",
604 "rev",
605 "fold",
606 "expand",
607 "unexpand",
608 "comm",
609 "cmp",
610 "numfmt",
611 "true",
612 "false",
613 "type",
614 "expr",
615 "test",
616 "getconf",
617 "seq",
618 "tsort",
619 "pr",
620 "go version",
621 "rustc --version",
622 "python --version",
623 "python3 --version",
624 "node --version",
625 "npm --version",
626 "npx --version",
627 "cargo --version",
628 "deno --version",
629 "bun --version",
630];
631
632fn classify_allowable(command: &str) -> ShellRiskDecision {
633 if let Some(base) = strip_trailing_safe_stderr_redirect(command) {
636 return classify_allowable(&base);
637 }
638 if let Some(parts) = split_sequence(command) {
639 return classify_all_safe_read(&parts, "; list");
640 }
641 if let Some(parts) = split_and_list(command) {
642 return classify_all_safe_read(&parts, "&& list");
643 }
644 if let Some(parts) = split_pipeline(command) {
645 return classify_all_safe_read(&parts, "pipeline");
646 }
647 let Some(argv) = parse_simple_command(command) else {
648 return needs_approval("command is not a simple shell command");
649 };
650 let lower: Vec<String> = argv.iter().map(|a| a.to_lowercase()).collect();
651 if has_unsafe_args(&lower) {
652 return needs_approval(
653 "command contains arguments that may mutate files or execute arbitrary code",
654 );
655 }
656 if make_bounded_target_has_extra_args(&lower) {
657 return needs_approval("make bounded-write targets must not include extra targets or args");
658 }
659 if let Some(decision) = classify_builtin_read_only(&argv, &lower) {
660 return decision;
661 }
662 if lower[0] == "git" {
663 if git_command_read_only(&argv) {
664 return safe_read("git read-only command");
665 }
666 return needs_approval("git command is not classified as read-only");
667 }
668 if let Some(decision) = classify_bounded_write(&lower) {
669 return decision;
670 }
671 for prefix in READ_ONLY_PREFIXES {
672 if argv_has_prefix(&lower, prefix) {
673 return safe_read("built-in read-only command");
674 }
675 }
676 needs_approval("command is not classified as safe read-only or bounded-write")
677}
678
679fn classify_all_safe_read(parts: &[String], kind: &str) -> ShellRiskDecision {
681 for part in parts {
682 let decision = classify_shell_command(part);
683 if decision.level == ShellRiskLevel::Blocked {
684 return decision;
685 }
686 if decision.level != ShellRiskLevel::SafeRead {
687 return needs_approval(&format!(
688 "{kind} contains a command that is not safe read-only"
689 ));
690 }
691 }
692 safe_read(&format!("{kind} of read-only commands"))
693}
694
695fn split_unquoted(command: &str, separator: &str) -> Option<Vec<String>> {
701 let mut parts = Vec::new();
702 let mut current = String::new();
703 let mut quote: Option<char> = None;
704 let mut escaped = false;
705 let mut saw_separator = false;
706 let runes: Vec<char> = command.trim().chars().collect();
707 let sep: Vec<char> = separator.chars().collect();
708 let mut i = 0;
709 while i < runes.len() {
710 let r = runes[i];
711 if quote == Some('\'') {
712 if r == '\'' {
713 quote = None;
714 }
715 current.push(r);
716 i += 1;
717 continue;
718 }
719 if escaped {
720 escaped = false;
721 current.push(r);
722 i += 1;
723 continue;
724 }
725 match r {
726 '\\' => {
727 if quote == Some('"') {
728 escaped = true;
729 }
730 current.push(r);
731 }
732 '"' => {
733 if quote.is_none() {
734 quote = Some('"');
735 } else if quote == Some('"') {
736 quote = None;
737 }
738 current.push(r);
739 }
740 '\'' => {
741 if quote.is_none() {
742 quote = Some('\'');
743 }
744 current.push(r);
745 }
746 _ if r == sep[0] && quote.is_none() => {
747 if sep.len() == 2 {
751 if i + 1 >= runes.len() || runes[i + 1] != sep[1] {
752 current.push(r);
753 i += 1;
754 continue;
755 }
756 i += 1;
757 }
758 let part = current.trim().to_string();
759 current.clear();
760 if part.is_empty() {
761 return None;
762 }
763 parts.push(part);
764 saw_separator = true;
765 }
766 _ => current.push(r),
767 }
768 i += 1;
769 }
770 if quote.is_some() || escaped || !saw_separator {
771 return None;
772 }
773 let part = current.trim().to_string();
774 if part.is_empty() {
775 return None;
776 }
777 parts.push(part);
778 Some(parts)
779}
780
781fn split_sequence(command: &str) -> Option<Vec<String>> {
783 split_unquoted(command, ";")
784}
785
786fn split_and_list(command: &str) -> Option<Vec<String>> {
790 split_unquoted(command, "&&")
791}
792
793fn split_pipeline(command: &str) -> Option<Vec<String>> {
796 split_unquoted(command, "|")
797}
798
799fn strip_trailing_safe_stderr_redirect(command: &str) -> Option<String> {
802 let trimmed = command.trim();
803 for redirect in ["2>&1", "2>/dev/null", "2> /dev/null"] {
804 if let Some(base) = strip_trailing_redirect(trimmed, redirect) {
805 return Some(base);
806 }
807 }
808 None
809}
810
811fn strip_trailing_redirect(command: &str, redirect: &str) -> Option<String> {
812 let base = command.strip_suffix(redirect)?;
813 if base.is_empty() || !base.ends_with([' ', '\t']) {
814 return None;
815 }
816 if !offset_outside_quotes(command, base.len()) {
817 return None;
818 }
819 let base = base.trim();
820 if base.is_empty() {
821 return None;
822 }
823 Some(base.to_string())
824}
825
826fn offset_outside_quotes(command: &str, offset: usize) -> bool {
829 let mut quote: Option<char> = None;
830 let mut escaped = false;
831 for (i, r) in command.char_indices() {
832 if i >= offset {
833 break;
834 }
835 if quote == Some('\'') {
836 if r == '\'' {
837 quote = None;
838 }
839 continue;
840 }
841 if escaped {
842 escaped = false;
843 continue;
844 }
845 match r {
846 '\\' => {
847 if quote == Some('"') {
848 escaped = true;
849 }
850 }
851 '"' => {
852 if quote.is_none() {
853 quote = Some('"');
854 } else if quote == Some('"') {
855 quote = None;
856 }
857 }
858 '\'' if quote.is_none() => {
859 quote = Some('\'');
860 }
861 _ => {}
862 }
863 }
864 quote.is_none() && !escaped
865}
866
867fn parse_simple_command(command: &str) -> Option<Vec<String>> {
874 let mut argv = Vec::new();
875 let mut word = String::new();
876 let mut in_word = false;
877 let mut quote: Option<char> = None;
878 for r in command.trim().chars() {
879 match quote {
880 Some('\'') => {
881 if r == '\'' {
882 quote = None;
883 continue;
884 }
885 word.push(r);
886 continue;
887 }
888 Some('"') => {
889 match r {
890 '"' => quote = None,
891 '\\' | '$' | '`' => return None,
893 _ => word.push(r),
894 }
895 continue;
896 }
897 _ => {}
898 }
899 match r {
900 ' ' | '\t' => {
901 if in_word {
902 argv.push(std::mem::take(&mut word));
903 in_word = false;
904 }
905 }
906 '\'' | '"' => {
907 quote = Some(r);
908 in_word = true;
909 }
910 _ if rejected_simple_command_char(r) => return None,
911 _ => {
912 in_word = true;
913 word.push(r);
914 }
915 }
916 }
917 if quote.is_some() {
918 return None;
919 }
920 if in_word {
921 argv.push(word);
922 }
923 if argv.is_empty() {
924 None
925 } else {
926 Some(argv)
927 }
928}
929
930fn rejected_simple_command_char(r: char) -> bool {
931 matches!(
932 r,
933 '\\' | '$'
934 | '`'
935 | ';'
936 | '|'
937 | '&'
938 | '<'
939 | '>'
940 | '\n'
941 | '\r'
942 | '('
943 | ')'
944 | '{'
945 | '}'
946 | '#'
947 | '*'
948 | '?'
949 | '['
950 | ']'
951 )
952}
953
954fn has_unsafe_args(argv: &[String]) -> bool {
957 for field in &argv[1..] {
958 if field.contains(['$', '`', '&', '<', '>', '\n', '\r']) {
959 return true;
960 }
961 }
962 if argv_has_prefix(argv, "find") {
963 for field in argv {
964 match field.as_str() {
965 "-delete" | "-exec" | "-execdir" | "-ok" | "-okdir" | "-fls" => return true,
966 _ => {}
967 }
968 if field.starts_with("-fprint") {
969 return true;
970 }
971 }
972 } else if argv_has_prefix(argv, "git diff")
973 || argv_has_prefix(argv, "git show")
974 || argv_has_prefix(argv, "git log")
975 {
976 for field in argv {
977 if field == "--output"
978 || field.starts_with("--output=")
979 || field == "--ext-diff"
980 || field == "--external-diff"
981 || field == "--textconv"
982 {
983 return true;
984 }
985 }
986 } else if argv_has_prefix(argv, "rg") {
987 for field in argv {
988 if field == "--pre" || field.starts_with("--pre=") {
989 return true;
990 }
991 }
992 }
993 for field in argv {
994 match field.as_str() {
995 "--fix" | "--write" | "--update" | "--update-snapshot" | "--updatesnapshot" => {
996 return true
997 }
998 _ => {}
999 }
1000 if field.starts_with("--fix=")
1001 || field.starts_with("--write=")
1002 || field.starts_with("--update=")
1003 || field.starts_with("--update-snapshot=")
1004 || field.starts_with("--updatesnapshot=")
1005 {
1006 return true;
1007 }
1008 }
1009 if (argv_has_prefix(argv, "npx jest") || argv_has_prefix(argv, "npx vitest"))
1010 && argv.iter().any(|a| a == "-u")
1011 {
1012 return true;
1013 }
1014 false
1015}
1016
1017const MAKE_BOUNDED_TARGETS: &[&str] =
1020 &["build", "test", "check", "lint", "fmt", "fmt-check", "vet"];
1021
1022fn make_bounded_target_has_extra_args(argv: &[String]) -> bool {
1023 if argv.len() < 2 || argv[0] != "make" {
1024 return false;
1025 }
1026 if MAKE_BOUNDED_TARGETS.contains(&argv[1].as_str()) {
1027 return argv.len() != 2;
1028 }
1029 false
1030}
1031
1032fn classify_builtin_read_only(argv: &[String], lower: &[String]) -> Option<ShellRiskDecision> {
1035 match lower[0].as_str() {
1036 "date" => Some(classify_date(argv, lower)),
1037 "uname" => Some(classify_uname(lower)),
1038 "whoami" => Some(classify_whoami(lower)),
1039 "id" => Some(classify_id(lower)),
1040 "which" => Some(classify_command_lookup(&lower[1..])),
1041 "command" => {
1042 if lower.len() >= 2 && lower[1] == "-v" {
1043 Some(classify_command_lookup(&lower[2..]))
1044 } else {
1045 None
1046 }
1047 }
1048 "sed" => Some(classify_sed_read_only(argv)),
1049 "sort" => Some(classify_sort(argv)),
1050 "uniq" => Some(classify_uniq(argv)),
1051 "printf" => Some(classify_printf(lower)),
1052 _ => None,
1053 }
1054}
1055
1056fn classify_date(argv: &[String], lower: &[String]) -> ShellRiskDecision {
1057 const FLAGS_WITH_VALUES: &[&str] = &["-d", "--date", "-r", "--reference", "--rfc-3339"];
1058 const SAFE_NO_VALUE_FLAGS: &[&str] = &[
1059 "-u",
1060 "--utc",
1061 "--universal",
1062 "-I",
1063 "-R",
1064 "--iso-8601",
1065 "--rfc-email",
1066 "--debug",
1067 "--help",
1068 "--version",
1069 ];
1070 let mut i = 1;
1071 while i < lower.len() {
1072 let raw = argv[i].as_str();
1073 let arg = lower[i].as_str();
1074 if raw == "-s"
1075 || arg == "--set"
1076 || arg.starts_with("--set=")
1077 || raw == "-f"
1078 || arg == "--file"
1079 || arg.starts_with("--file=")
1080 {
1081 return needs_approval("date can set system time or read batch dates with this option");
1082 }
1083 if raw.starts_with('+') {
1084 i += 1;
1085 continue;
1086 }
1087 if FLAGS_WITH_VALUES.contains(&raw)
1088 || (raw.starts_with("--") && FLAGS_WITH_VALUES.contains(&arg))
1089 {
1090 i += 1;
1091 if i >= lower.len() {
1092 return needs_approval("date flag requires a value");
1093 }
1094 i += 1;
1095 continue;
1096 }
1097 if arg.starts_with("--date=")
1098 || arg.starts_with("--reference=")
1099 || arg.starts_with("--iso-8601=")
1100 || arg.starts_with("--rfc-3339=")
1101 {
1102 i += 1;
1103 continue;
1104 }
1105 if SAFE_NO_VALUE_FLAGS.contains(&raw) || SAFE_NO_VALUE_FLAGS.contains(&arg) {
1106 i += 1;
1107 continue;
1108 }
1109 if raw.starts_with('-') {
1110 return needs_approval("date option is not on the safe display allowlist");
1111 }
1112 return needs_approval("date positional arguments can set system time");
1113 }
1114 safe_read("date display command")
1115}
1116
1117fn classify_uname(lower: &[String]) -> ShellRiskDecision {
1118 const SAFE_LONG: &[&str] = &[
1119 "--all",
1120 "--kernel-name",
1121 "--nodename",
1122 "--kernel-release",
1123 "--kernel-version",
1124 "--machine",
1125 "--processor",
1126 "--hardware-platform",
1127 "--operating-system",
1128 "--help",
1129 "--version",
1130 ];
1131 for arg in &lower[1..] {
1132 if SAFE_LONG.contains(&arg.as_str()) {
1133 continue;
1134 }
1135 if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") {
1136 if arg[1..].chars().all(|r| "asnrvmpio".contains(r)) {
1137 continue;
1138 }
1139 return needs_approval("uname option is not on the safe display allowlist");
1140 }
1141 return needs_approval("uname only supports safe display flags in auto-allow");
1142 }
1143 safe_read("uname display command")
1144}
1145
1146fn classify_whoami(lower: &[String]) -> ShellRiskDecision {
1147 for arg in &lower[1..] {
1148 if arg != "--help" && arg != "--version" {
1149 return needs_approval("whoami only supports help/version args in auto-allow");
1150 }
1151 }
1152 safe_read("whoami display command")
1153}
1154
1155fn classify_id(lower: &[String]) -> ShellRiskDecision {
1156 const SAFE_LONG: &[&str] = &[
1157 "--user",
1158 "--group",
1159 "--groups",
1160 "--name",
1161 "--real",
1162 "--zero",
1163 "--help",
1164 "--version",
1165 ];
1166 for arg in &lower[1..] {
1167 if SAFE_LONG.contains(&arg.as_str()) || is_command_name(arg) {
1168 continue;
1169 }
1170 if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") {
1171 if arg[1..].chars().all(|r| "uggnrz".contains(r)) {
1172 continue;
1173 }
1174 return needs_approval("id option is not on the safe display allowlist");
1175 }
1176 return needs_approval("id argument is not safe for auto-allow");
1177 }
1178 safe_read("id display command")
1179}
1180
1181fn classify_command_lookup(args: &[String]) -> ShellRiskDecision {
1182 if args.is_empty() {
1183 return needs_approval("command lookup requires at least one command name");
1184 }
1185 for arg in args {
1186 if !is_command_name(arg) {
1187 return needs_approval("command lookup operands must be simple command names");
1188 }
1189 }
1190 safe_read("command lookup")
1191}
1192
1193fn classify_printf(lower: &[String]) -> ShellRiskDecision {
1194 for arg in &lower[1..] {
1195 if arg.contains('/') && arg.starts_with('-') {
1196 return needs_approval("printf option is not on the safe display allowlist");
1197 }
1198 }
1199 safe_read("printf display command")
1200}
1201
1202fn is_command_name(v: &str) -> bool {
1203 let v = v.trim();
1204 if v.is_empty() || v.contains('/') || v.starts_with('-') {
1205 return false;
1206 }
1207 v.chars()
1208 .all(|r| r.is_alphanumeric() || matches!(r, '_' | '.' | '-' | '+'))
1209}
1210
1211fn classify_sed_read_only(argv: &[String]) -> ShellRiskDecision {
1214 if sed_print_range_read_only(argv) {
1215 return safe_read("sed range print command");
1216 }
1217 if sed_substitution_read_only(argv) {
1218 return safe_read("sed stream substitution command");
1219 }
1220 needs_approval("sed command is not classified as read-only")
1221}
1222
1223fn sed_substitution_read_only(argv: &[String]) -> bool {
1224 if argv.len() < 2 || argv[0] != "sed" {
1225 return false;
1226 }
1227 let mut i = 1;
1228 while i < argv.len() {
1229 match argv[i].as_str() {
1230 "-E" | "-r" | "--regexp-extended" | "-n" | "--quiet" | "--silent" => i += 1,
1231 "--" => {
1232 i += 1;
1233 break;
1234 }
1235 _ => break,
1236 }
1237 }
1238 if i >= argv.len() || !sed_substitution_script_read_only(&argv[i]) {
1239 return false;
1240 }
1241 i += 1;
1242 argv[i..].iter().all(|a| !a.starts_with('-'))
1244}
1245
1246fn sed_substitution_script_read_only(script: &str) -> bool {
1247 if script.is_empty() || !script.starts_with('s') {
1248 return false;
1249 }
1250 let runes: Vec<char> = script.chars().collect();
1251 if runes.len() < 4 {
1252 return false;
1253 }
1254 let delim = runes[1];
1255 if delim == '\\' || delim == '\n' || delim == '\r' {
1256 return false;
1257 }
1258 let mut parts = 0;
1259 let mut escaped = false;
1260 let mut i = 2;
1261 while i < runes.len() {
1262 let r = runes[i];
1263 if escaped {
1264 escaped = false;
1265 i += 1;
1266 continue;
1267 }
1268 if r == '\\' {
1269 escaped = true;
1270 i += 1;
1271 continue;
1272 }
1273 if r == delim {
1274 parts += 1;
1275 if parts == 2 {
1276 let flags: String = runes[i + 1..].iter().collect();
1277 return sed_substitution_flags_read_only(&flags);
1278 }
1279 }
1280 i += 1;
1281 }
1282 false
1283}
1284
1285fn sed_substitution_flags_read_only(flags: &str) -> bool {
1286 flags
1287 .chars()
1288 .all(|r| r.is_ascii_digit() || matches!(r, 'g' | 'p' | 'I' | 'i' | 'M' | 'm'))
1289}
1290
1291fn sed_print_range_read_only(argv: &[String]) -> bool {
1292 if argv.len() < 3 || argv[0] != "sed" {
1293 return false;
1294 }
1295 let mut i = 1;
1296 let mut saw_quiet = false;
1297 while i < argv.len() {
1298 match argv[i].as_str() {
1299 "-n" | "--quiet" | "--silent" => {
1300 saw_quiet = true;
1301 i += 1;
1302 }
1303 "--" => {
1304 i += 1;
1305 break;
1306 }
1307 _ => break,
1308 }
1309 }
1310 if !saw_quiet || i >= argv.len() || !sed_range_print_script(&argv[i]) {
1311 return false;
1312 }
1313 i += 1;
1314 argv[i..].iter().all(|a| !a.starts_with('-'))
1315}
1316
1317fn sed_range_print_script(script: &str) -> bool {
1318 let Some(addr) = script.strip_suffix('p') else {
1319 return false;
1320 };
1321 if script.is_empty() {
1322 return false;
1323 }
1324 let parts: Vec<&str> = addr.split(',').collect();
1325 if parts.len() > 2 {
1326 return false;
1327 }
1328 for part in parts {
1329 if part == "$" {
1330 continue;
1331 }
1332 if part.is_empty() || !part.chars().all(|r| r.is_ascii_digit()) {
1333 return false;
1334 }
1335 }
1336 true
1337}
1338
1339fn classify_sort(argv: &[String]) -> ShellRiskDecision {
1342 let mut end_options = false;
1343 let mut i = 1;
1344 while i < argv.len() {
1345 let arg = argv[i].as_str();
1346 if end_options || !arg.starts_with('-') || arg == "-" {
1347 i += 1;
1348 continue;
1349 }
1350 if arg == "--" {
1351 end_options = true;
1352 i += 1;
1353 continue;
1354 }
1355 if arg.starts_with("--") {
1356 if arg == "--output" || arg.starts_with("--output=") {
1357 return needs_approval(
1358 "sort can write to an explicit output path with this option",
1359 );
1360 }
1361 if arg == "--compress-program" || arg.starts_with("--compress-program=") {
1362 return needs_approval("sort can execute an external compressor with this option");
1363 }
1364 if arg == "--temporary-directory" || arg.starts_with("--temporary-directory=") {
1365 return needs_approval(
1366 "sort can write temporary files outside the input stream with this option",
1367 );
1368 }
1369 if sort_long_option_consumes_next(arg) && !arg.contains('=') {
1370 i += 1;
1371 }
1372 if !sort_long_option_safe(arg) {
1373 return needs_approval("sort option is not on the safe display allowlist");
1374 }
1375 i += 1;
1376 continue;
1377 }
1378 if !sort_short_options_safe(arg) {
1379 return needs_approval("sort option is not on the safe display allowlist");
1380 }
1381 i += 1;
1382 }
1383 safe_read("sort display command")
1384}
1385
1386fn sort_long_option_safe(arg: &str) -> bool {
1387 let name = arg.split('=').next().unwrap_or(arg);
1388 matches!(
1389 name,
1390 "--ignore-leading-blanks"
1391 | "--dictionary-order"
1392 | "--ignore-nonprinting"
1393 | "--ignore-case"
1394 | "--general-numeric-sort"
1395 | "--human-numeric-sort"
1396 | "--month-sort"
1397 | "--numeric-sort"
1398 | "--reverse"
1399 | "--unique"
1400 | "--stable"
1401 | "--version-sort"
1402 | "--zero-terminated"
1403 | "--check"
1404 | "--key"
1405 | "--field-separator"
1406 )
1407}
1408
1409fn sort_long_option_consumes_next(arg: &str) -> bool {
1410 let name = arg.split('=').next().unwrap_or(arg);
1411 matches!(name, "--key" | "--field-separator")
1412}
1413
1414fn sort_short_options_safe(arg: &str) -> bool {
1417 let chars: Vec<char> = arg.chars().collect();
1418 for r in chars.iter().skip(1) {
1419 match r {
1420 'b' | 'c' | 'C' | 'd' | 'f' | 'g' | 'h' | 'i' | 'M' | 'm' | 'n' | 'r' | 's' | 'u'
1421 | 'V' | 'z' => continue,
1422 'k' | 't' => return true,
1423 _ => return false,
1424 }
1425 }
1426 chars.len() > 1
1427}
1428
1429fn classify_uniq(argv: &[String]) -> ShellRiskDecision {
1430 let mut operands = 0;
1431 let mut end_options = false;
1432 let mut i = 1;
1433 while i < argv.len() {
1434 let arg = argv[i].as_str();
1435 if end_options || !arg.starts_with('-') || arg == "-" {
1436 operands += 1;
1437 if operands > 1 {
1438 return needs_approval(
1439 "uniq can write to an output file when given a second operand",
1440 );
1441 }
1442 i += 1;
1443 continue;
1444 }
1445 if arg == "--" {
1446 end_options = true;
1447 i += 1;
1448 continue;
1449 }
1450 if arg.starts_with("--") {
1451 if uniq_long_option_consumes_next(arg) && !arg.contains('=') {
1452 i += 1;
1453 }
1454 if !uniq_long_option_safe(arg) {
1455 return needs_approval("uniq option is not on the safe display allowlist");
1456 }
1457 i += 1;
1458 continue;
1459 }
1460 let Some(consumes_next) = uniq_short_options_safe(arg) else {
1461 return needs_approval("uniq option is not on the safe display allowlist");
1462 };
1463 if consumes_next {
1464 i += 1;
1465 }
1466 i += 1;
1467 }
1468 safe_read("uniq display command")
1469}
1470
1471fn uniq_long_option_safe(arg: &str) -> bool {
1472 let name = arg.split('=').next().unwrap_or(arg);
1473 matches!(
1474 name,
1475 "--count"
1476 | "--repeated"
1477 | "--all-repeated"
1478 | "--unique"
1479 | "--ignore-case"
1480 | "--zero-terminated"
1481 | "--group"
1482 | "--skip-fields"
1483 | "--skip-chars"
1484 | "--check-chars"
1485 )
1486}
1487
1488fn uniq_long_option_consumes_next(arg: &str) -> bool {
1489 let name = arg.split('=').next().unwrap_or(arg);
1490 matches!(name, "--skip-fields" | "--skip-chars" | "--check-chars")
1491}
1492
1493fn uniq_short_options_safe(arg: &str) -> Option<bool> {
1496 let chars: Vec<char> = arg.chars().collect();
1497 for (idx, r) in chars.iter().enumerate().skip(1) {
1498 match r {
1499 'c' | 'd' | 'u' | 'i' | 'z' => continue,
1500 'f' | 's' | 'w' => {
1501 if idx == chars.len() - 1 {
1502 return Some(true);
1503 }
1504 return Some(false);
1505 }
1506 _ => return None,
1507 }
1508 }
1509 if chars.len() > 1 {
1510 Some(false)
1511 } else {
1512 None
1513 }
1514}
1515
1516fn git_command_read_only(argv: &[String]) -> bool {
1519 if argv.len() < 2 || argv[0] != "git" {
1520 return false;
1521 }
1522 if argv[1..].iter().any(|f| arg_contains_unsafe_meta(f)) {
1523 return false;
1524 }
1525 let mut subcommand_index = 1;
1526 while subcommand_index < argv.len() {
1527 let arg = argv[subcommand_index].as_str();
1528 if arg == "-c" || arg == "--config-env" || arg.starts_with("--config-env=") {
1529 return false;
1530 }
1531 if arg == "-C" {
1532 if subcommand_index + 1 >= argv.len()
1533 || !git_relative_path_allowed(&argv[subcommand_index + 1], false)
1534 {
1535 return false;
1536 }
1537 subcommand_index += 2;
1538 continue;
1539 }
1540 if let Some(path) = arg.strip_prefix("-C") {
1541 if !git_relative_path_allowed(path, false) {
1542 return false;
1543 }
1544 subcommand_index += 1;
1545 continue;
1546 }
1547 if arg.starts_with("-c") {
1548 return false;
1550 }
1551 if arg.starts_with('-') {
1552 return false;
1553 }
1554 break;
1555 }
1556 if subcommand_index >= argv.len() {
1557 return false;
1558 }
1559 let subcommand = argv[subcommand_index].as_str();
1560 let args = &argv[subcommand_index + 1..];
1561 match subcommand {
1562 "status" | "rev-parse" => git_args_are_read_only(args),
1563 "symbolic-ref" => git_args_are_read_only(args) && git_symbolic_ref_args_read_only(args),
1564 "branch" => git_args_are_read_only(args) && git_branch_args_read_only(args),
1565 "remote" => git_args_are_read_only(args) && git_remote_args_read_only(args),
1566 "config" => !args.is_empty() && args[0] == "--get" && git_args_are_read_only(args),
1567 "diff" => git_args_are_read_only(args) && git_diff_args_read_only(args),
1568 "show" | "log" | "shortlog" | "ls-files" => git_args_are_read_only(args),
1569 _ => false,
1570 }
1571}
1572
1573fn git_symbolic_ref_args_read_only(args: &[String]) -> bool {
1574 if args.is_empty() {
1575 return false;
1576 }
1577 let mut refs = 0;
1578 for arg in args {
1579 match arg.as_str() {
1580 "--short" | "-q" | "--quiet" => continue,
1581 _ => {
1582 if arg.starts_with('-') {
1583 return false;
1584 }
1585 refs += 1;
1586 }
1587 }
1588 }
1589 refs == 1
1590}
1591
1592fn git_branch_args_read_only(args: &[String]) -> bool {
1593 let mut saw_list = false;
1594 for arg in args {
1595 match arg.as_str() {
1596 "--show-current" | "--all" | "--remotes" | "--list" | "--verbose" | "--color"
1597 | "--no-color" | "-a" | "-r" | "-l" | "-v" | "-vv" => {
1598 if arg == "--list" || arg == "-l" {
1599 saw_list = true;
1600 }
1601 continue;
1602 }
1603 _ => {
1604 if arg.starts_with("--color=") {
1605 continue;
1606 }
1607 if saw_list && !arg.starts_with('-') {
1608 continue;
1609 }
1610 return false;
1611 }
1612 }
1613 }
1614 true
1615}
1616
1617fn git_remote_args_read_only(args: &[String]) -> bool {
1618 if args.is_empty() {
1619 return true;
1620 }
1621 if args.len() == 1 && args[0] == "-v" {
1622 return true;
1623 }
1624 args.len() >= 2 && args[0] == "get-url"
1625}
1626
1627fn git_args_are_read_only(args: &[String]) -> bool {
1628 for arg in args {
1629 if arg.starts_with("--output=") {
1630 return false;
1631 }
1632 match arg.as_str() {
1633 "--output" | "--ext-diff" | "--external-diff" | "--textconv" => return false,
1634 _ => {}
1635 }
1636 }
1637 true
1638}
1639
1640fn git_diff_args_read_only(args: &[String]) -> bool {
1641 if !args.iter().any(|a| a == "--no-index") {
1642 return true;
1643 }
1644 let paths = git_diff_no_index_paths(args);
1645 if paths.len() != 2 {
1646 return false;
1647 }
1648 git_relative_path_allowed(paths[0], true) && git_relative_path_allowed(paths[1], false)
1649}
1650
1651fn git_diff_no_index_paths(args: &[String]) -> Vec<&String> {
1652 let mut paths = Vec::with_capacity(2);
1653 let mut end_of_options = false;
1654 let mut i = 0;
1655 while i < args.len() {
1656 let arg = &args[i];
1657 if !end_of_options && arg == "--" {
1658 end_of_options = true;
1659 i += 1;
1660 continue;
1661 }
1662 if !end_of_options && arg.starts_with('-') {
1663 if git_diff_flag_consumes_next_arg(arg) && !arg.contains('=') {
1664 i += 1;
1665 }
1666 i += 1;
1667 continue;
1668 }
1669 paths.push(arg);
1670 i += 1;
1671 }
1672 paths
1673}
1674
1675fn git_diff_flag_consumes_next_arg(arg: &str) -> bool {
1676 matches!(
1680 arg,
1681 "--relative"
1682 | "--diff-filter"
1683 | "--word-diff-regex"
1684 | "--color-words"
1685 | "--ws-error-highlight"
1686 | "--abbrev"
1687 | "--break-rewrites"
1688 | "--find-renames"
1689 | "--find-copies"
1690 | "--diff-algorithm"
1691 | "--inter-hunk-context"
1692 | "-S"
1693 | "-G"
1694 | "-O"
1695 )
1696}
1697
1698fn git_relative_path_allowed(path: &str, allow_dev_null: bool) -> bool {
1699 let path = path.trim();
1700 if path.is_empty() {
1701 return false;
1702 }
1703 if allow_dev_null && path == "/dev/null" {
1704 return true;
1705 }
1706 if path.starts_with('/') || path.starts_with('~') || path.starts_with('-') {
1707 return false;
1708 }
1709 path.split('/')
1710 .all(|part| !part.is_empty() && part != "." && part != "..")
1711}
1712
1713fn arg_contains_unsafe_meta(arg: &str) -> bool {
1714 arg.contains(['$', '`', ';', '&', '|', '<', '>', '\n', '\r'])
1715}
1716
1717fn classify_bounded_write(lower: &[String]) -> Option<ShellRiskDecision> {
1720 match lower[0].as_str() {
1721 "go" => {
1722 if lower.len() >= 2 {
1723 match lower[1].as_str() {
1724 "test" => {
1725 if has_any_flag_prefix(&lower[2..], &["-exec", "-toolexec"]) {
1726 return Some(needs_approval(
1727 "go test can run an execution wrapper with this option",
1728 ));
1729 }
1730 if has_any_flag_prefix(&lower[2..], &["-c"]) {
1731 return Some(needs_approval("go test -c emits a test binary"));
1732 }
1733 if has_any_flag_prefix(
1734 &lower[2..],
1735 &[
1736 "-coverprofile",
1737 "-cpuprofile",
1738 "-memprofile",
1739 "-blockprofile",
1740 "-mutexprofile",
1741 "-trace",
1742 "-o",
1743 ],
1744 ) {
1745 return Some(needs_approval(
1746 "go test writes to an explicit output path with this option",
1747 ));
1748 }
1749 return Some(bounded_write(
1750 "go test may write build and test cache files".into(),
1751 ));
1752 }
1753 "build" => {
1754 if has_any_flag_prefix(&lower[2..], &["-o"]) {
1755 return Some(needs_approval(
1756 "go build writes to an explicit output path with this option",
1757 ));
1758 }
1759 return Some(needs_approval("go build may emit a workspace binary"));
1760 }
1761 "vet" => {
1762 return Some(bounded_write("go vet may write build cache files".into()))
1763 }
1764 _ => {}
1765 }
1766 }
1767 }
1768 "make" => {
1769 if lower.len() == 2 && MAKE_BOUNDED_TARGETS.contains(&lower[1].as_str()) {
1770 return Some(bounded_write(format!(
1771 "make {} may write project-local build or test artifacts",
1772 lower[1]
1773 )));
1774 }
1775 }
1776 "cargo" => {
1777 if lower.len() >= 2 {
1778 match lower[1].as_str() {
1779 "build" | "test" | "check" | "clippy" | "fmt" => {
1780 if has_any_flag_prefix(&lower[2..], &["--target-dir"]) {
1781 return Some(needs_approval(
1782 "cargo writes to an explicit target directory with this option",
1783 ));
1784 }
1785 return Some(bounded_write(format!(
1786 "cargo {} may write target build artifacts",
1787 lower[1]
1788 )));
1789 }
1790 _ => {}
1791 }
1792 }
1793 }
1794 "npm" | "pnpm" => {
1795 if lower.len() >= 2 {
1796 if lower[1] == "test" {
1797 return Some(bounded_write(format!(
1798 "{} test may write project-local test artifacts",
1799 lower[0]
1800 )));
1801 }
1802 if lower.len() >= 3 && lower[1] == "run" && npm_bounded_script(&lower[2]) {
1803 return Some(bounded_write(format!(
1804 "{} run {} may write project-local build or test artifacts",
1805 lower[0], lower[2]
1806 )));
1807 }
1808 }
1809 }
1810 "npx" => {
1811 if lower.len() >= 2 {
1812 match lower[1].as_str() {
1813 "jest" | "vitest" => {
1814 if has_known_test_output_flag(&lower[2..]) {
1815 return Some(needs_approval(
1816 "test runner writes to an explicit output path with this option",
1817 ));
1818 }
1819 return Some(bounded_write(format!(
1820 "npx {} may write project-local test artifacts",
1821 lower[1]
1822 )));
1823 }
1824 "tsc" if lower.len() >= 3 && lower[2] == "--noemit" => {
1825 return Some(bounded_write(
1826 "npx tsc --noEmit may write compiler cache files".into(),
1827 ));
1828 }
1829 _ => {}
1830 }
1831 }
1832 }
1833 "pytest" => {
1834 if has_known_test_output_flag(&lower[1..]) {
1835 return Some(needs_approval(
1836 "pytest writes to an explicit output path with this option",
1837 ));
1838 }
1839 return Some(bounded_write(
1840 "pytest may write project-local test artifacts".into(),
1841 ));
1842 }
1843 "python" | "python3" => {
1844 if lower.len() >= 3 && lower[1] == "-m" && lower[2] == "pytest" {
1845 if has_known_test_output_flag(&lower[3..]) {
1846 return Some(needs_approval(
1847 "pytest writes to an explicit output path with this option",
1848 ));
1849 }
1850 return Some(bounded_write(format!(
1851 "{} -m pytest may write project-local test artifacts",
1852 lower[0]
1853 )));
1854 }
1855 }
1856 "deno" | "bun" if lower.len() >= 2 && lower[1] == "test" => {
1857 return Some(bounded_write(format!(
1858 "{} test may write project-local test artifacts",
1859 lower[0]
1860 )));
1861 }
1862 _ => {}
1863 }
1864 None
1865}
1866
1867fn has_known_test_output_flag(args: &[String]) -> bool {
1868 args.iter().any(|arg| {
1869 arg == "--outputfile"
1870 || arg == "--output-file"
1871 || arg.starts_with("--outputfile=")
1872 || arg.starts_with("--output-file=")
1873 || arg == "--junitxml"
1874 || arg == "--junit-xml"
1875 || arg.starts_with("--junitxml=")
1876 || arg.starts_with("--junit-xml=")
1877 || arg == "--html"
1878 || arg.starts_with("--html=")
1879 || arg.starts_with("--cov-report=xml:")
1880 || arg.starts_with("--cov-report=html:")
1881 || arg.starts_with("--cov-report=lcov:")
1882 || arg.starts_with("--cov-report=json:")
1883 })
1884}
1885
1886fn has_any_flag_prefix(args: &[String], prefixes: &[&str]) -> bool {
1887 args.iter().any(|arg| {
1888 prefixes
1889 .iter()
1890 .any(|prefix| arg == prefix || arg.starts_with(&format!("{prefix}=")))
1891 })
1892}
1893
1894fn npm_bounded_script(script: &str) -> bool {
1895 matches!(script, "build" | "test" | "lint" | "typecheck")
1896}
1897
1898fn argv_has_prefix(argv: &[String], prefix: &str) -> bool {
1899 let prefix_argv: Vec<&str> = prefix.split_whitespace().collect();
1900 if argv.len() < prefix_argv.len() {
1901 return false;
1902 }
1903 prefix_argv
1904 .iter()
1905 .enumerate()
1906 .all(|(i, want)| argv[i] == *want)
1907}
1908
1909#[cfg(test)]
1910mod tests {
1911 use super::*;
1912
1913 fn level(command: &str) -> ShellRiskLevel {
1914 classify_shell_command(command).level
1915 }
1916
1917 #[test]
1920 fn read_only_commands_are_safe() {
1921 for cmd in [
1922 "ls -la",
1923 "pwd",
1924 "cat src/main.rs",
1925 "grep -rn pattern src",
1926 "rg TODO",
1927 "head -n 20 file.txt",
1928 "wc -l file.txt",
1929 "which cargo",
1930 "uname -a",
1931 "whoami",
1932 "date -u",
1933 "printf hello",
1934 "go version",
1935 "rustc --version",
1936 ] {
1937 assert_eq!(level(cmd), ShellRiskLevel::SafeRead, "command: {cmd}");
1938 }
1939 }
1940
1941 #[test]
1942 fn pipelines_of_read_only_commands_are_safe() {
1943 assert_eq!(
1944 level("cat file.txt | grep foo | wc -l"),
1945 ShellRiskLevel::SafeRead
1946 );
1947 assert_eq!(level("ls && pwd"), ShellRiskLevel::SafeRead);
1948 assert_eq!(level("pwd; ls"), ShellRiskLevel::SafeRead);
1949 }
1950
1951 #[test]
1952 fn trailing_stderr_redirect_is_transparent() {
1953 assert_eq!(level("ls -la 2>/dev/null"), ShellRiskLevel::SafeRead);
1954 assert_eq!(level("cat file 2>&1"), ShellRiskLevel::SafeRead);
1955 }
1956
1957 #[test]
1958 fn git_read_only_commands_are_safe() {
1959 for cmd in [
1960 "git status",
1961 "git log --oneline",
1962 "git diff HEAD~1",
1963 "git branch --show-current",
1964 "git remote -v",
1965 "git config --get user.name",
1966 ] {
1967 assert_eq!(level(cmd), ShellRiskLevel::SafeRead, "command: {cmd}");
1968 }
1969 }
1970
1971 #[test]
1972 fn git_mutating_commands_need_approval() {
1973 for cmd in [
1974 "git push origin main",
1975 "git commit -m x",
1976 "git checkout -b f",
1977 "git diff --output=/tmp/d.patch",
1978 "git -c core.editor=vim log",
1979 ] {
1980 assert_eq!(level(cmd), ShellRiskLevel::NeedsApproval, "command: {cmd}");
1981 }
1982 }
1983
1984 #[test]
1985 fn sed_stream_substitution_is_safe_but_in_place_is_not() {
1986 assert_eq!(level("sed s/foo/bar/g file.txt"), ShellRiskLevel::SafeRead);
1987 assert_eq!(level("sed -n 1,20p file.txt"), ShellRiskLevel::SafeRead);
1988 assert_eq!(
1989 level("sed -i s/foo/bar/ file.txt"),
1990 ShellRiskLevel::NeedsApproval
1991 );
1992 }
1993
1994 #[test]
1995 fn sort_uniq_display_safe_output_flags_not() {
1996 assert_eq!(level("sort -u file.txt"), ShellRiskLevel::SafeRead);
1997 assert_eq!(level("uniq -c file.txt"), ShellRiskLevel::SafeRead);
1998 assert_eq!(
1999 level("sort -o out.txt file.txt"),
2000 ShellRiskLevel::NeedsApproval
2001 );
2002 assert_eq!(
2003 level("uniq file.txt out.txt"),
2004 ShellRiskLevel::NeedsApproval
2005 );
2006 }
2007
2008 #[test]
2011 fn build_test_commands_are_bounded_writes() {
2012 for cmd in [
2013 "cargo test",
2014 "cargo check",
2015 "cargo clippy",
2016 "go test ./...",
2017 "go vet ./...",
2018 "npm test",
2019 "pnpm run build",
2020 "pytest",
2021 "python -m pytest tests",
2022 "make test",
2023 ] {
2024 assert_eq!(level(cmd), ShellRiskLevel::BoundedWrite, "command: {cmd}");
2025 }
2026 }
2027
2028 #[test]
2029 fn bounded_write_with_explicit_output_needs_approval() {
2030 for cmd in [
2031 "go test -coverprofile=cover.out ./...",
2032 "cargo build --target-dir /tmp/x",
2033 "pytest --junitxml=report.xml",
2034 "make test EXTRA=1",
2035 ] {
2036 assert_eq!(level(cmd), ShellRiskLevel::NeedsApproval, "command: {cmd}");
2037 }
2038 }
2039
2040 #[test]
2043 fn unparseable_or_unknown_commands_need_approval() {
2044 for cmd in [
2045 "curl https://example.com -o out.html",
2046 "echo $(whoami)",
2047 "ls > listing.txt",
2048 "ls *.rs",
2049 "foo || bar",
2050 "rm file.txt",
2051 "npm install",
2052 "pip install requests",
2053 ] {
2054 assert_eq!(level(cmd), ShellRiskLevel::NeedsApproval, "command: {cmd}");
2055 }
2056 }
2057
2058 #[test]
2059 fn unsafe_expansion_args_need_approval() {
2060 assert_eq!(level("echo `id`"), ShellRiskLevel::NeedsApproval);
2061 assert_eq!(
2062 level("find . -name x -delete"),
2063 ShellRiskLevel::NeedsApproval
2064 );
2065 assert_eq!(level("find . -exec rm {} +"), ShellRiskLevel::NeedsApproval);
2066 assert_eq!(level("rg --pre cat TODO"), ShellRiskLevel::NeedsApproval);
2067 assert_eq!(level("npx jest -u"), ShellRiskLevel::NeedsApproval);
2068 }
2069
2070 #[test]
2071 fn pipeline_with_non_read_only_stage_needs_approval() {
2072 assert_eq!(
2073 level("cat file.txt | tee out.txt"),
2074 ShellRiskLevel::NeedsApproval
2075 );
2076 assert_eq!(level("ls && cargo test"), ShellRiskLevel::NeedsApproval);
2077 }
2078
2079 #[test]
2082 fn recursive_delete_of_critical_paths_is_blocked() {
2083 for cmd in [
2084 "rm -rf /",
2085 "rm -rf /*",
2086 "rm -fr /usr",
2087 "rm -r /etc",
2088 "rm -rf /var/",
2089 "rm --recursive --force /bin",
2090 "rm --no-preserve-root -rf /",
2091 "sudo rm -rf /usr",
2092 ] {
2093 assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2094 }
2095 }
2096
2097 #[test]
2098 fn workspace_recursive_delete_is_not_blocked() {
2099 for cmd in ["rm -rf target", "rm -rf ./build", "rm -rf /tmp/scratch"] {
2100 assert_eq!(level(cmd), ShellRiskLevel::NeedsApproval, "command: {cmd}");
2101 }
2102 }
2103
2104 #[test]
2105 fn raw_device_writes_are_blocked() {
2106 for cmd in [
2107 "dd if=/dev/zero of=/dev/sda",
2108 "mkfs.ext4 /dev/sda1",
2109 "mkswap /dev/sda2",
2110 "wipefs -a /dev/sda",
2111 "echo x > /dev/sda",
2112 "cat data >> /dev/nvme0n1",
2113 "fdisk /dev/sda",
2114 ] {
2115 assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2116 }
2117 }
2118
2119 #[test]
2120 fn benign_device_usage_is_not_blocked() {
2121 assert_eq!(
2122 level("dd if=/dev/zero of=test.img bs=1M count=10"),
2123 ShellRiskLevel::NeedsApproval
2124 );
2125 assert_eq!(level("ls /dev/sda"), ShellRiskLevel::SafeRead);
2126 assert_eq!(level("fdisk -l"), ShellRiskLevel::NeedsApproval);
2127 }
2128
2129 #[test]
2130 fn system_lifecycle_commands_are_blocked() {
2131 for cmd in [
2132 "shutdown -h now",
2133 "reboot",
2134 "halt",
2135 "poweroff",
2136 "init 0",
2137 "telinit 6",
2138 "systemctl reboot",
2139 "systemctl poweroff",
2140 ] {
2141 assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2142 }
2143 assert_eq!(
2145 level("systemctl status nginx"),
2146 ShellRiskLevel::NeedsApproval
2147 );
2148 }
2149
2150 #[test]
2151 fn killing_pid_one_is_blocked() {
2152 for cmd in [
2153 "kill 1",
2154 "kill -9 1",
2155 "kill -TERM 1",
2156 "kill -9 -1",
2157 "killall5",
2158 ] {
2159 assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2160 }
2161 assert_eq!(level("kill -9 12345"), ShellRiskLevel::NeedsApproval);
2162 assert_eq!(level("kill -1 12345"), ShellRiskLevel::NeedsApproval); }
2164
2165 #[test]
2166 fn fork_bomb_is_blocked() {
2167 assert_eq!(level(":(){ :|:& };:"), ShellRiskLevel::Blocked);
2168 assert_eq!(level("bomb(){ bomb|bomb& };bomb"), ShellRiskLevel::Blocked);
2169 }
2170
2171 #[test]
2172 fn permission_sweep_on_system_paths_is_blocked() {
2173 assert_eq!(level("chmod -R 777 /"), ShellRiskLevel::Blocked);
2174 assert_eq!(level("chmod -R 000 /usr"), ShellRiskLevel::Blocked);
2175 assert_eq!(level("chown -R nobody /etc"), ShellRiskLevel::Blocked);
2176 assert_eq!(
2178 level("chmod -R 755 ./scripts"),
2179 ShellRiskLevel::NeedsApproval
2180 );
2181 }
2182
2183 #[test]
2184 fn deny_scan_sees_through_compound_syntax() {
2185 for cmd in [
2186 "ls; rm -rf /usr",
2187 "true && rm -rf /etc",
2188 "false || rm -rf /var",
2189 "echo hi | tee log; reboot",
2190 "(rm -rf /usr)",
2191 "echo $(rm -rf /etc)",
2192 "bash -c 'rm -rf /usr'",
2193 "sudo sh -c \"rm -rf /etc\"",
2194 "env FOO=1 rm -rf /usr",
2195 "nohup reboot",
2196 "timeout 30 rm -rf /etc",
2197 ] {
2198 assert_eq!(level(cmd), ShellRiskLevel::Blocked, "command: {cmd}");
2199 }
2200 }
2201
2202 #[test]
2203 fn quoted_destructive_text_is_not_blocked() {
2204 assert_eq!(level("echo 'rm -rf /usr'"), ShellRiskLevel::SafeRead);
2206 assert_eq!(level("grep 'rm -rf /' README.md"), ShellRiskLevel::SafeRead);
2207 }
2208
2209 #[test]
2210 fn decisions_carry_reasons() {
2211 let decision = classify_shell_command("rm -rf /");
2212 assert_eq!(decision.level, ShellRiskLevel::Blocked);
2213 assert!(!decision.reason.is_empty());
2214 assert_eq!(decision.level.as_str(), "blocked");
2215 }
2216}