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