1use std::collections::{BTreeSet, HashMap};
19use std::path::{Component, Path, PathBuf};
20
21use car_ir::Action;
22use car_policy::{InspectionResult, Inspector, InspectorChain, PolicyEngine, PolicyRules};
23use car_state::StateStore;
24use serde_json::Value;
25
26pub fn coder_inspector_chain(worktree: &Path) -> InspectorChain {
28 InspectorChain::new()
29 .with(Box::new(DenyGitRemoteMutation))
30 .with(Box::new(DenyForgePublication))
31 .with(Box::new(DenyHistoryRewrite))
32 .with(Box::new(DenyPrivilegeEscalation))
33 .with(Box::new(DenyCredentialAccess))
34 .with(Box::new(DenyEnvironmentRepair))
35 .with(Box::new(DenyDestructiveOutsideWorktree {
36 worktree: worktree.to_path_buf(),
37 }))
38 .with(Box::new(DenyPathEscape {
39 worktree: worktree.to_path_buf(),
40 }))
41}
42
43pub struct CoderPolicy {
64 pub chain: InspectorChain,
65 pub denied_tools: BTreeSet<String>,
66}
67
68pub fn coder_inspector_chain_with_project_policies(
69 worktree: &Path,
70) -> Result<CoderPolicy, car_policy::PolicyLoadError> {
71 let dirs = [
72 car_home::root_or_relative().join("policies"),
73 worktree.join(".car").join("policies"),
74 ];
75 coder_inspector_chain_from_policy_dirs(worktree, &dirs)
76}
77
78fn coder_inspector_chain_from_policy_dirs(
79 worktree: &Path,
80 dirs: &[PathBuf],
81) -> Result<CoderPolicy, car_policy::PolicyLoadError> {
82 let mut rules = PolicyRules::default();
83 for dir in dirs {
84 rules.merge(car_policy::load_policy_dir(dir)?);
85 }
86
87 let mut engine = PolicyEngine::new();
88 rules.apply(&mut engine);
89 let denied_tools = engine.blanket_denied_tools();
91 Ok(CoderPolicy {
92 chain: coder_inspector_chain(worktree).with(Box::new(ProjectPolicyInspector {
93 engine,
94 state: StateStore::new(),
95 })),
96 denied_tools,
97 })
98}
99
100struct ProjectPolicyInspector {
104 engine: PolicyEngine,
105 state: StateStore,
106}
107
108impl Inspector for ProjectPolicyInspector {
109 fn name(&self) -> &'static str {
110 "project_policy"
111 }
112
113 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
114 let mut action = Action::tool_call(tool);
117 action.id = "coder-policy-check".to_string();
118 action.parameters = params
119 .as_object()
120 .map(|m| {
121 m.iter()
122 .map(|(key, value)| (key.clone(), value.clone()))
123 .collect::<HashMap<_, _>>()
124 })
125 .unwrap_or_default();
126
127 match self.engine.check(&action, &self.state).into_iter().next() {
128 Some(violation) => InspectionResult::Deny(format!(
129 "operator policy '{}': {}",
130 violation.policy_name, violation.reason
131 )),
132 None => InspectionResult::Allow,
133 }
134 }
135}
136
137struct DenyGovernedShellPathEscape {
142 worktree: PathBuf,
143 oldpwd_is_worktree: bool,
147 cargo_target_dir: Option<PathBuf>,
151}
152
153impl DenyGovernedShellPathEscape {
154 fn new(worktree: &Path) -> Self {
155 let inherited_path = |name: &str| {
156 std::env::var_os(name).and_then(|value| {
157 let path = PathBuf::from(value);
158 (!path.as_os_str().is_empty()).then(|| {
159 if path.is_absolute() {
160 path
161 } else {
162 worktree.join(path)
163 }
164 })
165 })
166 };
167 let oldpwd_is_worktree = inherited_path("OLDPWD").is_some_and(|oldpwd| {
168 match (oldpwd.canonicalize(), worktree.canonicalize()) {
169 (Ok(oldpwd), Ok(worktree)) => oldpwd == worktree,
170 _ => false,
171 }
172 });
173 Self {
174 worktree: worktree.to_path_buf(),
175 oldpwd_is_worktree,
176 cargo_target_dir: inherited_path("CARGO_TARGET_DIR"),
177 }
178 }
179}
180
181const READ_OR_CHDIR_VERBS: &[&str] = &[
182 "cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "sed", "awk", "find",
183 "ls", "stat", "wc", "strings", "readlink", "realpath", "cd", "type",
184];
185
186fn skip_program_whitespace(program: &str, index: &mut usize) {
188 while let Some(ch) = program[*index..].chars().next() {
189 if !ch.is_whitespace() {
190 break;
191 }
192 *index += ch.len_utf8();
193 }
194}
195
196fn consume_sed_address(program: &str, index: &mut usize) -> bool {
200 let Some(first) = program[*index..].chars().next() else {
201 return false;
202 };
203 match first {
204 '0'..='9' => {
205 while let Some(ch) = program[*index..].chars().next() {
206 if !ch.is_ascii_digit() {
207 break;
208 }
209 *index += ch.len_utf8();
210 }
211 true
212 }
213 '$' => {
214 *index += 1;
215 true
216 }
217 '/' => {
218 *index += 1;
219 let mut escaped = false;
220 while let Some(ch) = program[*index..].chars().next() {
221 *index += ch.len_utf8();
222 if escaped {
223 escaped = false;
224 } else if ch == '\\' {
225 escaped = true;
226 } else if ch == '/' {
227 return true;
228 }
229 }
230 true
231 }
232 '\\' => {
235 *index += 1;
236 let Some(delimiter) = program[*index..].chars().next() else {
237 return true;
238 };
239 *index += delimiter.len_utf8();
240 let mut escaped = false;
241 while let Some(ch) = program[*index..].chars().next() {
242 *index += ch.len_utf8();
243 if escaped {
244 escaped = false;
245 } else if ch == '\\' {
246 escaped = true;
247 } else if ch == delimiter {
248 return true;
249 }
250 }
251 true
252 }
253 _ => false,
254 }
255}
256
257fn consume_sed_delimited_field(statement: &str, index: &mut usize, delimiter: char) -> bool {
259 let mut escaped = false;
260 while let Some(ch) = statement[*index..].chars().next() {
261 *index += ch.len_utf8();
262 if escaped {
263 escaped = false;
264 } else if ch == '\\' {
265 escaped = true;
266 } else if ch == delimiter {
267 return true;
268 }
269 }
270 false
271}
272
273fn sed_line_end(program: &str, index: usize) -> usize {
274 program[index..]
275 .find('\n')
276 .map_or(program.len(), |offset| index + offset)
277}
278
279fn skip_to_sed_separator(program: &str, index: &mut usize) {
280 while let Some(ch) = program[*index..].chars().next() {
281 if matches!(ch, ';' | '\n' | '{' | '}') {
282 break;
283 }
284 *index += ch.len_utf8();
285 }
286}
287
288fn sed_program_paths(program: &str) -> Result<Vec<String>, String> {
294 let mut paths = Vec::new();
295 let mut index = 0;
296 while index < program.len() {
297 skip_program_whitespace(program, &mut index);
298 while let Some(separator) = program[index..].chars().next() {
299 if !matches!(separator, ';' | '{' | '}') {
300 break;
301 }
302 index += separator.len_utf8();
303 skip_program_whitespace(program, &mut index);
304 }
305 if index == program.len() {
306 break;
307 }
308
309 loop {
310 if !consume_sed_address(program, &mut index) {
311 break;
312 }
313 skip_program_whitespace(program, &mut index);
314 if program[index..].starts_with(',') || program[index..].starts_with('~') {
315 index += 1;
316 skip_program_whitespace(program, &mut index);
317 continue;
318 }
319 break;
320 }
321 skip_program_whitespace(program, &mut index);
322 if program[index..].starts_with('!') {
323 index += 1;
324 skip_program_whitespace(program, &mut index);
325 }
326 let Some(command) = program[index..].chars().next() else {
327 break;
328 };
329 index += command.len_utf8();
330 match command {
331 'r' | 'R' | 'w' | 'W' => {
332 let end = sed_line_end(program, index);
335 let operand = program[index..end].trim();
336 if !operand.is_empty() {
337 paths.push(operand.to_string());
338 }
339 index = end;
340 }
341 's' => {
342 let Some(delimiter) = program[index..].chars().next() else {
343 break;
344 };
345 index += delimiter.len_utf8();
346 if !consume_sed_delimited_field(program, &mut index, delimiter)
347 || !consume_sed_delimited_field(program, &mut index, delimiter)
348 {
349 continue;
350 }
351 while let Some(flag) = program[index..].chars().next() {
352 match flag {
353 'e' => {
354 return Err(
355 "sed shell execution through the e flag is not allowed in a governed session"
356 .to_string(),
357 )
358 }
359 'w' => {
360 index += flag.len_utf8();
361 let end = sed_line_end(program, index);
362 let operand = program[index..end].trim();
363 if !operand.is_empty() {
364 paths.push(operand.to_string());
365 }
366 index = end;
367 break;
368 }
369 ';' | '\n' | '{' | '}' => break,
370 ch if ch.is_whitespace()
371 || ch.is_ascii_digit()
372 || matches!(ch, 'g' | 'i' | 'I' | 'm' | 'M' | 'p') =>
373 {
374 index += ch.len_utf8();
375 }
376 _ => {
377 skip_to_sed_separator(program, &mut index);
380 break;
381 }
382 }
383 }
384 }
385 'y' => {
386 let Some(delimiter) = program[index..].chars().next() else {
390 break;
391 };
392 index += delimiter.len_utf8();
393 if !consume_sed_delimited_field(program, &mut index, delimiter)
394 || !consume_sed_delimited_field(program, &mut index, delimiter)
395 {
396 continue;
397 }
398 }
399 'e' => return Err(
400 "sed shell execution through the e command is not allowed in a governed session"
401 .to_string(),
402 ),
403 'a' | 'c' | 'i' | '#' => index = sed_line_end(program, index),
407 ':' | 'b' | 'l' | 'q' | 'Q' | 't' | 'T' | 'v' => {
409 skip_to_sed_separator(program, &mut index)
410 }
411 _ => {}
412 }
413 }
414 Ok(paths)
415}
416
417fn awk_program_tokens(program: &str) -> Vec<String> {
418 fn finish(token: &mut String, result: &mut Vec<String>) {
419 if !token.is_empty() {
420 result.push(std::mem::take(token));
421 }
422 }
423
424 let mut result = Vec::new();
425 let mut token = String::new();
426 let mut quote = None;
427 let mut escaped = false;
428 let mut chars = program.chars().peekable();
429 while let Some(ch) = chars.next() {
430 if let Some(delimiter) = quote {
431 if escaped {
432 token.push(ch);
433 escaped = false;
434 } else if ch == '\\' {
435 escaped = true;
436 } else if ch == delimiter {
437 quote = None;
438 finish(&mut token, &mut result);
439 } else {
440 token.push(ch);
441 }
442 continue;
443 }
444 match ch {
445 '"' | '\'' => {
446 finish(&mut token, &mut result);
447 quote = Some(ch);
448 }
449 ch if ch.is_whitespace() => finish(&mut token, &mut result),
450 '<' | '>' | '|' => {
451 finish(&mut token, &mut result);
452 let mut operator = ch.to_string();
453 if (ch == '>' && chars.peek() == Some(&'>'))
454 || (ch == '|' && chars.peek() == Some(&'&'))
455 {
456 let joined = chars.next().expect("peeked operator suffix");
457 operator.push(joined);
458 }
459 result.push(operator);
460 }
461 ';' | '{' | '}' | '(' | ')' | ',' => {
462 finish(&mut token, &mut result);
463 result.push(ch.to_string());
464 }
465 _ => token.push(ch),
466 }
467 }
468 finish(&mut token, &mut result);
469 result
470}
471
472fn awk_program_paths(program: &str) -> Result<Vec<String>, String> {
477 let tokens = awk_program_tokens(program);
478 if tokens
479 .windows(2)
480 .any(|pair| pair[0] == "system" && pair[1] == "(")
481 {
482 return Err("awk system() is not allowed in a governed session".to_string());
483 }
484 for (index, token) in tokens.iter().enumerate() {
485 if !matches!(token.as_str(), "|" | "|&") {
486 continue;
487 }
488 let start = tokens[..index]
489 .iter()
490 .rposition(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
491 .map_or(0, |position| position + 1);
492 let end = tokens[index + 1..]
493 .iter()
494 .position(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
495 .map_or(tokens.len(), |offset| index + 1 + offset);
496 if tokens[start..index]
497 .iter()
498 .any(|candidate| matches!(candidate.as_str(), "print" | "printf"))
499 || tokens[index + 1..end]
500 .iter()
501 .any(|candidate| candidate == "getline")
502 {
503 return Err("awk command pipes are not allowed in a governed session".to_string());
504 }
505 }
506
507 let mut paths = Vec::new();
508 for (index, token) in tokens.iter().enumerate() {
509 let wanted = match token.as_str() {
510 "getline" => &["<"][..],
511 "print" | "printf" => &[">", ">>"][..],
512 _ => continue,
513 };
514 let end = tokens[index + 1..]
515 .iter()
516 .position(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
517 .map_or(tokens.len(), |offset| index + 1 + offset);
518 let Some(operator) = tokens[index + 1..end]
519 .iter()
520 .position(|candidate| wanted.contains(&candidate.as_str()))
521 .map(|offset| index + 1 + offset)
522 else {
523 continue;
524 };
525 if let Some(path) = tokens[operator + 1..end]
526 .iter()
527 .find(|candidate| candidate.as_str() != "(")
528 {
529 paths.push(path.clone());
530 }
531 }
532 Ok(paths)
533}
534
535fn inline_program_paths(verb: &str, program: &str) -> Result<Vec<String>, String> {
536 match verb {
537 "sed" => sed_program_paths(program),
538 "awk" => awk_program_paths(program),
539 _ => Ok(Vec::new()),
540 }
541}
542
543fn append_inline_program_paths(
544 verb: &str,
545 program: &str,
546 paths: &mut Vec<String>,
547) -> Result<(), String> {
548 paths.extend(inline_program_paths(verb, program)?);
549 Ok(())
550}
551
552fn has_brace_expansion_comma(source: &str) -> bool {
558 let mut braces = Vec::new();
559 let mut escaped = false;
560 for ch in source.chars() {
561 if escaped {
562 escaped = false;
563 continue;
564 }
565 match ch {
566 '\\' => escaped = true,
567 '{' => braces.push(false),
568 ',' => {
569 if let Some(brace) = braces.last_mut() {
570 *brace = true;
571 }
572 }
573 '}' if braces.pop().is_some_and(|has_comma| has_comma) => return true,
574 _ => {}
575 }
576 }
577 false
578}
579
580fn reject_shell_fed_program_source(verb: &str, source: &str) -> Result<(), String> {
581 let is_program_channel = source.is_empty()
584 || source == "-"
585 || source == "/dev/stdin"
586 || source
587 .strip_prefix("/dev/fd/")
588 .is_some_and(|fd| !fd.is_empty())
589 || source
590 .strip_prefix("/proc/self/fd/")
591 .is_some_and(|fd| !fd.is_empty())
592 || source.starts_with("<(")
593 || source.starts_with(">(")
594 || source.contains('$')
595 || source.contains('`')
596 || has_brace_expansion_comma(source);
597 if is_program_channel {
598 return Err(format!(
599 "{verb} script source '{source}' cannot be inspected in a governed session"
600 ));
601 }
602 Ok(())
603}
604
605fn append_program_source_path(
606 verb: &str,
607 source: &str,
608 paths: &mut Vec<String>,
609) -> Result<(), String> {
610 reject_shell_fed_program_source(verb, source)?;
611 paths.push(source.to_string());
612 Ok(())
613}
614
615fn reject_shell_fed_program_file_operands(verb: &str, args: &[String]) -> Result<(), String> {
622 for (index, arg) in args.iter().enumerate() {
623 if arg == "--" {
624 break;
625 }
626 let is_file_option = matches!(
627 (verb, arg.as_str()),
628 ("sed", "-f" | "--file") | ("awk", "-f" | "-E" | "--file")
629 );
630 if is_file_option {
631 let source = args.get(index + 1).map_or("", String::as_str);
632 reject_shell_fed_program_source(verb, source)?;
633 }
634 }
635 Ok(())
636}
637
638fn governed_path_arguments(verb: &str, tokens: &[String]) -> Result<Vec<String>, String> {
649 let Some(verb_index) = tokens.iter().position(|token| !token.contains('=')) else {
650 return Ok(Vec::new());
651 };
652 let args = &tokens[verb_index + 1..];
653 if !matches!(verb, "sed" | "awk") {
654 return Ok(args
655 .iter()
656 .filter(|arg| !arg.starts_with('-'))
657 .cloned()
658 .collect());
659 }
660
661 reject_shell_fed_program_file_operands(verb, args)?;
662
663 let mut paths = Vec::new();
664 let mut explicit_program = false;
665 let mut positional_program_seen = false;
666 let mut options = true;
667 let mut index = 0;
668 while index < args.len() {
669 let arg = args[index].as_str();
670 if options && arg == "--" {
671 options = false;
672 index += 1;
673 continue;
674 }
675 if options && arg.starts_with('-') && arg != "-" {
676 match (verb, arg) {
677 ("sed", "-e" | "--expression") | ("awk", "-e" | "--source") => {
678 explicit_program = true;
679 if let Some(program) = args.get(index + 1) {
680 append_inline_program_paths(verb, program, &mut paths)?;
681 }
682 index += 2;
683 continue;
684 }
685 ("sed", "-f" | "--file") | ("awk", "-f" | "-E" | "--file") => {
686 explicit_program = true;
687 if let Some(source) = args.get(index + 1) {
688 append_program_source_path(verb, source, &mut paths)?;
689 }
690 index += 2;
691 continue;
692 }
693 ("awk", "-F" | "-v") => {
694 index += 2; continue;
696 }
697 _ => {}
698 }
699 if let Some(source) = arg.strip_prefix("--file=") {
700 explicit_program = true;
701 append_program_source_path(verb, source, &mut paths)?;
702 index += 1;
703 continue;
704 }
705 if let Some(program) = arg
706 .strip_prefix("--expression=")
707 .or_else(|| arg.strip_prefix("--source="))
708 {
709 explicit_program = true;
710 append_inline_program_paths(verb, program, &mut paths)?;
711 index += 1;
712 continue;
713 }
714
715 if verb == "sed" && !arg.starts_with("--") {
716 let cluster = &arg[1..];
721 let mut cursor = 0;
722 let mut handled = true;
723 while let Some(option) = cluster[cursor..].chars().next() {
724 cursor += option.len_utf8();
725 match option {
726 'n' | 'E' | 'r' | 's' | 'u' | 'z' => {}
727 'e' | 'f' => {
728 explicit_program = true;
729 let attached = &cluster[cursor..];
730 if option == 'e' {
731 if attached.is_empty() {
732 if let Some(program) = args.get(index + 1) {
733 append_inline_program_paths("sed", program, &mut paths)?;
734 }
735 index += 2;
736 } else {
737 append_inline_program_paths("sed", attached, &mut paths)?;
738 index += 1;
739 }
740 } else if attached.is_empty() {
741 if let Some(source) = args.get(index + 1) {
742 append_program_source_path("sed", source, &mut paths)?;
743 }
744 index += 2;
745 } else {
746 append_program_source_path("sed", attached, &mut paths)?;
747 index += 1;
748 }
749 break;
750 }
751 'i' => {
752 index += 1;
755 break;
756 }
757 _ => {
758 handled = false;
759 break;
760 }
761 }
762 }
763 if handled {
764 if cursor == cluster.len()
765 && !matches!(cluster.chars().last(), Some('e' | 'f' | 'i'))
766 {
767 index += 1;
768 }
769 continue;
770 }
771 }
772
773 let attached_file_source = arg
774 .strip_prefix("-f")
775 .filter(|source| !source.is_empty())
776 .or_else(|| {
777 if verb == "awk" {
778 arg.strip_prefix("-E").filter(|source| !source.is_empty())
779 } else {
780 None
781 }
782 });
783 if let Some(source) = attached_file_source {
784 explicit_program = true;
785 append_program_source_path(verb, source, &mut paths)?;
786 } else if let Some(program) = arg.strip_prefix("-e").filter(|p| !p.is_empty()) {
787 explicit_program = true;
788 append_inline_program_paths(verb, program, &mut paths)?;
789 }
790 index += 1;
791 continue;
792 }
793
794 if !explicit_program && !positional_program_seen {
795 positional_program_seen = true;
796 append_inline_program_paths(verb, arg, &mut paths)?;
797 } else {
798 paths.push(arg.to_string());
799 }
800 index += 1;
801 }
802 Ok(paths)
803}
804
805fn join_shell_line_continuations(command: &str) -> String {
810 let mut logical = String::with_capacity(command.len());
811 let mut chars = command.chars().peekable();
812 while let Some(ch) = chars.next() {
813 if ch == '\\' {
814 if chars.peek() == Some(&'\n') {
815 chars.next();
816 continue;
817 }
818 if chars.peek() == Some(&'\r') {
819 let mut lookahead = chars.clone();
820 if lookahead.next() == Some('\r') && lookahead.next() == Some('\n') {
821 chars.next();
822 chars.next();
823 continue;
824 }
825 }
826 }
827 logical.push(ch);
828 }
829 logical
830}
831
832fn governed_shell_segments(command: &str) -> Vec<Vec<String>> {
839 fn finish_token(
840 token: &mut String,
841 started: &mut bool,
842 attached_empty: &mut bool,
843 tokens: &mut Vec<String>,
844 ) {
845 if std::mem::take(started) {
846 tokens.push(std::mem::take(token));
847 if std::mem::take(attached_empty) {
848 tokens.push(String::new());
849 }
850 }
851 }
852 fn finish_segment(
853 token: &mut String,
854 started: &mut bool,
855 attached_empty: &mut bool,
856 tokens: &mut Vec<String>,
857 result: &mut Vec<Vec<String>>,
858 ) {
859 finish_token(token, started, attached_empty, tokens);
860 if !tokens.is_empty() {
861 result.push(std::mem::take(tokens));
862 }
863 }
864
865 let mut result = Vec::new();
866 let mut tokens = Vec::new();
867 let mut token = String::new();
868 let mut token_started = false;
869 let mut attached_empty = false;
870 let mut quote = None;
871 let logical_command = join_shell_line_continuations(command);
872 let mut chars = logical_command.chars().peekable();
873 while let Some(ch) = chars.next() {
874 if let Some((delimiter, start_len, was_attached)) = quote {
875 if ch == delimiter {
876 quote = None;
877 if was_attached && token.len() == start_len {
878 attached_empty = true;
879 }
880 } else if delimiter == '"' && ch == '\\' {
881 if chars
884 .peek()
885 .is_some_and(|next| matches!(next, '$' | '`' | '"' | '\\'))
886 {
887 token.push(chars.next().expect("peeked escaped character"));
888 } else {
889 token.push(ch);
890 }
891 attached_empty = false;
892 } else {
893 token.push(ch);
894 attached_empty = false;
895 }
896 continue;
897 }
898 match ch {
899 '\\' => {
907 if let Some(escaped) = chars.next() {
908 token.push(escaped);
909 } else {
910 token.push(ch);
911 }
912 token_started = true;
913 attached_empty = false;
914 }
915 '\'' | '"' => {
916 quote = Some((ch, token.len(), token_started));
917 token_started = true;
918 }
919 '\n' | '\r' => finish_segment(
923 &mut token,
924 &mut token_started,
925 &mut attached_empty,
926 &mut tokens,
927 &mut result,
928 ),
929 ch if ch.is_whitespace() => finish_token(
930 &mut token,
931 &mut token_started,
932 &mut attached_empty,
933 &mut tokens,
934 ),
935 ';' | ';' | '|' | '&' => {
936 finish_segment(
937 &mut token,
938 &mut token_started,
939 &mut attached_empty,
940 &mut tokens,
941 &mut result,
942 );
943 if matches!(ch, '|' | '&') && chars.peek() == Some(&ch) {
944 chars.next();
945 }
946 }
947 _ => {
948 token.push(ch);
949 token_started = true;
950 attached_empty = false;
951 }
952 }
953 }
954 finish_segment(
955 &mut token,
956 &mut token_started,
957 &mut attached_empty,
958 &mut tokens,
959 &mut result,
960 );
961 result
962}
963
964#[derive(Clone, Copy)]
968struct CommandCarrier {
969 name: &'static str,
970 short_value_options: &'static [char],
971 long_value_options: &'static [&'static str],
972 leading_operands: usize,
973 assignments: bool,
974}
975
976const COMMAND_CARRIERS: &[CommandCarrier] = &[
977 CommandCarrier {
978 name: "env",
979 short_value_options: &['a', 'C', 'S', 'u'],
980 long_value_options: &["--argv0", "--chdir", "--split-string", "--unset"],
981 leading_operands: 0,
982 assignments: true,
983 },
984 CommandCarrier {
985 name: "command",
986 short_value_options: &[],
987 long_value_options: &[],
988 leading_operands: 0,
989 assignments: false,
990 },
991 CommandCarrier {
992 name: "exec",
993 short_value_options: &['a'],
994 long_value_options: &[],
995 leading_operands: 0,
996 assignments: false,
997 },
998 CommandCarrier {
999 name: "nohup",
1000 short_value_options: &[],
1001 long_value_options: &[],
1002 leading_operands: 0,
1003 assignments: false,
1004 },
1005 CommandCarrier {
1006 name: "time",
1007 short_value_options: &['f', 'o'],
1008 long_value_options: &["--format", "--output"],
1009 leading_operands: 0,
1010 assignments: false,
1011 },
1012 CommandCarrier {
1013 name: "nice",
1014 short_value_options: &['n'],
1015 long_value_options: &["--adjustment"],
1016 leading_operands: 0,
1017 assignments: false,
1018 },
1019 CommandCarrier {
1020 name: "caffeinate",
1021 short_value_options: &['t', 'w'],
1022 long_value_options: &[],
1023 leading_operands: 0,
1024 assignments: false,
1025 },
1026 CommandCarrier {
1027 name: "script",
1028 short_value_options: &['B', 'F', 'I', 'O', 'T', 'c', 'm', 't'],
1029 long_value_options: &[
1030 "--command",
1031 "--log-in",
1032 "--log-io",
1033 "--log-out",
1034 "--log-timing",
1035 "--logging-format",
1036 ],
1037 leading_operands: 1,
1040 assignments: false,
1041 },
1042 CommandCarrier {
1043 name: "xargs",
1044 short_value_options: &['E', 'I', 'J', 'L', 'P', 'R', 'S', 'a', 'd', 'n', 's'],
1045 long_value_options: &[
1046 "--arg-file",
1047 "--delimiter",
1048 "--eof",
1049 "--max-args",
1050 "--max-chars",
1051 "--max-lines",
1052 "--max-procs",
1053 "--replace",
1054 ],
1055 leading_operands: 0,
1056 assignments: false,
1057 },
1058 CommandCarrier {
1059 name: "builtin",
1060 short_value_options: &[],
1061 long_value_options: &[],
1062 leading_operands: 0,
1063 assignments: false,
1064 },
1065 CommandCarrier {
1066 name: "busybox",
1069 short_value_options: &[],
1070 long_value_options: &[],
1071 leading_operands: 0,
1072 assignments: false,
1073 },
1074 CommandCarrier {
1075 name: "sudo",
1076 short_value_options: &['C', 'D', 'R', 'T', 'a', 'g', 'h', 'p', 'r', 't', 'u'],
1077 long_value_options: &[
1078 "--auth-type",
1079 "--chdir",
1080 "--chroot",
1081 "--close-from",
1082 "--command-timeout",
1083 "--group",
1084 "--host",
1085 "--prompt",
1086 "--role",
1087 "--type",
1088 "--user",
1089 ],
1090 leading_operands: 0,
1091 assignments: false,
1092 },
1093 CommandCarrier {
1094 name: "ionice",
1095 short_value_options: &['P', 'c', 'n', 'p', 'u'],
1096 long_value_options: &["--class", "--classdata", "--pgid", "--pid", "--uid"],
1097 leading_operands: 0,
1098 assignments: false,
1099 },
1100 CommandCarrier {
1101 name: "timeout",
1102 short_value_options: &['k', 's'],
1103 long_value_options: &["--kill-after", "--signal"],
1104 leading_operands: 1,
1106 assignments: false,
1107 },
1108];
1109
1110fn executable_name(raw: &str) -> String {
1111 let lower = raw.to_ascii_lowercase();
1112 let name = Path::new(&lower)
1113 .file_name()
1114 .and_then(|name| name.to_str())
1115 .unwrap_or(&lower);
1116 name.strip_suffix(".exe").unwrap_or(name).to_string()
1117}
1118
1119#[derive(Clone, Copy)]
1120struct ShellAssignmentParts<'a> {
1121 name: &'a str,
1122 subscript: Option<&'a str>,
1123 value: &'a str,
1124}
1125
1126fn shell_assignment_parts(token: &str) -> Option<ShellAssignmentParts<'_>> {
1127 let name_length = shell_name_len(token);
1128 if name_length == 0 {
1129 return None;
1130 }
1131 let name = &token[..name_length];
1132 let remainder = &token[name_length..];
1133 let (subscript, operator) = if remainder.starts_with('[') {
1134 let mut depth = 0usize;
1135 let mut close = None;
1136 for (index, ch) in remainder.char_indices() {
1137 match ch {
1138 '[' => depth += 1,
1139 ']' => {
1140 depth = depth.checked_sub(1)?;
1141 if depth == 0 {
1142 close = Some(index);
1143 break;
1144 }
1145 }
1146 _ => {}
1147 }
1148 }
1149 let close = close?;
1150 (Some(&remainder[1..close]), &remainder[close + 1..])
1151 } else {
1152 (None, remainder)
1153 };
1154 let value = operator
1158 .strip_prefix("+=")
1159 .or_else(|| operator.strip_prefix('='))?;
1160 Some(ShellAssignmentParts {
1161 name,
1162 subscript,
1163 value,
1164 })
1165}
1166
1167fn shell_subscript_contains_command_substitution(subscript: &str) -> bool {
1168 if subscript.contains('`') {
1169 return true;
1170 }
1171 let mut cursor = 0usize;
1172 while let Some(relative) = subscript[cursor..].find("$(") {
1173 let start = cursor + relative;
1174 if subscript[start + 2..].starts_with('(') {
1177 cursor = start + 3;
1178 } else {
1179 return true;
1180 }
1181 }
1182 false
1183}
1184
1185#[derive(Clone, Copy, PartialEq, Eq)]
1188enum SubscriptFlaw {
1189 CommandSubstitution,
1191 BreaksTokenization,
1195}
1196
1197fn subscript_flaw(subscript: &str) -> Option<SubscriptFlaw> {
1202 if shell_subscript_contains_command_substitution(subscript) {
1203 return Some(SubscriptFlaw::CommandSubstitution);
1204 }
1205 subscript
1206 .chars()
1207 .any(|ch| ch.is_whitespace() || matches!(ch, ';' | ';' | '|' | '&' | '\'' | '"' | '\\'))
1208 .then_some(SubscriptFlaw::BreaksTokenization)
1209}
1210
1211fn uninspectable_assignment_subscripts(command: &str) -> Vec<(&str, SubscriptFlaw)> {
1216 let bytes = command.as_bytes();
1217 let mut found = Vec::new();
1218 let mut index = 0usize;
1219 let mut quote = None;
1220 let mut word_boundary = true;
1221 while index < bytes.len() {
1222 let byte = bytes[index];
1223 if let Some(delimiter) = quote {
1224 if byte == delimiter {
1225 quote = None;
1226 } else if delimiter == b'"' && byte == b'\\' {
1227 index += usize::from(index + 1 < bytes.len());
1228 }
1229 index += 1;
1230 continue;
1231 }
1232 match byte {
1233 b'\\' => {
1234 index += 1 + usize::from(index + 1 < bytes.len());
1235 word_boundary = false;
1236 }
1237 b'\'' | b'"' => {
1238 quote = Some(byte);
1239 word_boundary = false;
1240 index += 1;
1241 }
1242 b' ' | b'\t' | b'\r' | b'\n' | b';' | b'|' | b'&' | b'(' => {
1243 word_boundary = true;
1244 index += 1;
1245 }
1246 b'_' | b'a'..=b'z' | b'A'..=b'Z' if word_boundary => {
1247 let name_start = index;
1248 index += 1;
1249 while index < bytes.len()
1250 && matches!(bytes[index], b'_' | b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9')
1251 {
1252 index += 1;
1253 }
1254 if bytes.get(index) == Some(&b'[') {
1255 let subscript_start = index + 1;
1256 let mut close_search = subscript_start;
1257 while let Some(relative) = command[close_search..].find(']') {
1258 let close = close_search + relative;
1259 let after = &command[close + 1..];
1260 if after.starts_with('=') || after.starts_with("+=") {
1261 let subscript = &command[subscript_start..close];
1262 if let Some(flaw) = subscript_flaw(subscript) {
1263 found.push((&command[name_start..index], flaw));
1264 }
1265 break;
1266 }
1267 close_search = close + 1;
1268 }
1269 }
1270 word_boundary = false;
1271 }
1272 _ => {
1273 word_boundary = false;
1274 index += 1;
1275 }
1276 }
1277 }
1278 found
1279}
1280
1281fn shell_assignment(token: &str) -> bool {
1282 shell_assignment_parts(token).is_some()
1283}
1284
1285fn carrier_option_consumes_next(spec: CommandCarrier, option: &str) -> bool {
1286 if spec.long_value_options.contains(&option) {
1287 return true;
1288 }
1289 if option.starts_with("--") {
1290 return false;
1291 }
1292
1293 let mut options = option.strip_prefix('-').unwrap_or("").chars().peekable();
1294 while let Some(option) = options.next() {
1295 if spec.short_value_options.contains(&option) {
1296 return options.peek().is_none();
1299 }
1300 }
1301 false
1302}
1303
1304struct CarrierInvocation<'a> {
1305 command: &'a [String],
1306 assignments: Vec<&'a str>,
1307}
1308
1309fn carrier_command<'a>(
1314 tokens: &'a [String],
1315 verb_index: usize,
1316 verb: &str,
1317) -> Option<CarrierInvocation<'a>> {
1318 let spec = *COMMAND_CARRIERS.iter().find(|spec| spec.name == verb)?;
1319 let args = &tokens[verb_index + 1..];
1320 if verb == "command"
1324 && args
1325 .iter()
1326 .take_while(|arg| arg.as_str() != "--")
1327 .any(|arg| {
1328 arg.strip_prefix('-')
1329 .filter(|short| !short.starts_with('-'))
1330 .is_some_and(|short| short.contains('v') || short.contains('V'))
1331 })
1332 {
1333 return Some(CarrierInvocation {
1334 command: &[],
1335 assignments: Vec::new(),
1336 });
1337 }
1338 let mut index = 0;
1339 let mut options = true;
1340 let mut assignments = Vec::new();
1341 while index < args.len() {
1342 let arg = args[index].as_str();
1343 if options && arg == "--" {
1344 options = false;
1345 index += 1;
1346 continue;
1347 }
1348 if options && arg.starts_with('-') && arg != "-" {
1349 let consumes_next = !arg.contains('=') && carrier_option_consumes_next(spec, arg);
1350 index += 1 + usize::from(consumes_next && index + 1 < args.len());
1351 continue;
1352 }
1353 if spec.assignments && shell_assignment(arg) {
1354 assignments.push(arg);
1355 index += 1;
1356 continue;
1357 }
1358 break;
1359 }
1360 index = (index + spec.leading_operands).min(args.len());
1361 Some(CarrierInvocation {
1362 command: &args[index..],
1363 assignments,
1364 })
1365}
1366
1367fn has_short_command_flag(args: &[String], wanted: char, value_options: &[char]) -> bool {
1370 let mut index = 0;
1371 while index < args.len() {
1372 let arg = args[index].as_str();
1373 if arg == "--" || !arg.starts_with('-') || arg == "-" {
1374 break;
1375 }
1376 let Some(short) = arg
1377 .strip_prefix('-')
1378 .filter(|short| !short.starts_with('-'))
1379 else {
1380 index += 1;
1381 continue;
1382 };
1383 let mut options = short.chars().peekable();
1384 while let Some(option) = options.next() {
1385 if option == wanted {
1386 return true;
1387 }
1388 if value_options.contains(&option) {
1389 if options.peek().is_none() {
1390 index += 1;
1391 }
1392 break;
1393 }
1394 }
1395 index += 1;
1396 }
1397 false
1398}
1399
1400fn shell_has_script_operand(args: &[String]) -> bool {
1401 let mut index = 0;
1402 while index < args.len() {
1403 let arg = args[index].as_str();
1404 if arg == "--" {
1405 return index + 1 < args.len();
1406 }
1407 if !arg.starts_with('-') || arg == "-" {
1408 return true;
1409 }
1410 let consumes_next = matches!(arg, "-O" | "-o" | "--init-file" | "--rcfile");
1411 index += 1 + usize::from(consumes_next && index + 1 < args.len());
1412 }
1413 false
1414}
1415
1416fn nested_command_carrier(verb: &str, args: &[String]) -> bool {
1417 if verb == "eval" {
1418 return true;
1419 }
1420 if verb == "env" {
1424 return has_short_command_flag(args, 'S', &['a', 'C', 'S', 'u'])
1425 || args
1426 .iter()
1427 .any(|arg| arg == "--split-string" || arg.starts_with("--split-string="));
1428 }
1429 if matches!(
1430 verb,
1431 "bash" | "sh" | "zsh" | "dash" | "ksh" | "fish" | "ash"
1432 ) {
1433 return has_short_command_flag(args, 'c', &['O', 'o'])
1434 || (verb == "fish"
1435 && (has_short_command_flag(args, 'C', &['C'])
1436 || args.iter().any(|arg| {
1437 matches!(arg.as_str(), "--command" | "--init-command")
1438 || arg.starts_with("--command=")
1439 || arg.starts_with("--init-command=")
1440 })))
1441 || !shell_has_script_operand(args);
1442 }
1443 if verb == "script" {
1444 return has_short_command_flag(args, 'c', &['B', 'F', 'I', 'O', 'T', 'c', 'm', 't'])
1445 || args
1446 .iter()
1447 .any(|arg| arg == "--command" || arg.starts_with("--command="));
1448 }
1449 if verb == "perl" {
1450 const PERL_VALUE_OPTIONS: &[char] =
1453 &['0', 'C', 'D', 'F', 'I', 'M', 'V', 'd', 'i', 'l', 'm', 'x'];
1454 return has_short_command_flag(args, 'e', PERL_VALUE_OPTIONS)
1455 || has_short_command_flag(args, 'E', PERL_VALUE_OPTIONS);
1456 }
1457 if matches!(verb, "ruby" | "node" | "osascript") {
1458 let value_options: &[char] = match verb {
1459 "ruby" => &['0', 'C', 'E', 'F', 'I', 'S', 'i', 'r', 'x'],
1460 "node" => &['C', 'r'],
1461 "osascript" => &['l'],
1462 _ => unreachable!("matched interpreter"),
1463 };
1464 return has_short_command_flag(args, 'e', value_options)
1465 || (verb == "node"
1466 && args
1467 .iter()
1468 .any(|arg| arg == "--eval" || arg.starts_with("--eval=")));
1469 }
1470 if verb == "python"
1471 || verb == "py"
1472 || verb == "python3"
1473 || verb
1474 .strip_prefix("python3.")
1475 .is_some_and(|minor| minor.chars().all(|ch| ch.is_ascii_digit()))
1476 {
1477 return has_short_command_flag(args, 'c', &['W', 'X', 'Q', 'm']);
1478 }
1479 false
1480}
1481
1482struct LeadingShellVariable<'a> {
1483 modifier: &'a str,
1487 suffix: &'a str,
1489}
1490
1491fn shell_name_len(value: &str) -> usize {
1492 let mut chars = value.char_indices();
1493 let Some((_, first)) = chars.next() else {
1494 return 0;
1495 };
1496 if first != '_' && !first.is_ascii_alphabetic() {
1497 return 0;
1498 }
1499 chars
1500 .take_while(|(_, ch)| *ch == '_' || ch.is_ascii_alphanumeric())
1501 .last()
1502 .map_or(first.len_utf8(), |(index, ch)| index + ch.len_utf8())
1503}
1504
1505fn leading_shell_variable(candidate: &str) -> Option<LeadingShellVariable<'_>> {
1509 if let Some(rest) = candidate.strip_prefix("${") {
1510 let length = shell_name_len(rest);
1511 if length == 0 {
1512 return None;
1513 }
1514 let mut depth = 1usize;
1515 let mut close = None;
1516 let mut index = length;
1517 while index < rest.len() {
1518 if rest[index..].starts_with("${") {
1519 depth += 1;
1520 index += 2;
1521 continue;
1522 }
1523 let ch = rest[index..].chars().next().expect("index is in bounds");
1524 if ch == '}' {
1525 depth -= 1;
1526 if depth == 0 {
1527 close = Some(index);
1528 break;
1529 }
1530 }
1531 index += ch.len_utf8();
1532 }
1533 let close = close?;
1534 let suffix = &rest[close + 1..];
1535 if !suffix.is_empty() && !suffix.starts_with('/') {
1536 return None;
1537 }
1538 return Some(LeadingShellVariable {
1539 modifier: &rest[length..close],
1540 suffix,
1541 });
1542 }
1543
1544 let rest = candidate.strip_prefix('$')?;
1545 let length = shell_name_len(rest);
1546 if length == 0 {
1547 return None;
1548 }
1549 let suffix = &rest[length..];
1550 if !suffix.is_empty() && !suffix.starts_with('/') {
1551 return None;
1552 }
1553 Some(LeadingShellVariable {
1554 modifier: "",
1555 suffix,
1556 })
1557}
1558
1559#[derive(Clone, Copy)]
1560struct ShellVariableReference<'a> {
1561 name: &'a str,
1562 start: usize,
1563 end: usize,
1564}
1565
1566fn shell_variable_references(candidate: &str) -> Result<Vec<ShellVariableReference<'_>>, ()> {
1570 let mut references = Vec::new();
1571 let mut cursor = 0;
1572 while let Some(relative) = candidate[cursor..].find('$') {
1573 let start = cursor + relative;
1574 let after_dollar = start + 1;
1575 let rest = &candidate[after_dollar..];
1576 if let Some(braced) = rest.strip_prefix('{') {
1577 let length = shell_name_len(braced);
1578 if length == 0 || !braced[length..].starts_with('}') {
1579 return Err(());
1580 }
1581 let end = after_dollar + 1 + length + 1;
1582 references.push(ShellVariableReference {
1583 name: &braced[..length],
1584 start,
1585 end,
1586 });
1587 cursor = end;
1588 } else {
1589 let length = shell_name_len(rest);
1590 if length == 0 {
1591 return Err(());
1592 }
1593 let end = after_dollar + length;
1594 references.push(ShellVariableReference {
1595 name: &rest[..length],
1596 start,
1597 end,
1598 });
1599 cursor = end;
1600 }
1601 }
1602 Ok(references)
1603}
1604
1605fn resolved_shell_variable<'a>(
1606 gate: &'a DenyGovernedShellPathEscape,
1607 name: &str,
1608) -> Option<&'a Path> {
1609 match name {
1610 "PWD" => Some(gate.worktree.as_path()),
1611 "OLDPWD" if gate.oldpwd_is_worktree => Some(gate.worktree.as_path()),
1612 "CARGO_TARGET_DIR" => gate.cargo_target_dir.as_deref(),
1613 _ => None,
1614 }
1615}
1616
1617fn variable_operand_denial(
1621 gate: &DenyGovernedShellPathEscape,
1622 verb: &str,
1623 candidate: &str,
1624) -> Option<String> {
1625 if !candidate.contains('$') {
1626 return None;
1627 }
1628
1629 if let Some(operand) = leading_shell_variable(candidate) {
1632 if operand.suffix.is_empty()
1633 && operand.modifier.is_empty()
1634 && gate.worktree.join(candidate).exists()
1635 && stays_under(&gate.worktree, candidate)
1636 {
1637 return None;
1638 }
1639 }
1640
1641 let references = match shell_variable_references(candidate) {
1642 Ok(references) if !references.is_empty() => references,
1643 _ => {
1644 return Some(format!(
1645 "'{verb}' variable operand cannot be inspected: '{candidate}'"
1646 ));
1647 }
1648 };
1649 let mut expanded = String::with_capacity(candidate.len());
1650 let mut copied = 0;
1651 for reference in references {
1652 let Some(value) = resolved_shell_variable(gate, reference.name) else {
1653 return Some(format!(
1654 "'{verb}' variable operand cannot be inspected: '{candidate}'"
1655 ));
1656 };
1657 expanded.push_str(&candidate[copied..reference.start]);
1658 expanded.push_str(&value.to_string_lossy());
1659 copied = reference.end;
1660 }
1661 expanded.push_str(&candidate[copied..]);
1662
1663 let expanded_path = Path::new(&expanded);
1664 let stays_governed = if expanded_path.is_absolute() {
1665 stays_under(&gate.worktree, &expanded)
1666 || gate
1667 .cargo_target_dir
1668 .as_deref()
1669 .is_some_and(|root| stays_under(root, &expanded))
1670 } else {
1671 stays_under(&gate.worktree, &expanded)
1672 };
1673 if stays_governed {
1674 None
1675 } else {
1676 Some(format!(
1677 "'{verb}' variable path '{candidate}' resolves outside its governed root"
1678 ))
1679 }
1680}
1681
1682fn governed_operand_prefix_variables(segment: &[String]) -> BTreeSet<String> {
1683 let mut names = BTreeSet::new();
1684 let mut command = segment;
1685 while let Some(verb_index) = command.iter().position(|token| !shell_assignment(token)) {
1686 let verb = executable_name(&command[verb_index]);
1687 if READ_OR_CHDIR_VERBS.contains(&verb.as_str()) {
1688 if let Ok(paths) = governed_path_arguments(&verb, command) {
1689 for path in paths {
1690 let candidate = path
1691 .trim_matches(|c: char| matches!(c, '"' | '\'' | '(' | ')' | ',' | ';'));
1692 if let Ok(references) = shell_variable_references(candidate) {
1693 if let Some(reference) = references.first().filter(|item| item.start == 0) {
1694 names.insert(reference.name.to_string());
1695 }
1696 }
1697 }
1698 }
1699 }
1700 let Some(invocation) = carrier_command(command, verb_index, &verb) else {
1701 break;
1702 };
1703 if invocation.command.is_empty() {
1704 break;
1705 }
1706 command = invocation.command;
1707 }
1708 names
1709}
1710
1711fn shell_root_escape(candidate: &str) -> bool {
1712 ["HOME", "TMPDIR"].iter().any(|name| {
1713 let unbraced = format!("${name}");
1714 let unbraced_match = candidate.strip_prefix(&unbraced).is_some_and(|suffix| {
1715 suffix
1716 .chars()
1717 .next()
1718 .is_none_or(|ch| ch != '_' && !ch.is_ascii_alphanumeric())
1719 });
1720 let braced = format!("${{{name}");
1721 let braced_match = candidate.strip_prefix(&braced).is_some_and(|suffix| {
1722 suffix
1723 .chars()
1724 .next()
1725 .is_some_and(|ch| ch != '_' && !ch.is_ascii_alphanumeric())
1726 });
1727 unbraced_match || braced_match
1728 })
1729}
1730
1731fn execution_redirecting_variable(name: &str) -> bool {
1732 matches!(
1733 name,
1734 "BASH_ENV"
1735 | "ENV"
1736 | "PATH"
1737 | "LD_PRELOAD"
1738 | "PYTHONPATH"
1739 | "PERL5LIB"
1740 | "RUBYLIB"
1741 | "NODE_OPTIONS"
1742 | "CARGO_HOME"
1743 | "RUSTUP_HOME"
1744 | "GIT_EXEC_PATH"
1745 | "GIT_SSH_COMMAND"
1746 ) || name.starts_with("DYLD_")
1747}
1748
1749fn command_opens_editor(verb: &str, args: &[String]) -> bool {
1750 (verb == "git"
1751 && args.iter().any(|arg| {
1752 matches!(
1753 arg.as_str(),
1754 "add" | "commit" | "config" | "merge" | "rebase" | "tag"
1755 )
1756 }))
1757 || matches!(verb, "crontab" | "vipw" | "vigr" | "visudo")
1758}
1759
1760fn assignment_value_names_path(value: &str) -> bool {
1761 value.contains('/')
1762 || value.contains('\\')
1763 || value.starts_with('~')
1764 || shell_root_escape(value)
1765 || value.contains("..")
1766 || (value.as_bytes().get(1) == Some(&b':')
1767 && value
1768 .as_bytes()
1769 .first()
1770 .is_some_and(u8::is_ascii_alphabetic))
1771}
1772
1773fn effective_command(mut command: &[String]) -> Option<(String, &[String])> {
1774 loop {
1775 let verb_index = command.iter().position(|token| !shell_assignment(token))?;
1776 let current_verb = executable_name(&command[verb_index]);
1777 let args = &command[verb_index + 1..];
1778 let Some(invocation) = carrier_command(command, verb_index, ¤t_verb) else {
1779 return Some((current_verb, args));
1780 };
1781 if invocation.command.is_empty() {
1782 return Some((current_verb, args));
1783 }
1784 command = invocation.command;
1785 }
1786}
1787
1788struct AssignmentBuiltinOperands<'a> {
1789 assignments: Vec<&'a str>,
1790 removed_names: Vec<&'a str>,
1791}
1792
1793fn assignment_builtin_operands<'a>(
1798 command: &'a [String],
1799 verb_index: usize,
1800 verb: &str,
1801) -> AssignmentBuiltinOperands<'a> {
1802 let args = &command[verb_index + 1..];
1803 let assignment_builtin = matches!(
1804 verb,
1805 "export" | "readonly" | "declare" | "typeset" | "local"
1806 );
1807 let assignments = if assignment_builtin {
1808 args.iter()
1809 .map(String::as_str)
1810 .filter(|arg| shell_assignment(arg))
1811 .collect()
1812 } else {
1813 Vec::new()
1814 };
1815
1816 let removes_export_attribute = verb == "export"
1817 && args
1818 .iter()
1819 .take_while(|arg| arg.as_str() != "--")
1820 .filter_map(|arg| arg.strip_prefix('-'))
1821 .any(|options| !options.starts_with('-') && options.contains('n'));
1822 let removes_variables = verb == "unset" || removes_export_attribute;
1823 let removed_names = if removes_variables {
1824 args.iter()
1825 .skip_while(|arg| arg.starts_with('-') && arg.as_str() != "--")
1826 .filter(|arg| arg.as_str() != "--")
1827 .map(String::as_str)
1828 .filter(|arg| shell_name_len(arg) == arg.len())
1829 .collect()
1830 } else {
1831 Vec::new()
1832 };
1833
1834 AssignmentBuiltinOperands {
1835 assignments,
1836 removed_names,
1837 }
1838}
1839
1840fn assignment_name_denial(
1841 name: &str,
1842 opens_editor: bool,
1843 operand_prefix_variables: &BTreeSet<String>,
1844) -> Option<String> {
1845 if execution_redirecting_variable(name) || (matches!(name, "EDITOR" | "VISUAL") && opens_editor)
1846 {
1847 return Some(format!(
1848 "environment assignment '{name}' may redirect executable code in a governed session"
1849 ));
1850 }
1851 if matches!(
1852 name,
1853 "PWD" | "OLDPWD" | "CARGO_TARGET_DIR" | "HOME" | "TMPDIR" | "IFS" | "PATH"
1854 ) || operand_prefix_variables.contains(name)
1855 {
1856 return Some(format!(
1857 "environment assignment '{name}' may change a governed path operand"
1858 ));
1859 }
1860 None
1861}
1862
1863fn assignment_denial(
1864 worktree: &Path,
1865 command: &[String],
1866 operand_prefix_variables: &BTreeSet<String>,
1867) -> Option<String> {
1868 let opens_editor =
1869 effective_command(command).is_some_and(|(verb, args)| command_opens_editor(&verb, args));
1870 let mut current = command;
1871 loop {
1872 let verb_index = current
1875 .iter()
1876 .position(|token| !shell_assignment(token))
1877 .unwrap_or(current.len());
1878 let invocation = if verb_index < current.len() {
1879 let current_verb = executable_name(¤t[verb_index]);
1880 carrier_command(current, verb_index, ¤t_verb)
1881 } else {
1882 None
1883 };
1884 let carrier_assignments = invocation
1885 .as_ref()
1886 .into_iter()
1887 .flat_map(|invocation| invocation.assignments.iter().copied());
1888 let builtin_operands = if verb_index < current.len() {
1889 assignment_builtin_operands(current, verb_index, &executable_name(¤t[verb_index]))
1890 } else {
1891 AssignmentBuiltinOperands {
1892 assignments: Vec::new(),
1893 removed_names: Vec::new(),
1894 }
1895 };
1896 let assignments = current[..verb_index]
1897 .iter()
1898 .map(String::as_str)
1899 .chain(carrier_assignments)
1900 .chain(builtin_operands.assignments);
1901 for assignment in assignments {
1902 let Some(parts) = shell_assignment_parts(assignment) else {
1903 continue;
1904 };
1905 if parts
1906 .subscript
1907 .is_some_and(shell_subscript_contains_command_substitution)
1908 {
1909 return Some(format!(
1910 "environment assignment '{}' has a subscript that cannot be inspected",
1911 parts.name
1912 ));
1913 }
1914 if let Some(reason) =
1915 assignment_name_denial(parts.name, opens_editor, operand_prefix_variables)
1916 {
1917 return Some(reason);
1918 }
1919 if assignment_value_names_path(parts.value) && !stays_under(worktree, parts.value) {
1920 return Some(format!(
1921 "environment assignment '{}' names path '{}' outside the governed repository",
1922 parts.name, parts.value
1923 ));
1924 }
1925 }
1926 for name in builtin_operands.removed_names {
1927 if let Some(reason) =
1928 assignment_name_denial(name, opens_editor, operand_prefix_variables)
1929 {
1930 return Some(reason);
1931 }
1932 }
1933 let Some(invocation) = invocation else {
1934 break;
1935 };
1936 if invocation.command.is_empty() {
1937 break;
1938 }
1939 current = invocation.command;
1940 }
1941 None
1942}
1943
1944fn uninspectable_subscript_denial(
1951 command: &str,
1952 segments: &[Vec<String>],
1953 operand_prefix_variables: &BTreeSet<String>,
1954) -> Option<String> {
1955 let candidates = uninspectable_assignment_subscripts(command);
1956 if candidates.is_empty() {
1957 return None;
1958 }
1959 let opens_editor = segments.iter().any(|segment| {
1960 effective_command(segment).is_some_and(|(verb, args)| command_opens_editor(&verb, args))
1961 });
1962 for (name, flaw) in candidates {
1963 match flaw {
1964 SubscriptFlaw::CommandSubstitution => {
1965 return Some(format!(
1966 "environment assignment '{name}' has a subscript that cannot be inspected"
1967 ));
1968 }
1969 SubscriptFlaw::BreaksTokenization => {
1970 if let Some(reason) =
1971 assignment_name_denial(name, opens_editor, operand_prefix_variables)
1972 {
1973 return Some(reason);
1974 }
1975 }
1976 }
1977 }
1978 None
1979}
1980
1981impl Inspector for DenyGovernedShellPathEscape {
1982 fn name(&self) -> &'static str {
1983 "governed_host.deny_shell_path_escape"
1984 }
1985
1986 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
1987 let Some(cmd) = shell_command(tool, params) else {
1988 return InspectionResult::Allow;
1989 };
1990 let segments = governed_shell_segments(&cmd);
1991 let mut future_operand_prefix_variables = vec![BTreeSet::new(); segments.len()];
1992 let mut suffix_variables = BTreeSet::new();
1993 for (index, segment) in segments.iter().enumerate().rev() {
1994 suffix_variables.extend(governed_operand_prefix_variables(segment));
1995 future_operand_prefix_variables[index] = suffix_variables.clone();
1996 }
1997 if let Some(reason) = uninspectable_subscript_denial(&cmd, &segments, &suffix_variables) {
2002 return InspectionResult::Deny(reason);
2003 }
2004 for (index, seg) in segments.into_iter().enumerate() {
2005 if let Some(reason) = assignment_denial(
2006 &self.worktree,
2007 &seg,
2008 &future_operand_prefix_variables[index],
2009 ) {
2010 return InspectionResult::Deny(reason);
2011 }
2012 let mut command = seg.as_slice();
2013 while let Some(verb_index) = command.iter().position(|token| !shell_assignment(token)) {
2014 let v = executable_name(&command[verb_index]);
2015 let args = &command[verb_index + 1..];
2016 if v == "busybox"
2017 && args
2018 .iter()
2019 .any(|arg| arg == "--install" || arg.starts_with("--install="))
2020 {
2021 return InspectionResult::Deny(
2022 "busybox --install may write outside the governed repository".into(),
2023 );
2024 }
2025 if nested_command_carrier(&v, args) {
2026 return InspectionResult::Deny(format!(
2027 "nested shell command through '{v}' cannot be inspected in a governed session"
2028 ));
2029 }
2030 if READ_OR_CHDIR_VERBS.contains(&v.as_str()) {
2031 let governed_args = match governed_path_arguments(&v, command) {
2032 Ok(paths) => paths,
2033 Err(reason) => return InspectionResult::Deny(reason),
2034 };
2035 for arg in governed_args {
2036 let candidate = arg.trim_matches(|c: char| {
2037 matches!(c, '"' | '\'' | '(' | ')' | ',' | ';')
2038 });
2039 if let Some(reason) = variable_operand_denial(self, &v, candidate) {
2040 return InspectionResult::Deny(reason);
2041 }
2042 let names_path = candidate.starts_with('~')
2043 || shell_root_escape(candidate)
2044 || is_abs_or_traversal(candidate)
2045 || self.worktree.join(candidate).exists();
2046 if names_path && !stays_under(&self.worktree, candidate) {
2047 return InspectionResult::Deny(format!(
2048 "'{v}' path '{candidate}' resolves outside the governed repository"
2049 ));
2050 }
2051 }
2052 }
2053
2054 let Some(invocation) = carrier_command(command, verb_index, &v) else {
2055 break;
2056 };
2057 if invocation.command.is_empty() {
2058 break;
2059 }
2060 command = invocation.command;
2061 }
2062 }
2063 InspectionResult::Allow
2064 }
2065}
2066
2067struct DenyGovernedFilePathEscape {
2070 worktree: PathBuf,
2071}
2072
2073impl Inspector for DenyGovernedFilePathEscape {
2074 fn name(&self) -> &'static str {
2075 "governed_host.deny_file_path_escape"
2076 }
2077
2078 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2079 if !matches!(
2080 tool,
2081 "read_file" | "write_file" | "edit_file" | "grep_files"
2082 ) {
2083 return InspectionResult::Allow;
2084 }
2085 let Some(path) = params.get("path").and_then(Value::as_str) else {
2086 return InspectionResult::Allow;
2087 };
2088 if stays_under(&self.worktree, path) {
2089 InspectionResult::Allow
2090 } else {
2091 InspectionResult::Deny(format!(
2092 "file access to '{path}' resolves outside the governed repository"
2093 ))
2094 }
2095 }
2096}
2097
2098pub fn governed_host_inspector_chain(worktree: &Path) -> InspectorChain {
2103 InspectorChain::new()
2104 .with(Box::new(DenyGuiShellAutomation))
2105 .with(Box::new(DenyForcePushAndRemoteReconfiguration))
2106 .with(Box::new(DenyBroadGitStage))
2107 .with(Box::new(DenyHistoryRewrite))
2108 .with(Box::new(DenyPrivilegeEscalation))
2109 .with(Box::new(DenyCredentialAccess))
2110 .with(Box::new(DenyEnvironmentRepair))
2111 .with(Box::new(DenyDestructiveOutsideWorktree {
2112 worktree: worktree.to_path_buf(),
2113 }))
2114 .with(Box::new(DenyGovernedShellPathEscape::new(worktree)))
2115 .with(Box::new(DenyGovernedFilePathEscape {
2116 worktree: worktree.to_path_buf(),
2117 }))
2118}
2119
2120struct DenyGuiShellAutomation;
2124
2125impl Inspector for DenyGuiShellAutomation {
2126 fn name(&self) -> &'static str {
2127 "governed_host.deny_gui_shell_automation"
2128 }
2129
2130 fn inspect(&self, tool: &str, _params: &Value) -> InspectionResult {
2131 if matches!(tool, "run_applescript" | "run_powershell") {
2132 InspectionResult::Deny(
2133 "desktop-driven shell execution is not allowed; use the governed shell tool".into(),
2134 )
2135 } else {
2136 InspectionResult::Allow
2137 }
2138 }
2139}
2140
2141struct DenyBroadGitStage;
2144
2145impl Inspector for DenyBroadGitStage {
2146 fn name(&self) -> &'static str {
2147 "governed_host.deny_broad_git_stage"
2148 }
2149
2150 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2151 let Some(cmd) = shell_command(tool, params) else {
2152 return InspectionResult::Allow;
2153 };
2154 for seg in segments(&cmd) {
2155 if verb(&seg) != Some("git") {
2156 continue;
2157 }
2158 let add = seg.iter().position(|token| token == "add");
2159 if let Some(index) = add {
2160 if seg
2161 .iter()
2162 .skip(index + 1)
2163 .any(|token| matches!(token.as_str(), "." | "-A" | "--all" | "-u" | "--update"))
2164 {
2165 return InspectionResult::Deny(
2166 "broad git staging is not allowed; name only the files changed for this task"
2167 .into(),
2168 );
2169 }
2170 }
2171 if seg.iter().any(|token| token == "commit")
2172 && seg.iter().any(|token| {
2173 token == "--all"
2174 || token
2175 .strip_prefix('-')
2176 .filter(|short| !short.starts_with('-'))
2177 .is_some_and(|short| short.contains('a'))
2178 })
2179 {
2180 return InspectionResult::Deny(
2181 "git commit -a is not allowed; stage only explicit task files".into(),
2182 );
2183 }
2184 }
2185 InspectionResult::Allow
2186 }
2187}
2188
2189pub(crate) fn stays_under(root: &Path, candidate: &str) -> bool {
2193 if candidate.starts_with('~') || shell_root_escape(candidate) {
2198 return false;
2199 }
2200 if let Some(variable) = leading_shell_variable(candidate) {
2205 if !variable.suffix.is_empty()
2206 || !variable.modifier.is_empty()
2207 || !root.join(candidate).exists()
2208 {
2209 return false;
2210 }
2211 }
2212 let p = Path::new(candidate);
2213 let joined = if p.is_absolute() {
2214 p.to_path_buf()
2215 } else {
2216 root.join(p)
2217 };
2218 if joined.exists() {
2224 if let (Ok(real_root), Ok(real_candidate)) = (root.canonicalize(), joined.canonicalize()) {
2225 return path_starts_with(&real_candidate, &real_root);
2226 }
2227 return false;
2228 }
2229 if let Ok(real_root) = root.canonicalize() {
2230 let mut ancestor = joined.as_path();
2231 while !ancestor.exists() {
2232 let Some(parent) = ancestor.parent() else {
2233 return false;
2234 };
2235 ancestor = parent;
2236 }
2237 match ancestor.canonicalize() {
2238 Ok(real_ancestor) if path_starts_with(&real_ancestor, &real_root) => {}
2239 _ => return false,
2240 }
2241 }
2242 let mut stack: Vec<Component> = Vec::new();
2243 for c in joined.components() {
2244 match c {
2245 Component::CurDir => {}
2246 Component::ParentDir => {
2247 if stack.pop().is_none() {
2248 return false;
2249 }
2250 }
2251 other => stack.push(other),
2252 }
2253 }
2254 let normalized: PathBuf = stack.iter().collect();
2255 path_starts_with(&normalized, root)
2256}
2257
2258#[cfg(not(windows))]
2264fn path_starts_with(path: &Path, base: &Path) -> bool {
2265 path.starts_with(base)
2266}
2267
2268#[cfg(windows)]
2269fn path_starts_with(path: &Path, base: &Path) -> bool {
2270 fn key(p: &Path) -> String {
2271 let s = p.to_string_lossy().into_owned();
2272 let s = if let Some(r) = s.strip_prefix(r"\\?\UNC\") {
2273 format!(r"\\{r}")
2274 } else if let Some(r) = s.strip_prefix(r"\\?\") {
2275 r.to_string()
2276 } else {
2277 s
2278 };
2279 s.replace('/', "\\").to_ascii_lowercase()
2280 }
2281 let base_key = key(base);
2282 let base_trim = base_key.trim_end_matches('\\');
2283 let path_key = key(path);
2284 path_key == base_trim || path_key.starts_with(&format!("{base_trim}\\"))
2285}
2286
2287fn is_abs_or_traversal(arg: &str) -> bool {
2293 arg.starts_with('/')
2294 || arg.starts_with('\\')
2295 || arg.contains("..")
2296 || Path::new(arg).is_absolute()
2297}
2298
2299fn is_windows_switch(arg: &str) -> bool {
2305 #[cfg(not(windows))]
2306 {
2307 let _ = arg;
2308 false
2309 }
2310 #[cfg(windows)]
2311 {
2312 arg.strip_prefix('/')
2313 .map(|rest| {
2314 (1..=2).contains(&rest.len()) && rest.chars().all(|c| c.is_ascii_alphanumeric())
2315 })
2316 .unwrap_or(false)
2317 }
2318}
2319
2320fn segments(command: &str) -> Vec<Vec<String>> {
2327 let outer: Vec<Vec<String>> = join_shell_line_continuations(command)
2328 .replace("&&", "\n")
2329 .replace("||", "\n")
2330 .replace([';', ';', '|'], "\n")
2331 .lines()
2332 .map(|seg| {
2333 seg.split_whitespace()
2334 .map(|t| t.trim_matches(|c| c == '"' || c == '\'').to_string())
2335 .filter(|t| !t.is_empty())
2336 .collect::<Vec<_>>()
2337 })
2338 .filter(|toks: &Vec<String>| !toks.is_empty())
2339 .collect();
2340
2341 let mut expanded = Vec::new();
2342 for segment in outer {
2343 expanded.push(segment.clone());
2344 let mut command = segment.as_slice();
2345 while let Some(verb_index) = command.iter().position(|token| !shell_assignment(token)) {
2346 let v = executable_name(&command[verb_index]);
2347 let Some(invocation) = carrier_command(command, verb_index, &v) else {
2348 break;
2349 };
2350 if invocation.command.is_empty() {
2351 break;
2352 }
2353 expanded.push(invocation.command.to_vec());
2354 command = invocation.command;
2355 }
2356 }
2357 expanded
2358}
2359
2360fn verb(tokens: &[String]) -> Option<&str> {
2362 tokens.iter().map(String::as_str).find(|t| !t.contains('='))
2363}
2364
2365fn shell_command(tool: &str, params: &Value) -> Option<String> {
2366 if tool != "shell" {
2367 return None;
2368 }
2369 params
2370 .get("command")
2371 .and_then(Value::as_str)
2372 .map(str::to_string)
2373}
2374
2375struct DenyGitRemoteMutation;
2378
2379impl Inspector for DenyGitRemoteMutation {
2380 fn name(&self) -> &'static str {
2381 "coder.deny_git_remote_mutation"
2382 }
2383
2384 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2385 let Some(cmd) = shell_command(tool, params) else {
2386 return InspectionResult::Allow;
2387 };
2388 for seg in segments(&cmd) {
2389 let is_git = verb(&seg) == Some("git");
2390 if !is_git {
2391 continue;
2392 }
2393 if seg.iter().any(|t| t == "push") {
2394 return InspectionResult::Deny(
2395 "git push is not allowed from a coder session — results are delivered \
2396 via the approved local branch"
2397 .into(),
2398 );
2399 }
2400 if seg.iter().any(|t| t == "remote")
2401 && seg
2402 .iter()
2403 .any(|t| t == "add" || t == "set-url" || t == "remove")
2404 {
2405 return InspectionResult::Deny("mutating git remotes is not allowed".into());
2406 }
2407 }
2408 InspectionResult::Allow
2409 }
2410}
2411
2412struct DenyForgePublication;
2437
2438const FORGE_VERBS: &[&str] = &["gh", "glab", "hub"];
2440
2441const FORGE_READS: &[(&str, &[&str])] = &[
2444 ("pr", &["view", "list", "diff", "checks", "status"]),
2445 ("mr", &["view", "list", "diff", "checks", "status"]),
2446 ("issue", &["view", "list"]),
2447 ("repo", &["view"]),
2448 ("run", &["view", "list", "watch"]),
2449 ("release", &["view", "list"]),
2450 ("workflow", &["view", "list"]),
2451 ("label", &["list"]),
2452 ("cache", &["list"]),
2453 ("gist", &["view", "list"]),
2454 ("auth", &["status"]),
2455 ("search", &[]),
2456 ("status", &[]),
2457 ("version", &[]),
2458];
2459
2460const FORGE_VALUE_FLAGS: &[&str] = &["-r", "--repo", "--hostname"];
2463
2464const PUBLICATION_COMMANDS: &[(&str, &[&str])] = &[
2466 ("npm", &["publish"]),
2467 ("pnpm", &["publish"]),
2468 ("yarn", &["publish"]),
2469 ("cargo", &["publish"]),
2470 ("gem", &["push"]),
2471 ("twine", &["upload"]),
2472 ("docker", &["push", "login"]),
2473];
2474
2475impl Inspector for DenyForgePublication {
2476 fn name(&self) -> &'static str {
2477 "coder.deny_forge_publication"
2478 }
2479
2480 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2481 let Some(cmd) = shell_command(tool, params) else {
2482 return InspectionResult::Allow;
2483 };
2484 for seg in segments(&cmd) {
2485 let Some(v) = verb(&seg) else { continue };
2486 let v = Path::new(&v.to_ascii_lowercase())
2489 .file_name()
2490 .map(|f| f.to_string_lossy().into_owned())
2491 .unwrap_or_default();
2492 let v = v.strip_suffix(".exe").unwrap_or(&v).to_string();
2493 let args: Vec<String> = seg.iter().skip(1).map(|a| a.to_ascii_lowercase()).collect();
2494
2495 if FORGE_VERBS.contains(&v.as_str()) {
2496 if let Some(reason) = forge_denial(&v, &args) {
2497 return InspectionResult::Deny(reason);
2498 }
2499 continue;
2500 }
2501
2502 for (mgr, subs) in PUBLICATION_COMMANDS {
2503 if v != *mgr {
2504 continue;
2505 }
2506 if leading_operands(&args).iter().any(|sub| subs.contains(sub)) {
2507 return InspectionResult::Deny(format!(
2508 "'{mgr}' publication is not allowed from a coder session — results \
2509 leave the worktree only through the approved merge branch"
2510 ));
2511 }
2512 }
2513 }
2514 InspectionResult::Allow
2515 }
2516}
2517
2518fn forge_operands(args: &[String]) -> Vec<&str> {
2521 let mut operands = Vec::new();
2522 let mut skip_value = false;
2523 for arg in args {
2524 if std::mem::take(&mut skip_value) {
2525 continue;
2526 }
2527 if FORGE_VALUE_FLAGS.contains(&arg.as_str()) {
2528 skip_value = true;
2529 continue;
2530 }
2531 if arg.starts_with('-') {
2532 continue;
2533 }
2534 operands.push(arg.as_str());
2535 }
2536 operands
2537}
2538
2539fn leading_operands(args: &[String]) -> Vec<&str> {
2558 args.iter()
2559 .map(String::as_str)
2560 .filter(|a| !a.starts_with('-') && !a.starts_with('+'))
2561 .take(2)
2562 .collect()
2563}
2564
2565fn forge_denial(verb: &str, args: &[String]) -> Option<String> {
2569 const BLOCKED: &str = "publishing from a coder session is not allowed — the runtime opens \
2570 the pull request after `coder.approve_merge`";
2571
2572 if args
2585 .iter()
2586 .any(|a| matches!(a.as_str(), "--version" | "--help"))
2587 {
2588 return None;
2589 }
2590 let operands = forge_operands(args);
2591 let Some(group) = operands.first().copied() else {
2592 return None; };
2594
2595 if group == "api" {
2598 let is_write_method = |v: &str| !v.is_empty() && v != "get";
2604 let explicit_method = args
2605 .windows(2)
2606 .any(|pair| matches!(pair[0].as_str(), "--method" | "-x") && is_write_method(&pair[1]))
2607 || args.iter().any(|a| {
2608 a.strip_prefix("--method=")
2609 .or_else(|| a.strip_prefix("-x"))
2610 .is_some_and(is_write_method)
2611 });
2612 let field_flag = |a: &String| {
2614 matches!(a.as_str(), "-f" | "--field" | "--raw-field" | "--input")
2615 || a.starts_with("--field=")
2616 || a.starts_with("--raw-field=")
2617 || a.starts_with("--input=")
2618 || a.starts_with("-f")
2619 };
2620 let graphql = operands.get(1).is_some_and(|o| *o == "graphql");
2625 let mutating_graphql = graphql
2626 && args
2627 .iter()
2628 .any(|a| a.contains("mutation") || a.contains("deletion"));
2629 let implicit_post = !graphql && args.iter().any(field_flag);
2630 return (explicit_method || implicit_post || mutating_graphql)
2631 .then(|| format!("'{verb} api' with a write method is not allowed — {BLOCKED}"));
2632 }
2633
2634 if group == "auth"
2640 && args
2641 .iter()
2642 .any(|a| a == "-t" || a == "--show-token" || a.starts_with("--show-token="))
2643 {
2644 return Some(format!(
2645 "'{verb} auth status --show-token' prints the forge credential — {BLOCKED}"
2646 ));
2647 }
2648
2649 let Some((_, subs)) = FORGE_READS.iter().find(|(g, _)| *g == group) else {
2650 return Some(format!("'{verb} {group}' is not allowed — {BLOCKED}"));
2651 };
2652 if subs.is_empty() {
2653 return None;
2654 }
2655 match operands.get(1).copied() {
2656 Some(sub) if subs.contains(&sub) => None,
2657 Some(sub) => Some(format!("'{verb} {group} {sub}' is not allowed — {BLOCKED}")),
2658 None => Some(format!(
2661 "'{verb} {group}' without a read-only subcommand is not allowed — {BLOCKED}"
2662 )),
2663 }
2664}
2665
2666struct DenyForcePushAndRemoteReconfiguration;
2669
2670impl Inspector for DenyForcePushAndRemoteReconfiguration {
2671 fn name(&self) -> &'static str {
2672 "governed_host.deny_force_push_and_remote_reconfiguration"
2673 }
2674
2675 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2676 let Some(cmd) = shell_command(tool, params) else {
2677 return InspectionResult::Allow;
2678 };
2679 for seg in segments(&cmd) {
2680 if verb(&seg) != Some("git") {
2681 continue;
2682 }
2683 let push = seg.iter().any(|token| token == "push");
2684 let forced = seg.iter().any(|token| {
2685 token == "--force"
2686 || token == "-f"
2687 || token.starts_with("--force-with-lease")
2688 || token.starts_with('+')
2689 });
2690 if push && forced {
2691 return InspectionResult::Deny("force-push is never allowed".into());
2692 }
2693 if seg.iter().any(|token| token == "remote")
2694 && seg.iter().any(|token| {
2695 token == "add" || token == "set-url" || token == "remove" || token == "rename"
2696 })
2697 {
2698 return InspectionResult::Deny(
2699 "mutating git remote configuration is not allowed".into(),
2700 );
2701 }
2702 }
2703 InspectionResult::Allow
2704 }
2705}
2706
2707struct DenyHistoryRewrite;
2710
2711impl Inspector for DenyHistoryRewrite {
2712 fn name(&self) -> &'static str {
2713 "coder.deny_history_rewrite"
2714 }
2715
2716 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2717 let Some(cmd) = shell_command(tool, params) else {
2718 return InspectionResult::Allow;
2719 };
2720 for seg in segments(&cmd) {
2721 if verb(&seg) != Some("git") {
2722 continue;
2723 }
2724 if seg.iter().any(|t| t == "rebase" || t == "filter-branch") {
2725 return InspectionResult::Deny("git history rewrite is not allowed".into());
2726 }
2727 if seg.iter().any(|t| t == "reset") && seg.iter().any(|t| t == "--hard") {
2728 return InspectionResult::Deny("git reset --hard is not allowed".into());
2729 }
2730 if seg.iter().any(|t| t == "worktree") && seg.iter().any(|t| t == "remove") {
2731 return InspectionResult::Deny(
2732 "removing worktrees is the runtime's job, not the agent's".into(),
2733 );
2734 }
2735 }
2736 InspectionResult::Allow
2737 }
2738}
2739
2740struct DenyPrivilegeEscalation;
2743
2744const PRIVILEGE_VERBS: &[&str] = &[
2745 "sudo",
2747 "doas",
2748 "su",
2749 "launchctl",
2750 "systemctl", "runas",
2753 "sc",
2754 "psexec",
2755];
2756
2757impl Inspector for DenyPrivilegeEscalation {
2758 fn name(&self) -> &'static str {
2759 "coder.deny_privilege_escalation"
2760 }
2761
2762 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2763 let Some(cmd) = shell_command(tool, params) else {
2764 return InspectionResult::Allow;
2765 };
2766 for seg in segments(&cmd) {
2767 if let Some(v) = verb(&seg) {
2768 if PRIVILEGE_VERBS.contains(&v.to_ascii_lowercase().as_str()) {
2769 return InspectionResult::Deny(format!(
2770 "'{v}' is not allowed in a coder session"
2771 ));
2772 }
2773 }
2774 }
2775 InspectionResult::Allow
2776 }
2777}
2778
2779struct DenyCredentialAccess;
2781
2782const CREDENTIAL_PATH_MARKERS: [&str; 6] = [
2783 "/.ssh",
2784 "/.aws",
2785 "/.gnupg",
2786 "/.kube",
2787 "/.car/secrets",
2788 "/.netrc",
2789];
2790
2791impl Inspector for DenyCredentialAccess {
2792 fn name(&self) -> &'static str {
2793 "coder.deny_credential_access"
2794 }
2795
2796 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2797 let haystacks: Vec<String> = if let Some(cmd) = shell_command(tool, params) {
2798 if cmd.contains("find-generic-password") || cmd.contains("find-internet-password") {
2799 return InspectionResult::Deny("keychain access is not allowed".into());
2800 }
2801 let cmd_lower = cmd.to_ascii_lowercase();
2803 if cmd_lower.contains("cmdkey") || cmd_lower.contains("vaultcmd") {
2804 return InspectionResult::Deny(
2805 "Windows Credential Manager access is not allowed".into(),
2806 );
2807 }
2808 let sensitive_env = [
2809 "_key",
2810 "_token",
2811 "_secret",
2812 "_password",
2813 "openai_",
2814 "anthropic_",
2815 "azure_client_",
2816 "github_token",
2817 "connection_string",
2818 ];
2819 if sensitive_env
2820 .iter()
2821 .any(|marker| cmd_lower.contains(marker))
2822 {
2823 return InspectionResult::Deny(
2824 "reading or expanding credential environment variables is not allowed".into(),
2825 );
2826 }
2827 for seg in segments(&cmd) {
2828 let Some(command) = verb(&seg).map(|value| value.to_ascii_lowercase()) else {
2829 continue;
2830 };
2831 if command == "env" && seg.len() == 1
2832 || command == "printenv"
2833 || command == "set" && seg.len() == 1
2834 {
2835 return InspectionResult::Deny(
2836 "dumping the process environment is not allowed".into(),
2837 );
2838 }
2839 }
2840 vec![cmd]
2841 } else if matches!(
2842 tool,
2843 "read_file" | "write_file" | "edit_file" | "grep_files"
2844 ) {
2845 params
2846 .get("path")
2847 .and_then(Value::as_str)
2848 .map(|p| vec![p.to_string()])
2849 .unwrap_or_default()
2850 } else {
2851 return InspectionResult::Allow;
2852 };
2853 for hay in &haystacks {
2854 let hay = hay.replace('\\', "/");
2858 let hay = hay
2859 .replace("~/", "/HOME/.")
2860 .replace("$HOME/", "/HOME/.")
2861 .replace("%USERPROFILE%/", "/HOME/.")
2862 .replace("%HOMEPATH%/", "/HOME/.");
2863 let hay = hay.replace("/HOME/..", "/."); for marker in CREDENTIAL_PATH_MARKERS {
2865 if hay.contains(marker) {
2866 return InspectionResult::Deny(format!(
2867 "access to credential path matching '{marker}' is not allowed"
2868 ));
2869 }
2870 }
2871 }
2872 InspectionResult::Allow
2873 }
2874}
2875
2876struct DenyDestructiveOutsideWorktree {
2879 worktree: PathBuf,
2880}
2881
2882const DESTRUCTIVE_VERBS: &[&str] = &[
2883 "rm", "rmdir", "mv", "cp", "chmod", "chown", "truncate", "dd", "del", "erase", "rd", "move", "copy", "format", "ren", "rename",
2888];
2889
2890impl Inspector for DenyDestructiveOutsideWorktree {
2891 fn name(&self) -> &'static str {
2892 "coder.deny_destructive_outside_worktree"
2893 }
2894
2895 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2896 let Some(cmd) = shell_command(tool, params) else {
2897 return InspectionResult::Allow;
2898 };
2899 for seg in segments(&cmd) {
2900 let Some(v) = verb(&seg) else { continue };
2901 let v_lower = v.to_ascii_lowercase();
2903 if !DESTRUCTIVE_VERBS.contains(&v_lower.as_str()) {
2904 continue;
2905 }
2906 for arg in seg
2908 .iter()
2909 .skip(1)
2910 .filter(|a| !a.starts_with('-') && !is_windows_switch(a))
2911 {
2912 if arg.starts_with('~') {
2913 return InspectionResult::Deny(format!(
2914 "'{v}' on a home-relative path ('{arg}') is not allowed"
2915 ));
2916 }
2917 if is_abs_or_traversal(arg) && !stays_under(&self.worktree, arg) {
2918 return InspectionResult::Deny(format!(
2919 "'{v}' outside the worktree ('{arg}') is not allowed"
2920 ));
2921 }
2922 }
2923 }
2924 InspectionResult::Allow
2925 }
2926}
2927
2928struct DenyEnvironmentRepair;
2944
2945const PACKAGE_MUTATIONS: &[(&str, &[&str])] = &[
2949 ("pip", &["install", "uninstall"]),
2950 ("pip3", &["install", "uninstall"]),
2951 ("conda", &["install", "remove", "uninstall", "update"]),
2952 ("poetry", &["add", "remove", "install", "update"]),
2953 ("uv", &["add", "remove", "sync"]),
2954 ("easy_install", &[]),
2955];
2956
2957const SHIM_FILES: &[&str] = &["sitecustomize.py", "usercustomize.py"];
2959
2960impl Inspector for DenyEnvironmentRepair {
2961 fn name(&self) -> &'static str {
2962 "coder.deny_environment_repair"
2963 }
2964
2965 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2966 if matches!(tool, "write_file" | "edit_file") {
2967 let path = params.get("path").and_then(Value::as_str).unwrap_or("");
2968 let base = Path::new(path)
2969 .file_name()
2970 .map(|f| f.to_string_lossy().to_ascii_lowercase())
2971 .unwrap_or_default();
2972 if SHIM_FILES.contains(&base.as_str()) {
2973 return InspectionResult::Deny(format!(
2974 "writing '{base}' changes how the interpreter loads, not what your code \
2975 does — the runtime re-runs the contract in the correct environment"
2976 ));
2977 }
2978 return InspectionResult::Allow;
2979 }
2980
2981 let Some(cmd) = shell_command(tool, params) else {
2982 return InspectionResult::Allow;
2983 };
2984 for seg in segments(&cmd) {
2985 let Some(v) = verb(&seg) else { continue };
2986 let v = Path::new(&v.to_ascii_lowercase())
2990 .file_name()
2991 .map(|f| f.to_string_lossy().into_owned())
2992 .unwrap_or_default();
2993 let v = v.strip_suffix(".exe").unwrap_or(&v).to_string();
2994 let args: Vec<String> = seg.iter().skip(1).map(|a| a.to_ascii_lowercase()).collect();
2995
2996 let module = args
2999 .iter()
3000 .position(|a| a == "-m")
3001 .and_then(|i| args.get(i + 1))
3002 .cloned();
3003 let (effective, effective_args): (String, Vec<String>) = match module {
3004 Some(m) if v.starts_with("python") || v.starts_with("py") => {
3005 let rest = args
3006 .iter()
3007 .skip_while(|a| **a != m)
3008 .skip(1)
3009 .cloned()
3010 .collect();
3011 (m, rest)
3012 }
3013 _ => (v.clone(), args.clone()),
3014 };
3015
3016 if effective == "venv" || effective == "virtualenv" {
3017 return InspectionResult::Deny(
3018 "creating an interpreter is environment repair, not part of the task — \
3019 the runtime re-runs the contract in the correct environment"
3020 .into(),
3021 );
3022 }
3023 for (mgr, subs) in PACKAGE_MUTATIONS {
3024 if effective != *mgr {
3025 continue;
3026 }
3027 let mutates = subs.is_empty()
3028 || effective_args.iter().any(|a| subs.contains(&a.as_str()))
3029 || (effective == "uv" && effective_args.iter().any(|a| a == "install"));
3031 if mutates {
3032 return InspectionResult::Deny(format!(
3033 "'{mgr}' package mutation is environment repair, not part of the task \
3034 — the runtime re-runs the contract in the correct environment"
3035 ));
3036 }
3037 }
3038 }
3039 InspectionResult::Allow
3040 }
3041}
3042
3043struct DenyPathEscape {
3047 worktree: PathBuf,
3048}
3049
3050impl Inspector for DenyPathEscape {
3051 fn name(&self) -> &'static str {
3052 "coder.deny_path_escape"
3053 }
3054
3055 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3056 if !matches!(tool, "write_file" | "edit_file") {
3057 return InspectionResult::Allow;
3058 }
3059 let Some(path) = params.get("path").and_then(Value::as_str) else {
3060 return InspectionResult::Allow; };
3062 if stays_under(&self.worktree, path) {
3063 InspectionResult::Allow
3064 } else {
3065 InspectionResult::Deny(format!("write to '{path}' resolves outside the worktree"))
3066 }
3067 }
3068}
3069
3070#[cfg(test)]
3071mod tests {
3072 use super::*;
3073 use serde_json::json;
3074
3075 fn chain() -> InspectorChain {
3076 coder_inspector_chain(Path::new("/wt"))
3077 }
3078
3079 fn denied(tool: &str, params: Value) -> bool {
3080 chain().check(tool, ¶ms).is_some()
3081 }
3082
3083 fn sh(cmd: &str) -> Value {
3084 json!({ "command": cmd })
3085 }
3086
3087 fn write_policy(dir: &Path, body: &str) {
3088 std::fs::create_dir_all(dir).unwrap();
3089 std::fs::write(dir.join("rules.toml"), body).unwrap();
3090 }
3091
3092 #[test]
3093 fn coder_chain_merges_machine_and_project_deny_rules() {
3094 let root = tempfile::tempdir().unwrap();
3095 let repo = root.path().join("repo");
3096 let machine = root.path().join("machine-policies");
3097 let project = repo.join(".car").join("policies");
3098 std::fs::create_dir_all(&repo).unwrap();
3099 write_policy(&machine, "deny_tool = [\"write_file\"]\n");
3100 write_policy(&project, "deny_keyword = [\"DO NOT RUN\"]\n");
3101
3102 let policy =
3103 coder_inspector_chain_from_policy_dirs(&repo, &[machine.clone(), project.clone()])
3104 .unwrap();
3105 assert_eq!(
3108 policy.denied_tools.iter().cloned().collect::<Vec<_>>(),
3109 vec!["write_file".to_string()]
3110 );
3111 let chain = policy.chain;
3112 assert!(chain
3113 .check("write_file", &json!({"path": "x", "content": "ok"}))
3114 .is_some());
3115 assert!(chain
3116 .check("shell", &json!({"command": "echo DO NOT RUN"}))
3117 .is_some());
3118 assert!(chain.check("read_file", &json!({"path": "x"})).is_none());
3119 }
3120
3121 #[test]
3122 fn built_in_denial_reason_wins_before_project_policy() {
3123 let root = tempfile::tempdir().unwrap();
3124 let policies = root.path().join("policies");
3125 write_policy(&policies, "deny_tool = [\"shell\"]\n");
3126 let chain = coder_inspector_chain_from_policy_dirs(root.path(), &[policies])
3127 .unwrap()
3128 .chain;
3129
3130 let reason = chain
3131 .check("shell", &sh("git push origin main"))
3132 .expect("both rules deny");
3133 assert!(
3134 reason.contains("git push"),
3135 "built-in reason must win: {reason}"
3136 );
3137 assert!(
3138 !reason.contains("operator policy"),
3139 "wrong precedence: {reason}"
3140 );
3141 }
3142
3143 #[test]
3144 fn malformed_or_unenforced_policy_refuses_chain_construction() {
3145 let root = tempfile::tempdir().unwrap();
3146 let malformed = root.path().join("malformed");
3147 write_policy(&malformed, "deny_tool = [not valid TOML\n");
3148 assert!(coder_inspector_chain_from_policy_dirs(root.path(), &[malformed]).is_err());
3149
3150 let trace = root.path().join("trace");
3151 write_policy(
3152 &trace,
3153 "[[trace_rule]]\nkind = \"never\"\ntool = \"deploy\"\n",
3154 );
3155 let err = coder_inspector_chain_from_policy_dirs(root.path(), &[trace])
3156 .err()
3157 .expect("trace rules are deliberately unenforced");
3158 assert!(err.to_string().contains("not enforced"), "{err}");
3159 }
3160
3161 #[test]
3162 fn denies_package_mutation_and_interpreter_creation() {
3163 for cmd in [
3164 "pip install requests",
3165 "pip3 uninstall -y six",
3166 "python -m pip install --upgrade pip",
3167 "/usr/bin/python3.11 -m pip install pytest",
3168 "conda install numpy",
3169 "poetry add httpx",
3170 "uv pip install ruff",
3171 "python -m venv .venv",
3172 "virtualenv env",
3173 "easy_install foo",
3174 "cd /wt && pip install -e .",
3175 ] {
3176 assert!(denied("shell", sh(cmd)), "should be denied: {cmd}");
3177 }
3178 }
3179
3180 #[test]
3181 fn allows_read_only_package_queries_and_real_test_runs() {
3182 for cmd in [
3185 "pip list",
3186 "pip show pytest",
3187 "python -m pytest -q tests/test_x.py",
3188 "/wt/.venv/bin/python -m pytest -q tests/test_x.py",
3189 "cargo test -p car-engine",
3190 "npm test",
3191 ] {
3192 assert!(!denied("shell", sh(cmd)), "should be allowed: {cmd}");
3193 }
3194 }
3195
3196 #[test]
3197 fn denies_interpreter_shims_but_not_ordinary_test_config() {
3198 assert!(denied(
3199 "write_file",
3200 json!({ "path": "sitecustomize.py", "content": "x" })
3201 ));
3202 assert!(denied(
3203 "write_file",
3204 json!({ "path": "src/usercustomize.py", "content": "x" })
3205 ));
3206 for path in ["conftest.py", "pyproject.toml", "tox.ini", "setup.cfg"] {
3209 assert!(
3210 !denied("write_file", json!({ "path": path, "content": "x" })),
3211 "must stay allowed: {path}"
3212 );
3213 }
3214 }
3215
3216 #[test]
3217 fn git_push_and_remote_mutation_denied() {
3218 assert!(denied("shell", sh("git push origin main")));
3219 assert!(denied("shell", sh("cargo test && git push --force")));
3220 assert!(denied("shell", sh("git remote add evil https://x")));
3221 assert!(denied("shell", sh("git remote set-url origin https://x")));
3222 assert!(!denied("shell", sh("git remote -v")));
3224 assert!(!denied("shell", sh("git commit -m 'x'")));
3225 assert!(!denied("shell", sh("git status && git diff")));
3226 assert!(!denied("shell", sh("echo push")));
3228 }
3229
3230 #[test]
3233 fn forge_publication_denied_but_reads_allowed() {
3234 for cmd in [
3235 "gh pr create --fill",
3236 "gh pr merge --admin",
3237 "gh api --method DELETE /repos/o/r/branches/main/protection",
3238 "gh api -X POST /repos/o/r/issues",
3239 "gh api repos/o/r/issues -f title=x",
3240 "gh release create v9.9.9 ./x",
3241 "gh auth token",
3242 "gh repo fork",
3243 "glab mr create",
3244 "npm publish",
3245 "cargo publish",
3246 "docker push img",
3247 "docker login ghcr.io",
3248 "cargo test && gh pr create",
3249 "/opt/homebrew/bin/gh pr create --fill",
3250 "gh release create v9.9.9 --notes -h",
3257 "gh pr create --title -h --body b --head mybranch --base main",
3258 "gh auth status -t",
3262 "gh auth status --show-token",
3263 "gh api -XPOST repos/o/r/pulls --input=-",
3266 "gh api -XPOST repos/o/r/pulls --field=title=x",
3267 "gh api --method=post repos/o/r/pulls",
3268 "gh api repos/o/r/issues --raw-field=title=x",
3269 "gh api graphql --field=query=mutation{createpullrequest}",
3271 "cargo +stable publish",
3273 "docker image push img",
3274 "npm --workspace x publish",
3275 ] {
3276 assert!(denied("shell", sh(cmd)), "should be denied: {cmd}");
3277 }
3278
3279 for cmd in [
3281 "gh pr view 12",
3282 "gh pr checks",
3283 "gh pr diff 12",
3284 "gh issue list",
3285 "gh run view 5",
3286 "gh run watch 5",
3287 "gh api repos/o/r",
3288 "gh api --method GET /repos/o/r",
3289 "gh --repo o/r pr view 12",
3290 "gh auth status",
3291 "gh --version",
3292 "/opt/homebrew/bin/gh pr list",
3293 "echo gh pr create",
3295 "cargo test -p car-engine",
3297 "npm run build",
3298 "docker build -t img .",
3299 "gh api graphql -f query=query{viewer{login}}",
3303 ] {
3304 assert!(!denied("shell", sh(cmd)), "should be allowed: {cmd}");
3305 }
3306 }
3307
3308 #[test]
3313 fn governed_host_still_allows_ci_reads_and_approved_push() {
3314 let chain = governed_host_inspector_chain(Path::new("/wt"));
3315 for command in [
3316 "gh run list",
3317 "az pipelines runs list",
3318 "git push origin HEAD:main",
3319 ] {
3320 assert!(
3321 chain.check("shell", &sh(command)).is_none(),
3322 "governed host must still allow {command}"
3323 );
3324 }
3325 }
3326
3327 #[test]
3328 fn governed_host_allows_only_normal_push_shape() {
3329 let chain = governed_host_inspector_chain(Path::new("/wt"));
3330 assert!(chain
3331 .check("shell", &sh("git push origin HEAD:main"))
3332 .is_none());
3333 for command in [
3334 "git push --force origin main",
3335 "git push --force-with-lease origin main",
3336 "git push origin +HEAD:main",
3337 "git remote set-url origin https://evil",
3338 "git rebase -i HEAD~2",
3339 "git add .",
3340 "git add -A",
3341 "git commit -am fix",
3342 ] {
3343 assert!(
3344 chain.check("shell", &sh(command)).is_some(),
3345 "must deny {command}"
3346 );
3347 }
3348 }
3349
3350 #[test]
3351 fn governed_host_denies_direct_reads_outside_repository() {
3352 let temp = tempfile::tempdir().unwrap();
3353 let repo = temp.path().join("repo");
3354 let outside = temp.path().join("outside.txt");
3355 std::fs::create_dir(&repo).unwrap();
3356 std::fs::write(&outside, "secret").unwrap();
3357 let chain = governed_host_inspector_chain(&repo);
3358
3359 assert!(chain
3360 .check("read_file", &json!({"path": outside}))
3361 .is_some());
3362 assert!(chain
3363 .check("shell", &sh(&format!("cat {}", outside.display())))
3364 .is_some());
3365 assert!(chain.check("shell", &sh("cd ..")).is_some());
3366 assert!(chain.check("shell", &sh(r"cat \../outside.txt")).is_some());
3367 for path in [
3368 "~/notes.txt",
3369 "$HOME/notes.txt",
3370 "${HOME}/notes.txt",
3371 "${HOME:-/tmp}/notes.txt",
3372 "${HOME:=/tmp}/notes.txt",
3373 "$TMPDIR/notes.txt",
3374 "${TMPDIR}/notes.txt",
3375 "${TMPDIR:-/tmp}/notes.txt",
3376 ] {
3377 assert!(
3378 chain.check("read_file", &json!({"path": path})).is_some(),
3379 "expanded shell root must be denied by read_file: {path}"
3380 );
3381 }
3382 for command in [
3383 "cat ~/notes.txt",
3384 "cat ~someone/notes.txt",
3385 "cat $HOME/notes.txt",
3386 "cat ${HOME}/notes.txt",
3387 "cat ${HOME:-/tmp}/notes.txt",
3388 "cat ${HOME:=/tmp}/notes.txt",
3389 "cat $TMPDIR/notes.txt",
3390 "cat ${TMPDIR}/notes.txt",
3391 "cat ${TMPDIR:=/tmp}/notes.txt",
3392 "sed -f ~/evil.sed file",
3393 ] {
3394 assert!(
3395 chain.check("shell", &sh(command)).is_some(),
3396 "home-relative path must be denied: {command}"
3397 );
3398 }
3399 assert!(chain.check("shell", &sh("cat src/lib.rs")).is_none());
3400
3401 #[cfg(unix)]
3402 {
3403 std::os::unix::fs::symlink(&outside, repo.join("escape")).unwrap();
3404 assert!(chain
3405 .check("read_file", &json!({"path": "escape"}))
3406 .is_some());
3407 assert!(chain.check("shell", &sh("cat escape")).is_some());
3408 }
3409 }
3410
3411 #[test]
3412 fn governed_host_denies_uninspectable_variable_operands() {
3413 let temp = tempfile::tempdir().unwrap();
3414 let repo = temp.path().join("repo");
3415 let cargo_target = temp.path().join("cargo-target");
3416 std::fs::create_dir_all(repo.join("src")).unwrap();
3417 std::fs::create_dir_all(cargo_target.join("debug")).unwrap();
3418 std::fs::write(repo.join("src/x"), "x\n").unwrap();
3419 std::fs::write(repo.join("src/lib.rs"), "pub fn control() {}\n").unwrap();
3420 std::fs::write(repo.join("$HOME_fixture"), "literal\n").unwrap();
3421 let chain = governed_host_inspector_chain(&repo);
3422
3423 for command in [
3424 "cat $NOPE/etc/hosts",
3425 "cat ${NOPE}/x",
3426 "cat ${NOPE:-${PWD}}/x",
3427 "cat $NOPE",
3428 "cat $MISSING_fixture",
3429 "cat $HOME_fixture/inside",
3430 "cat ${HOME_fixture}/inside",
3431 "cat ${PWD%repo}/x",
3432 "cat $1/etc/hosts",
3433 "cat ${1}",
3434 "cat $@",
3435 "cat $*",
3436 "cat $?",
3437 "cat $-",
3438 "cat $$",
3439 "cat $!",
3440 "cat src/$x",
3441 "cat src/${x}/file",
3442 "cat src/deeper/$x",
3443 "cat src/file$x",
3444 "cat src/$1",
3445 "cat $PWD/src/$x",
3446 ] {
3447 let reason = chain
3448 .check("shell", &sh(command))
3449 .unwrap_or_else(|| panic!("variable operand must be denied: {command}"));
3450 assert!(
3451 reason.contains("variable operand cannot be inspected"),
3452 "unexpected variable denial for {command}: {reason}"
3453 );
3454 }
3455
3456 for command in [
3457 "cat $PWD/src/x",
3458 "cat ${PWD}/src/x",
3459 "cat src/$PWD",
3460 "cat src/${PWD}/x",
3461 "cat src/lib.rs",
3462 "cat $HOME_fixture",
3463 "env RUST_LOG=debug cargo test",
3464 ] {
3465 assert!(
3466 chain.check("shell", &sh(command)).is_none(),
3467 "resolvable or repo-prefixed control must stay allowed: {command}"
3468 );
3469 }
3470
3471 let path_gate = DenyGovernedShellPathEscape {
3474 worktree: repo.clone(),
3475 oldpwd_is_worktree: true,
3476 cargo_target_dir: Some(cargo_target.clone()),
3477 };
3478 for command in [
3479 "cat $OLDPWD/src/x",
3480 "cat $CARGO_TARGET_DIR/debug/output",
3481 "cat src/$OLDPWD/x",
3482 "cat src/$CARGO_TARGET_DIR/output",
3483 ] {
3484 assert!(
3485 matches!(
3486 path_gate.inspect("shell", &sh(command)),
3487 InspectionResult::Allow
3488 ),
3489 "pinned variable root must stay allowed: {command}"
3490 );
3491 }
3492 assert!(matches!(
3493 path_gate.inspect("shell", &sh("cat $CARGO_TARGET_DIR/../outside")),
3494 InspectionResult::Deny(reason)
3495 if reason.contains("resolves outside its governed root")
3496 ));
3497 }
3498
3499 #[test]
3500 fn governed_host_gates_every_shell_command_segment() {
3501 let temp = tempfile::tempdir().unwrap();
3502 let repo = temp.path().join("repo");
3503 let outside = temp.path().join("outside.txt");
3504 std::fs::create_dir(&repo).unwrap();
3505 std::fs::write(&outside, "secret").unwrap();
3506 let chain = governed_host_inspector_chain(&repo);
3507
3508 for separator in ["\n", "\r\n", ";", "&&", "||"] {
3509 let command = format!("printf harmless{separator}cat {}", outside.display());
3510 assert!(
3511 chain.check("shell", &sh(&command)).is_some(),
3512 "the command after separator {separator:?} must be path-gated"
3513 );
3514 }
3515 }
3516
3517 #[test]
3518 fn governed_host_joins_continued_lines_before_path_gating() {
3519 let temp = tempfile::tempdir().unwrap();
3520 let repo = temp.path().join("repo");
3521 let outside = temp.path().join("outside.txt");
3522 std::fs::create_dir_all(repo.join("src")).unwrap();
3523 std::fs::write(repo.join("src/lib.rs"), "pub fn example() {}\n").unwrap();
3524 std::fs::write(&outside, "secret").unwrap();
3525 let chain = governed_host_inspector_chain(&repo);
3526
3527 for verb in ["cat", "rm"] {
3528 let command = format!("{verb} \\\n{}", outside.display());
3529 assert!(
3530 chain.check("shell", &sh(&command)).is_some(),
3531 "a continued {verb} path must remain governed: {command:?}"
3532 );
3533 }
3534 assert!(
3535 chain
3536 .check("shell", &sh("sed -n '/pub fn/p' \\\n src/lib.rs"))
3537 .is_none(),
3538 "a continued repository-local sed operand must remain allowed"
3539 );
3540 }
3541
3542 #[test]
3543 fn governed_host_gates_paths_embedded_in_sed_and_awk_programs() {
3544 let temp = tempfile::tempdir().unwrap();
3545 let repo = temp.path().join("repo");
3546 let outside = temp.path().join("outside.txt");
3547 std::fs::create_dir(&repo).unwrap();
3548 std::fs::write(repo.join("file"), "x\n").unwrap();
3549 std::fs::write(repo.join("script.sed"), "p\n").unwrap();
3550 std::fs::write(&outside, "secret").unwrap();
3551 let chain = governed_host_inspector_chain(&repo);
3552 let outside = outside.display();
3553
3554 for command in [
3555 format!("sed 'r {outside}' file"),
3556 format!("sed -n 'R {outside}' file"),
3557 format!("sed -e '1w {outside}' file"),
3558 format!("sed '/x/W {outside}' file"),
3559 format!("awk '{{ getline < \"{outside}\" }}' file"),
3560 format!("awk '{{ getline line < \"{outside}\" }}' file"),
3561 format!("awk '{{ print $0 > \"{outside}\" }}' file"),
3562 format!("awk '{{ print $0 > (\"{outside}\") }}' file"),
3563 format!("awk '{{ printf \"%s\", $0 >> \"{outside}\" }}' file"),
3564 format!("sed -n 's/x/y/w {outside}' file"),
3567 format!("sed -n '/x/s//y/gw {outside}' file"),
3568 format!("sed -n 's/x/;/w {outside}' file"),
3569 format!("sed -n 's;x;y;w {outside}' file"),
3570 format!("sed -n 's/x/{{/w {outside}' file"),
3571 format!("sed -n 'r{outside}' file"),
3573 format!("sed -n 'R{outside}' file"),
3574 format!("sed -n 'w{outside}' file"),
3575 format!("sed -n 'W{outside}' file"),
3576 format!("sed -nf {outside} file"),
3578 format!("sed -ne 'r {outside}' file"),
3579 format!("sed -i.bak 's/x/y/' {outside}"),
3580 format!("awk 'BEGIN {{ system(\"cat {outside}\") }}'"),
3583 format!("awk 'BEGIN {{ \"cat {outside}\" | getline line }}'"),
3584 format!("awk 'BEGIN {{ \"cat {outside}\" |& getline line }}'"),
3585 format!("awk '{{ print $0 | \"cat > {outside}\" }}' file"),
3586 format!("awk '{{ printf \"%s\", $0 | \"cat > {outside}\" }}' file"),
3587 format!("sed -n '1e cat {outside}' file"),
3588 "sed -n 's/x/y/e' file".to_string(),
3589 ] {
3590 assert!(
3591 chain.check("shell", &sh(&command)).is_some(),
3592 "dangerous embedded operand or command execution must be denied: {command}"
3593 );
3594 }
3595
3596 for separator in ["\n", "\r\n"] {
3597 let command = format!("printf harmless{separator}sed -n 's/x/y/w {outside}' file");
3598 assert!(
3599 chain.check("shell", &sh(&command)).is_some(),
3600 "a sed write after {separator:?} must remain governed"
3601 );
3602 }
3603 for continuation in ["\\\n", "\\\r\n"] {
3604 let command = format!("sed -n 's/x/y/w {continuation}{outside}' file");
3605 assert!(
3606 chain.check("shell", &sh(&command)).is_some(),
3607 "a continued sed write must remain governed: {command:?}"
3608 );
3609 }
3610
3611 for command in [
3612 "sed 'r file' file",
3613 "sed -e '1w generated.txt' file",
3614 "awk '{ getline < \"file\" }' file",
3615 "awk '{ print $0 > \"generated.txt\" }' file",
3616 "sed -nf script.sed file",
3617 "sed -ne '/x/p' file",
3618 "sed -i.bak 's/x/y/' file",
3619 "sed 'y/x/;/' file",
3620 "sed ':example; /x/p' file",
3621 ] {
3622 assert!(
3623 chain.check("shell", &sh(command)).is_none(),
3624 "repository-relative embedded file operand must remain allowed: {command}"
3625 );
3626 }
3627 }
3628
3629 #[test]
3630 fn governed_host_denies_shell_fed_sed_and_awk_programs() {
3631 let temp = tempfile::tempdir().unwrap();
3632 let repo = temp.path().join("repo");
3633 let outside = temp.path().join("outside.txt");
3634 std::fs::create_dir(&repo).unwrap();
3635 std::fs::write(repo.join("file"), "x\n").unwrap();
3636 std::fs::write(&outside, "secret").unwrap();
3637 let chain = governed_host_inspector_chain(&repo);
3638 let outside = outside.display();
3639
3640 for command in [
3641 format!("sed -f - file <<'EOF'\nw {outside}\nEOF"),
3643 format!("awk -f /dev/stdin file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
3644 format!("sed -f - file <<< 'w {outside}'"),
3646 format!("printf 'w {outside}\\n' | sed -f - file"),
3647 format!("sed -f <(printf 'w {outside}\\n') file"),
3650 format!("awk -f /dev/fd/0 file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
3651 "awk -f /proc/self/fd/0 file".to_string(),
3652 format!("sed -e '' -f - file <<'EOF'\nw {outside}\nEOF"),
3655 format!("awk -e '' -f - file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
3656 "sed -f '' file".to_string(),
3659 "sed -f\"\" file".to_string(),
3660 "sed -f $(printf -) file".to_string(),
3661 "awk -f `printf -` file".to_string(),
3662 "sed -f >(printf p) file".to_string(),
3663 r"sed -f \- file".to_string(),
3665 r"awk -f \- file".to_string(),
3666 "busybox awk -f - file".to_string(),
3667 r"sed --file=\- file".to_string(),
3668 "sed -f {-,} file".to_string(),
3671 "x=-; sed -f $x file".to_string(),
3672 "x=-; sed -f \"$x\" file".to_string(),
3673 ] {
3674 let reason = chain
3675 .check("shell", &sh(&command))
3676 .unwrap_or_else(|| panic!("shell-fed program must be denied: {command}"));
3677 assert!(
3678 reason.contains("script source") && reason.contains("cannot be inspected"),
3679 "program channels must not be misclassified as governed paths: {reason}"
3680 );
3681 }
3682 }
3683
3684 #[test]
3685 fn governed_host_denies_nested_shell_command_carriers() {
3686 let chain = governed_host_inspector_chain(Path::new("/wt"));
3687 for command in [
3688 "eval 'sed -f - file'",
3689 "bash -c 'sed -f - file'",
3690 "sh -c 'cat /etc/passwd'",
3691 "zsh -c 'awk -f - file'",
3692 "dash -c 'cat /etc/passwd'",
3693 "ksh -c 'cat /etc/passwd'",
3694 "fish -c 'cat /etc/passwd'",
3695 "busybox sh -c 'cat /etc/passwd'",
3696 "busybox ash -c 'cat /etc/passwd'",
3697 "perl -e 'open F, q(/etc/passwd)'",
3698 "perl -E 'say qx(cat /etc/passwd)'",
3699 "perl -wE 'say qx(cat /etc/passwd)'",
3700 "python3 -c 'open(\"/etc/passwd\").read()'",
3701 "python3.14 -c 'open(\"/etc/passwd\").read()'",
3702 "ruby -e 'puts File.read(\"/etc/passwd\")'",
3703 "node -e 'require(\"fs\").readFileSync(\"/etc/passwd\")'",
3704 "osascript -e 'do shell script \"cat /etc/passwd\"'",
3705 "script -c 'cat /etc/passwd'",
3706 "bash -lc 'cat /etc/passwd'",
3707 "MODE=check /bin/bash --noprofile -c 'cat /etc/passwd'",
3708 "env -S 'cat /etc/passwd'",
3709 "bash",
3710 "nice bash",
3711 "nohup bash",
3712 ] {
3713 let reason = chain
3714 .check("shell", &sh(command))
3715 .unwrap_or_else(|| panic!("nested shell carrier must be denied: {command}"));
3716 assert!(
3717 reason.contains("nested shell command"),
3718 "unexpected nested-shell denial reason: {reason}"
3719 );
3720 }
3721 assert!(chain.check("shell", &sh("echo eval")).is_none());
3722 assert!(chain.check("shell", &sh("bash script.sh")).is_none());
3723 assert!(chain
3724 .check("shell", &sh("nice bash scripts/check.sh"))
3725 .is_none());
3726 assert!(chain.check("shell", &sh("python3 script.py")).is_none());
3727 assert!(chain.check("shell", &sh("python3 -E script.py")).is_none());
3728 assert!(chain
3729 .check("shell", &sh("ruby -E UTF-8 script.rb"))
3730 .is_none());
3731 assert!(chain.check("shell", &sh("node script.js")).is_none());
3732 assert!(chain
3733 .check("shell", &sh("perl -MExtUtils::MakeMaker scripts/build.pl"))
3734 .is_none());
3735 assert!(chain.check("shell", &sh("command -v sudo")).is_none());
3736 }
3737
3738 #[test]
3739 fn governed_host_inspects_command_environment_assignments() {
3740 let temp = tempfile::tempdir().unwrap();
3741 let repo = temp.path().join("repo");
3742 let outside = temp.path().join("outside");
3743 std::fs::create_dir_all(repo.join("target")).unwrap();
3744 std::fs::create_dir_all(&outside).unwrap();
3745 let chain = governed_host_inspector_chain(&repo);
3746
3747 for name in [
3748 "BASH_ENV",
3749 "ENV",
3750 "PATH",
3751 "LD_PRELOAD",
3752 "DYLD_INSERT_LIBRARIES",
3753 "PYTHONPATH",
3754 "PERL5LIB",
3755 "RUBYLIB",
3756 "NODE_OPTIONS",
3757 "CARGO_HOME",
3758 "RUSTUP_HOME",
3759 "GIT_EXEC_PATH",
3760 "GIT_SSH_COMMAND",
3761 ] {
3762 let command = format!("env {name}=repo-local cargo test");
3763 let reason = chain
3764 .check("shell", &sh(&command))
3765 .unwrap_or_else(|| panic!("redirecting assignment must be denied: {command}"));
3766 assert!(reason.contains("redirect executable code"), "{reason}");
3767 }
3768
3769 for command in [
3770 "BASH_ENV=repo-local bash script.sh".to_string(),
3771 format!("env CACHE_DIR={} cargo test", outside.display()),
3772 "env EDITOR=repo-editor git commit".to_string(),
3773 "VISUAL=repo-editor git rebase --interactive main".to_string(),
3774 "env CARGO_TARGET_DIR=target cargo test".to_string(),
3775 ] {
3776 assert!(
3777 chain.check("shell", &sh(&command)).is_some(),
3778 "unsafe assignment must be denied: {command}"
3779 );
3780 }
3781
3782 for command in [
3783 "env RUST_LOG=debug cargo test".to_string(),
3784 "RUST_LOG=debug".to_string(),
3785 "RUST_LOG[0]=debug; cargo test".to_string(),
3786 "RUST_LOG[$index]=debug; cargo test".to_string(),
3787 "RUST_LOG[$((1+1))]=debug; cargo test".to_string(),
3788 "RUST_LOG[ 0 ]=debug; cargo test".to_string(),
3791 "RUST_LOG[0 ]=debug; cargo test".to_string(),
3792 "RUST_LOG[ 0]=debug; cargo test".to_string(),
3793 "RUST_LOG[\t0]=debug; cargo test".to_string(),
3794 "RUST_LOG[ 0 ]+=,debug; cargo test".to_string(),
3795 "cargo test 'a[0]'".to_string(),
3796 "CACHE_KIND=local".to_string(),
3797 "env EDITOR=vim cargo test".to_string(),
3798 ] {
3799 assert!(
3800 chain.check("shell", &sh(&command)).is_none(),
3801 "plain assignment must stay allowed: {command}"
3802 );
3803 }
3804
3805 for command in [
3806 "PWD=repo-local",
3807 "OLDPWD=repo-local",
3808 "CARGO_TARGET_DIR=target",
3809 "HOME=repo-local",
3810 "TMPDIR=tmp",
3811 "IFS=: ",
3812 "PATH=bin",
3813 "PWD=/etc; cat $PWD/passwd",
3814 "PWD[0]=/etc; cat $PWD/passwd",
3815 "PWD[0]+=/x",
3816 "PWD[ 0 ]=/etc; cat $PWD/passwd",
3820 "PWD[0 ]=/etc; cat $PWD/passwd",
3821 "PWD[ 0]=/etc; cat $PWD/passwd",
3822 "PWD[\t0]=/etc; cat $PWD/passwd",
3823 "PWD[\t0]+=/x",
3824 "env PWD[0]=/etc cat $PWD/passwd",
3825 "source=../../outside; cat $source/file",
3826 "source[0]=repo-local; cat $source/file",
3827 "env input=../../outside cat $input/file",
3828 ] {
3829 let reason = chain
3830 .check("shell", &sh(command))
3831 .unwrap_or_else(|| panic!("path-changing assignment must be denied: {command}"));
3832 assert!(
3833 reason.contains("environment assignment"),
3834 "unexpected assignment denial for {command}: {reason}"
3835 );
3836 }
3837 }
3838
3839 #[test]
3840 fn governed_host_inspects_assignment_builtins() {
3841 let chain = governed_host_inspector_chain(Path::new("/wt"));
3842
3843 for command in [
3844 "export PWD=/etc; cat $PWD/passwd",
3845 "readonly PWD=/etc; cat $PWD/passwd",
3846 "declare PWD=/etc; cat $PWD/passwd",
3847 "typeset PWD=/etc; cat $PWD/passwd",
3848 "local PWD=/etc; cat $PWD/passwd",
3849 "export PWD[1]=/etc; cat $PWD/passwd",
3850 "export PWD[ 1 ]=/etc; cat $PWD/passwd",
3851 "declare -a PWD[\t0]=/etc; cat $PWD/passwd",
3852 "readonly PWD[1]=/etc; cat $PWD/passwd",
3853 "declare PATH[0]=/tmp; cargo test",
3854 "declare -a arr[0]=/etc",
3855 "typeset HOME[0]=/tmp; cargo test",
3856 "local PWD[1]=/etc; cat $PWD/passwd",
3857 "unset PWD",
3858 "export -n PWD",
3859 ] {
3860 let reason = chain
3861 .check("shell", &sh(command))
3862 .unwrap_or_else(|| panic!("assignment builtin must be denied: {command}"));
3863 assert!(
3864 reason.contains("environment assignment"),
3865 "unexpected assignment-builtin denial for {command}: {reason}"
3866 );
3867 }
3868
3869 for builtin in ["export", "readonly", "declare", "typeset", "local"] {
3870 for assignment in ["source=repo-local", "source[0]=repo-local"] {
3871 let command = format!("{builtin} {assignment}; cat $source/file");
3872 let reason = chain.check("shell", &sh(&command)).unwrap_or_else(|| {
3873 panic!("later operand-prefix assignment must be denied: {command}")
3874 });
3875 assert!(reason.contains("environment assignment"), "{reason}");
3876 }
3877 }
3878
3879 for command in [
3880 "PWD+=/../../../etc; cat $PWD/passwd",
3881 "env PWD+=/../../../etc cat $PWD/passwd",
3882 "export PWD+=/../../../etc; cat $PWD/passwd",
3883 "readonly PWD+=/../../../etc; cat $PWD/passwd",
3884 "declare PATH+=/../../../bin; cargo test",
3885 "typeset PWD+=/../../../etc; cat $PWD/passwd",
3886 "local PWD+=/../../../etc; cat $PWD/passwd",
3887 ] {
3888 let reason = chain
3889 .check("shell", &sh(command))
3890 .unwrap_or_else(|| panic!("append assignment must be denied: {command}"));
3891 assert!(
3892 reason.contains("environment assignment"),
3893 "unexpected append-assignment denial for {command}: {reason}"
3894 );
3895 }
3896
3897 for command in [
3898 "RUST_LOG[$(id)]=debug; cargo test",
3899 "RUST_LOG[$(printf 0)]=debug; cargo test",
3900 "env RUST_LOG[$(id)]=debug cargo test",
3901 "export RUST_LOG[$(id)]=debug; cargo test",
3902 "readonly RUST_LOG[`printf 0`]=debug; cargo test",
3903 "declare RUST_LOG[$(id)]=debug; cargo test",
3904 "typeset RUST_LOG[$(id)]=debug; cargo test",
3905 "local RUST_LOG[$(id)]=debug; cargo test",
3906 ] {
3907 let reason = chain.check("shell", &sh(command)).unwrap_or_else(|| {
3908 panic!("command-substitution subscript must be denied: {command}")
3909 });
3910 assert!(reason.contains("cannot be inspected"), "{reason}");
3911 }
3912
3913 for command in [
3914 "export RUST_LOG=debug; cargo test",
3915 "export RUST_LOG+=,debug; cargo test",
3916 "export RUST_LOG[0]=debug; cargo test",
3917 "export RUST_LOG[ 0 ]=debug; cargo test",
3918 "declare RUST_LOG[\t0]=debug; cargo test",
3919 "declare RUST_LOG[$index]=debug; cargo test",
3920 "typeset RUST_LOG[$((1+1))]=debug; cargo test",
3921 "echo 'RUST_LOG[$(id)]=debug'",
3922 "local x=1",
3923 "unset RUST_LOG",
3924 "export -n RUST_LOG",
3925 ] {
3926 assert!(
3927 chain.check("shell", &sh(command)).is_none(),
3928 "harmless assignment builtin must stay allowed: {command}"
3929 );
3930 }
3931 }
3932
3933 #[test]
3934 fn governed_host_denies_busybox_install_mode() {
3935 let chain = governed_host_inspector_chain(Path::new("/wt"));
3936 for command in [
3937 "busybox --install",
3938 "busybox --install -s",
3939 "busybox --install=/tmp/bin",
3940 ] {
3941 let reason = chain
3942 .check("shell", &sh(command))
3943 .unwrap_or_else(|| panic!("busybox install must be denied: {command}"));
3944 assert!(reason.contains("busybox --install"), "{reason}");
3945 }
3946 assert!(chain
3947 .check("shell", &sh("busybox grep foo src/input.txt"))
3948 .is_none());
3949 }
3950
3951 #[test]
3952 fn governed_host_unwraps_prefix_command_carriers() {
3953 let temp = tempfile::tempdir().unwrap();
3954 let repo = temp.path().join("repo");
3955 let outside = temp.path().join("outside.txt");
3956 std::fs::create_dir_all(repo.join("src")).unwrap();
3957 std::fs::write(repo.join("src/input.txt"), "foo\n").unwrap();
3958 std::fs::write(&outside, "secret\n").unwrap();
3959 let chain = governed_host_inspector_chain(&repo);
3960 let path_gate = DenyGovernedShellPathEscape::new(&repo);
3961 let outside = outside.display();
3962
3963 for command in [
3964 format!("env FOO=1 bash -c 'cat {outside}'"),
3965 format!("env -i -u FOO FOO=1 cat {outside}"),
3966 format!("command -p cat {outside}"),
3967 format!("exec -a reader cat {outside}"),
3968 format!("nohup -- cat {outside}"),
3969 format!("time -f '%E' cat {outside}"),
3970 format!("nice --adjustment 5 cat {outside}"),
3971 format!("caffeinate -t 1 cat {outside}"),
3972 format!("script -q transcript cat {outside}"),
3973 format!("xargs -0P 2 cat {outside}"),
3974 "builtin eval 'cat /etc/passwd'".to_string(),
3975 format!("sudo -u root cat {outside}"),
3976 format!("ionice -c 2 cat {outside}"),
3977 format!("timeout --signal TERM 5 cat {outside}"),
3978 format!("env -i time nice -n 1 cat {outside}"),
3979 ] {
3980 assert!(
3981 chain.check("shell", &sh(&command)).is_some(),
3982 "carrier must not hide the governed command: {command}"
3983 );
3984 }
3985
3986 assert!(matches!(
3990 path_gate.inspect("shell", &sh("env")),
3991 InspectionResult::Allow
3992 ));
3993 for command in [
3994 "time cargo build",
3995 "xargs -0 grep foo src/input.txt",
3996 "busybox grep foo src/input.txt",
3997 ] {
3998 assert!(
3999 chain.check("shell", &sh(command)).is_none(),
4000 "safe carrier control must stay allowed: {command}"
4001 );
4002 }
4003 }
4004
4005 #[test]
4006 fn prefix_carriers_do_not_bypass_other_shell_inspectors() {
4007 for command in [
4008 "env FOO=1 git push origin main",
4009 "command gh pr create --fill",
4010 "time cargo publish",
4011 "nice -n 1 pip install requests",
4012 ] {
4013 assert!(
4014 denied("shell", sh(command)),
4015 "must deny carried command: {command}"
4016 );
4017 }
4018 }
4019
4020 #[test]
4021 fn governed_host_distinguishes_sed_and_awk_programs_from_paths() {
4022 let temp = tempfile::tempdir().unwrap();
4023 let repo = temp.path().join("repo");
4024 std::fs::create_dir_all(repo.join("src")).unwrap();
4025 std::fs::write(repo.join("src/lib.rs"), "pub fn example() {}\n").unwrap();
4026 std::fs::write(repo.join("file.rs"), "pub fn example() {}\n").unwrap();
4027 std::fs::write(repo.join("file"), "x\n").unwrap();
4028 std::fs::write(repo.join("script.sed"), "p\n").unwrap();
4029 std::fs::write(repo.join("-"), "p\n").unwrap();
4030 #[cfg(unix)]
4031 {
4032 let mut escaped_dash = repo.clone();
4037 escaped_dash.push(r"\-");
4038 std::fs::write(escaped_dash, "p\n").unwrap();
4039 }
4040 let chain = governed_host_inspector_chain(&repo);
4041
4042 assert!(
4043 chain
4044 .check("shell", &sh("sed -n /pub fn/p file.rs"))
4045 .is_none(),
4046 "a sed address is a program, not the absolute path /pub"
4047 );
4048 assert!(
4049 chain
4050 .check("shell", &sh("sed -n '/pub fn/p' src/lib.rs"))
4051 .is_none(),
4052 "a quoted sed address is a program, not the absolute path /pub"
4053 );
4054 assert!(
4055 chain
4056 .check("shell", &sh("sed -e '/pub fn/p' src/lib.rs"))
4057 .is_none(),
4058 "a sed -e operand is an inline program, not a path"
4059 );
4060 assert!(
4061 chain
4062 .check("shell", &sh("sed -n '/pub \\/etc/p' src/lib.rs"))
4063 .is_none(),
4064 "quoted whitespace must not split one sed program into path operands"
4065 );
4066 assert!(
4067 chain.check("shell", &sh("awk /pub/ file.rs")).is_none(),
4068 "an awk pattern is a program, not the absolute path /pub"
4069 );
4070 assert!(
4071 chain.check("shell", &sh("awk '/x/{print}' file")).is_none(),
4072 "an awk pattern-action is a program, not a path"
4073 );
4074 assert!(
4075 chain
4076 .check("shell", &sh("awk '/x/ { print \"/etc\" }' file"))
4077 .is_none(),
4078 "a quoted awk program remains one non-path argument"
4079 );
4080 assert!(
4081 chain.check("shell", &sh("sed -n p /etc/passwd")).is_some(),
4082 "sed input files remain governed"
4083 );
4084 assert!(
4085 chain.check("shell", &sh("sed -f /etc/evil")).is_some(),
4086 "sed -f names a script file and must remain governed"
4087 );
4088 assert!(
4089 chain.check("shell", &sh("awk -E/path file")).is_some(),
4090 "an attached awk -E script path must remain governed"
4091 );
4092 assert!(
4093 chain
4094 .check("shell", &sh("sed -f script.sed file"))
4095 .is_none(),
4096 "a literal repository script must remain allowed"
4097 );
4098 assert!(
4099 chain
4100 .check("shell", &sh("sed -e '' -f script.sed file"))
4101 .is_none(),
4102 "empty -e padding must not deny a literal repository script"
4103 );
4104 assert!(
4105 chain.check("shell", &sh("sed -f ./- file")).is_none(),
4106 "./- is a literal repository path, not sed's stdin marker"
4107 );
4108 #[cfg(unix)]
4109 {
4110 for command in [r"sed -f '\-' file", r#"sed -f "\-" file"#] {
4111 assert!(
4112 chain.check("shell", &sh(command)).is_none(),
4113 "a backslash preserved by shell quotes remains a repository filename: {command}"
4114 );
4115 }
4116 }
4117 }
4118
4119 #[test]
4120 fn governed_host_denies_gui_shell_automation() {
4121 let chain = governed_host_inspector_chain(Path::new("/wt"));
4122 assert!(chain
4123 .check(
4124 "run_applescript",
4125 &json!({"script": "tell application \"Terminal\" to do script \"az deploy\""})
4126 )
4127 .is_some());
4128 assert!(chain
4129 .check("run_powershell", &json!({"script": "az deploy"}))
4130 .is_some());
4131 }
4132
4133 #[test]
4134 fn history_rewrite_denied() {
4135 assert!(denied("shell", sh("git rebase -i HEAD~3")));
4136 assert!(denied("shell", sh("git reset --hard HEAD~1")));
4137 assert!(denied("shell", sh("git filter-branch --all")));
4138 assert!(denied("shell", sh("git worktree remove /wt")));
4139 assert!(!denied("shell", sh("git reset HEAD file.txt"))); }
4141
4142 #[test]
4143 fn privilege_escalation_denied() {
4144 assert!(denied("shell", sh("sudo rm -rf /tmp/x")));
4145 assert!(denied("shell", sh("doas pkg_add x")));
4146 assert!(denied("shell", sh("FOO=1 sudo make install")));
4147 assert!(denied("shell", sh("launchctl unload foo")));
4148 assert!(!denied("shell", sh("echo sudo"))); }
4150
4151 #[test]
4152 fn credential_access_denied_for_shell_and_file_tools() {
4153 assert!(denied("shell", sh("cat ~/.ssh/id_rsa")));
4154 assert!(denied("shell", sh("cat $HOME/.aws/credentials")));
4155 assert!(denied("shell", sh("security find-generic-password -s x")));
4156 assert!(denied("read_file", json!({"path": "/Users/u/.ssh/id_rsa"})));
4157 assert!(denied("read_file", json!({"path": "~/.netrc"})));
4158 assert!(!denied("read_file", json!({"path": "src/main.rs"})));
4159 }
4162
4163 #[test]
4164 fn destructive_ops_scoped_to_worktree() {
4165 assert!(denied("shell", sh("rm -rf /etc")));
4166 assert!(denied("shell", sh("rm -rf ../other-checkout")));
4167 assert!(denied("shell", sh("mv target ~/elsewhere")));
4168 assert!(denied("shell", sh("chmod 777 /usr/local/bin/x")));
4169 assert!(!denied("shell", sh("rm -rf target/debug")));
4171 assert!(!denied("shell", sh("rm /wt/scratch.txt")));
4172 assert!(!denied("shell", sh("cp a.txt b.txt")));
4173 }
4174
4175 #[test]
4176 fn write_path_escape_denied_but_reads_allowed() {
4177 assert!(denied(
4178 "write_file",
4179 json!({"path": "/etc/hosts", "content": "x"})
4180 ));
4181 assert!(denied("edit_file", json!({"path": "../outside.txt"})));
4182 assert!(!denied(
4183 "write_file",
4184 json!({"path": "src/new.rs", "content": "x"})
4185 ));
4186 assert!(!denied(
4187 "write_file",
4188 json!({"path": "/wt/src/new.rs", "content": "x"})
4189 ));
4190 assert!(!denied(
4193 "read_file",
4194 json!({"path": "/usr/include/stdio.h"})
4195 ));
4196 }
4197
4198 #[test]
4199 fn stays_under_is_lexical_and_strict() {
4200 let root = Path::new("/wt");
4201 assert!(stays_under(root, "src/x.rs"));
4202 assert!(stays_under(root, "a/../b.txt"));
4203 assert!(stays_under(root, "/wt/deep/file"));
4204 assert!(!stays_under(root, "../escape"));
4205 assert!(!stays_under(root, "a/../../escape"));
4206 assert!(!stays_under(root, "/etc/passwd"));
4207 assert!(!stays_under(root, "/wtevil/file")); assert!(!stays_under(root, "~"));
4209 assert!(!stays_under(root, "~/outside"));
4210 assert!(!stays_under(root, "~someone/outside"));
4211 assert!(!stays_under(root, "$HOME"));
4212 assert!(!stays_under(root, "$HOME/outside"));
4213 assert!(!stays_under(root, "${HOME}/outside"));
4214 assert!(!stays_under(root, "${HOME:-/tmp}/outside"));
4215 assert!(!stays_under(root, "${HOME:=/tmp}/outside"));
4216 assert!(!stays_under(root, "$TMPDIR/outside"));
4217 assert!(!stays_under(root, "${TMPDIR}/outside"));
4218 assert!(!stays_under(root, "${TMPDIR:-/tmp}/outside"));
4219 assert!(!stays_under(root, "${TMPDIR:=/tmp}/outside"));
4220 assert!(!stays_under(root, "$HOME_fixture"));
4221 assert!(!stays_under(root, "${HOME_fixture}/inside"));
4222 assert!(stays_under(root, "src/$x"));
4223 assert!(stays_under(root, "src/~fixture"));
4224
4225 let temp = tempfile::tempdir().unwrap();
4226 std::fs::write(temp.path().join("$HOME_fixture"), "literal").unwrap();
4227 assert!(stays_under(temp.path(), "$HOME_fixture"));
4228 }
4229
4230 #[cfg(windows)]
4231 #[test]
4232 fn windows_destructive_and_privilege_denied() {
4233 let chain = coder_inspector_chain(Path::new(r"C:\wt"));
4234 let denied = |cmd: &str| chain.check("shell", &sh(cmd)).is_some();
4235 assert!(denied(r"del C:\Windows\System32\drivers\etc\hosts"));
4237 assert!(denied(r"rd /s /q C:\Windows"));
4238 assert!(denied(r"del /q C:\Users\victim\file")); assert!(denied(r"move C:\wt\keep.txt C:\Users\public\stolen.txt"));
4240 assert!(denied("runas /user:Administrator cmd"));
4242 assert!(denied("sc stop windefend"));
4243 assert!(!denied(r"del C:\wt\target\debug\app.exe"));
4245 assert!(!denied(r"del build\out.txt"));
4246 assert!(!denied("dir")); }
4248
4249 #[cfg(windows)]
4250 #[test]
4251 fn windows_credential_access_denied() {
4252 let chain = coder_inspector_chain(Path::new(r"C:\wt"));
4253 assert!(chain
4254 .check("shell", &sh(r"type %USERPROFILE%\.ssh\id_rsa"))
4255 .is_some());
4256 assert!(chain.check("shell", &sh("cmdkey /list")).is_some());
4257 assert!(chain
4258 .check(
4259 "read_file",
4260 &json!({"path": r"C:\Users\u\.aws\credentials"})
4261 )
4262 .is_some());
4263 assert!(chain
4265 .check("read_file", &json!({"path": r"C:\wt\src\main.rs"}))
4266 .is_none());
4267 }
4268
4269 #[cfg(windows)]
4270 #[test]
4271 fn stays_under_handles_verbatim_prefix_and_case() {
4272 let root = Path::new(r"\\?\C:\wt");
4276 assert!(stays_under(root, r"C:\WT\src\main.rs"));
4277 assert!(stays_under(root, r"c:\wt\src\main.rs"));
4278 assert!(!stays_under(root, r"C:\other\x"));
4279 assert!(!stays_under(root, r"C:\wtevil\x")); }
4281}