1use std::collections::{BTreeSet, HashMap};
21use std::path::{Component, Path, PathBuf};
22use std::sync::Arc;
23
24use car_ir::Action;
25use car_policy::{InspectionResult, Inspector, InspectorChain, PolicyEngine, PolicyRules};
26use car_state::StateStore;
27use serde_json::Value;
28
29fn coder_inspector_chain_for(worktree: &Path, deny_credentials: bool) -> InspectorChain {
30 let mut chain = InspectorChain::new()
31 .with(Box::new(DenyShellFunctionDeclaration))
32 .with(Box::new(DenyGitRemoteMutation))
33 .with(Box::new(DenyForgePublication))
34 .with(Box::new(DenyHistoryRewrite))
35 .with(Box::new(DenyPrivilegeEscalation));
36 if deny_credentials {
37 chain = chain.with(Box::new(DenyCredentialAccess));
38 }
39 chain
40 .with(Box::new(DenyEnvironmentRepair))
41 .with(Box::new(DenyDestructiveOutsideWorktree {
42 worktree: worktree.to_path_buf(),
43 }))
44 .with(Box::new(DenyPathEscape {
45 worktree: worktree.to_path_buf(),
46 }))
47}
48
49pub fn coder_inspector_chain(worktree: &Path) -> InspectorChain {
51 coder_inspector_chain_for(worktree, true)
52}
53
54pub(super) fn credentialed_contract_inspector_chain(worktree: &Path) -> InspectorChain {
61 coder_inspector_chain_for(worktree, false)
62}
63
64pub struct CoderPolicy {
85 pub chain: InspectorChain,
86 pub credentialed_contract_chain: InspectorChain,
87 pub denied_tools: BTreeSet<String>,
88}
89
90pub fn coder_inspector_chain_with_project_policies(
91 worktree: &Path,
92) -> Result<CoderPolicy, car_policy::PolicyLoadError> {
93 let dirs = [
94 car_home::root_or_relative().join("policies"),
95 worktree.join(".car").join("policies"),
96 ];
97 coder_inspector_chain_from_policy_dirs(worktree, &dirs)
98}
99
100fn coder_inspector_chain_from_policy_dirs(
101 worktree: &Path,
102 dirs: &[PathBuf],
103) -> Result<CoderPolicy, car_policy::PolicyLoadError> {
104 let mut rules = PolicyRules::default();
105 for dir in dirs {
106 rules.merge(car_policy::load_policy_dir(dir)?);
107 }
108
109 let mut engine = PolicyEngine::new();
110 rules.apply(&mut engine);
111 let denied_tools = engine.blanket_denied_tools();
115 let engine = Arc::new(engine);
116 let state = Arc::new(StateStore::new());
117 let project_policy = || {
118 Box::new(ProjectPolicyInspector {
119 engine: Arc::clone(&engine),
120 state: Arc::clone(&state),
121 }) as Box<dyn Inspector>
122 };
123 Ok(CoderPolicy {
124 chain: coder_inspector_chain(worktree).with(project_policy()),
125 credentialed_contract_chain: credentialed_contract_inspector_chain(worktree)
126 .with(project_policy()),
127 denied_tools,
128 })
129}
130
131struct ProjectPolicyInspector {
135 engine: Arc<PolicyEngine>,
136 state: Arc<StateStore>,
137}
138
139impl Inspector for ProjectPolicyInspector {
140 fn name(&self) -> &'static str {
141 "project_policy"
142 }
143
144 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
145 let mut action = Action::tool_call(tool);
148 action.id = "coder-policy-check".to_string();
149 action.parameters = params
150 .as_object()
151 .map(|m| {
152 m.iter()
153 .map(|(key, value)| (key.clone(), value.clone()))
154 .collect::<HashMap<_, _>>()
155 })
156 .unwrap_or_default();
157
158 match self.engine.check(&action, &self.state).into_iter().next() {
159 Some(violation) => InspectionResult::Deny(format!(
160 "operator policy '{}': {}",
161 violation.policy_name, violation.reason
162 )),
163 None => InspectionResult::Allow,
164 }
165 }
166}
167
168struct DenyGovernedShellPathEscape {
173 worktree: PathBuf,
174 oldpwd_is_worktree: bool,
178 cargo_target_dir: Option<PathBuf>,
182}
183
184impl DenyGovernedShellPathEscape {
185 fn new(worktree: &Path) -> Self {
186 let inherited_path = |name: &str| {
187 std::env::var_os(name).and_then(|value| {
188 let path = PathBuf::from(value);
189 (!path.as_os_str().is_empty()).then(|| {
190 if path.is_absolute() {
191 path
192 } else {
193 worktree.join(path)
194 }
195 })
196 })
197 };
198 let oldpwd_is_worktree = inherited_path("OLDPWD").is_some_and(|oldpwd| {
199 match (oldpwd.canonicalize(), worktree.canonicalize()) {
200 (Ok(oldpwd), Ok(worktree)) => oldpwd == worktree,
201 _ => false,
202 }
203 });
204 Self {
205 worktree: worktree.to_path_buf(),
206 oldpwd_is_worktree,
207 cargo_target_dir: inherited_path("CARGO_TARGET_DIR"),
208 }
209 }
210}
211
212const READ_OR_CHDIR_VERBS: &[&str] = &[
213 "cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "sed", "awk", "find",
214 "ls", "stat", "wc", "strings", "readlink", "realpath", "cd", "type",
215];
216
217fn skip_program_whitespace(program: &str, index: &mut usize) {
219 while let Some(ch) = program[*index..].chars().next() {
220 if !ch.is_whitespace() {
221 break;
222 }
223 *index += ch.len_utf8();
224 }
225}
226
227fn consume_sed_address(program: &str, index: &mut usize) -> bool {
231 let Some(first) = program[*index..].chars().next() else {
232 return false;
233 };
234 match first {
235 '0'..='9' => {
236 while let Some(ch) = program[*index..].chars().next() {
237 if !ch.is_ascii_digit() {
238 break;
239 }
240 *index += ch.len_utf8();
241 }
242 true
243 }
244 '$' => {
245 *index += 1;
246 true
247 }
248 '/' => {
249 *index += 1;
250 let mut escaped = false;
251 while let Some(ch) = program[*index..].chars().next() {
252 *index += ch.len_utf8();
253 if escaped {
254 escaped = false;
255 } else if ch == '\\' {
256 escaped = true;
257 } else if ch == '/' {
258 return true;
259 }
260 }
261 true
262 }
263 '\\' => {
266 *index += 1;
267 let Some(delimiter) = program[*index..].chars().next() else {
268 return true;
269 };
270 *index += delimiter.len_utf8();
271 let mut escaped = false;
272 while let Some(ch) = program[*index..].chars().next() {
273 *index += ch.len_utf8();
274 if escaped {
275 escaped = false;
276 } else if ch == '\\' {
277 escaped = true;
278 } else if ch == delimiter {
279 return true;
280 }
281 }
282 true
283 }
284 _ => false,
285 }
286}
287
288fn consume_sed_delimited_field(statement: &str, index: &mut usize, delimiter: char) -> bool {
290 let mut escaped = false;
291 while let Some(ch) = statement[*index..].chars().next() {
292 *index += ch.len_utf8();
293 if escaped {
294 escaped = false;
295 } else if ch == '\\' {
296 escaped = true;
297 } else if ch == delimiter {
298 return true;
299 }
300 }
301 false
302}
303
304fn sed_line_end(program: &str, index: usize) -> usize {
305 program[index..]
306 .find('\n')
307 .map_or(program.len(), |offset| index + offset)
308}
309
310fn skip_to_sed_separator(program: &str, index: &mut usize) {
311 while let Some(ch) = program[*index..].chars().next() {
312 if matches!(ch, ';' | '\n' | '{' | '}') {
313 break;
314 }
315 *index += ch.len_utf8();
316 }
317}
318
319fn sed_program_paths(program: &str) -> Result<Vec<String>, String> {
325 let mut paths = Vec::new();
326 let mut index = 0;
327 while index < program.len() {
328 skip_program_whitespace(program, &mut index);
329 while let Some(separator) = program[index..].chars().next() {
330 if !matches!(separator, ';' | '{' | '}') {
331 break;
332 }
333 index += separator.len_utf8();
334 skip_program_whitespace(program, &mut index);
335 }
336 if index == program.len() {
337 break;
338 }
339
340 loop {
341 if !consume_sed_address(program, &mut index) {
342 break;
343 }
344 skip_program_whitespace(program, &mut index);
345 if program[index..].starts_with(',') || program[index..].starts_with('~') {
346 index += 1;
347 skip_program_whitespace(program, &mut index);
348 continue;
349 }
350 break;
351 }
352 skip_program_whitespace(program, &mut index);
353 if program[index..].starts_with('!') {
354 index += 1;
355 skip_program_whitespace(program, &mut index);
356 }
357 let Some(command) = program[index..].chars().next() else {
358 break;
359 };
360 index += command.len_utf8();
361 match command {
362 'r' | 'R' | 'w' | 'W' => {
363 let end = sed_line_end(program, index);
366 let operand = program[index..end].trim();
367 if !operand.is_empty() {
368 paths.push(operand.to_string());
369 }
370 index = end;
371 }
372 's' => {
373 let Some(delimiter) = program[index..].chars().next() else {
374 break;
375 };
376 index += delimiter.len_utf8();
377 if !consume_sed_delimited_field(program, &mut index, delimiter)
378 || !consume_sed_delimited_field(program, &mut index, delimiter)
379 {
380 continue;
381 }
382 while let Some(flag) = program[index..].chars().next() {
383 match flag {
384 'e' => {
385 return Err(
386 "sed shell execution through the e flag is not allowed in a governed session"
387 .to_string(),
388 )
389 }
390 'w' => {
391 index += flag.len_utf8();
392 let end = sed_line_end(program, index);
393 let operand = program[index..end].trim();
394 if !operand.is_empty() {
395 paths.push(operand.to_string());
396 }
397 index = end;
398 break;
399 }
400 ';' | '\n' | '{' | '}' => break,
401 ch if ch.is_whitespace()
402 || ch.is_ascii_digit()
403 || matches!(ch, 'g' | 'i' | 'I' | 'm' | 'M' | 'p') =>
404 {
405 index += ch.len_utf8();
406 }
407 _ => {
408 skip_to_sed_separator(program, &mut index);
411 break;
412 }
413 }
414 }
415 }
416 'y' => {
417 let Some(delimiter) = program[index..].chars().next() else {
421 break;
422 };
423 index += delimiter.len_utf8();
424 if !consume_sed_delimited_field(program, &mut index, delimiter)
425 || !consume_sed_delimited_field(program, &mut index, delimiter)
426 {
427 continue;
428 }
429 }
430 'e' => return Err(
431 "sed shell execution through the e command is not allowed in a governed session"
432 .to_string(),
433 ),
434 'a' | 'c' | 'i' | '#' => index = sed_line_end(program, index),
438 ':' | 'b' | 'l' | 'q' | 'Q' | 't' | 'T' | 'v' => {
440 skip_to_sed_separator(program, &mut index)
441 }
442 _ => {}
443 }
444 }
445 Ok(paths)
446}
447
448fn awk_program_tokens(program: &str) -> Vec<String> {
449 fn finish(token: &mut String, result: &mut Vec<String>) {
450 if !token.is_empty() {
451 result.push(std::mem::take(token));
452 }
453 }
454
455 let mut result = Vec::new();
456 let mut token = String::new();
457 let mut quote = None;
458 let mut escaped = false;
459 let mut chars = program.chars().peekable();
460 while let Some(ch) = chars.next() {
461 if let Some(delimiter) = quote {
462 if escaped {
463 token.push(ch);
464 escaped = false;
465 } else if ch == '\\' {
466 #[cfg(windows)]
467 {
468 if chars
473 .peek()
474 .is_some_and(|next| *next != delimiter && *next != '\\')
475 {
476 token.push(ch);
477 } else {
478 escaped = true;
479 }
480 }
481 #[cfg(not(windows))]
482 {
483 escaped = true;
484 }
485 } else if ch == delimiter {
486 quote = None;
487 finish(&mut token, &mut result);
488 } else {
489 token.push(ch);
490 }
491 continue;
492 }
493 match ch {
494 '"' | '\'' => {
495 finish(&mut token, &mut result);
496 quote = Some(ch);
497 }
498 ch if ch.is_whitespace() => finish(&mut token, &mut result),
499 '<' | '>' | '|' => {
500 finish(&mut token, &mut result);
501 let mut operator = ch.to_string();
502 if (ch == '>' && chars.peek() == Some(&'>'))
503 || (ch == '|' && chars.peek() == Some(&'&'))
504 {
505 let joined = chars.next().expect("peeked operator suffix");
506 operator.push(joined);
507 }
508 result.push(operator);
509 }
510 ';' | '{' | '}' | '(' | ')' | ',' => {
511 finish(&mut token, &mut result);
512 result.push(ch.to_string());
513 }
514 _ => token.push(ch),
515 }
516 }
517 finish(&mut token, &mut result);
518 result
519}
520
521fn awk_program_paths(program: &str) -> Result<Vec<String>, String> {
526 let tokens = awk_program_tokens(program);
527 if tokens
528 .windows(2)
529 .any(|pair| pair[0] == "system" && pair[1] == "(")
530 {
531 return Err("awk system() is not allowed in a governed session".to_string());
532 }
533 for (index, token) in tokens.iter().enumerate() {
534 if !matches!(token.as_str(), "|" | "|&") {
535 continue;
536 }
537 let start = tokens[..index]
538 .iter()
539 .rposition(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
540 .map_or(0, |position| position + 1);
541 let end = tokens[index + 1..]
542 .iter()
543 .position(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
544 .map_or(tokens.len(), |offset| index + 1 + offset);
545 if tokens[start..index]
546 .iter()
547 .any(|candidate| matches!(candidate.as_str(), "print" | "printf"))
548 || tokens[index + 1..end]
549 .iter()
550 .any(|candidate| candidate == "getline")
551 {
552 return Err("awk command pipes are not allowed in a governed session".to_string());
553 }
554 }
555
556 let mut paths = Vec::new();
557 for (index, token) in tokens.iter().enumerate() {
558 let wanted = match token.as_str() {
559 "getline" => &["<"][..],
560 "print" | "printf" => &[">", ">>"][..],
561 _ => continue,
562 };
563 let end = tokens[index + 1..]
564 .iter()
565 .position(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
566 .map_or(tokens.len(), |offset| index + 1 + offset);
567 let Some(operator) = tokens[index + 1..end]
568 .iter()
569 .position(|candidate| wanted.contains(&candidate.as_str()))
570 .map(|offset| index + 1 + offset)
571 else {
572 continue;
573 };
574 if let Some(path) = tokens[operator + 1..end]
575 .iter()
576 .find(|candidate| candidate.as_str() != "(")
577 {
578 paths.push(path.clone());
579 }
580 }
581 Ok(paths)
582}
583
584fn inline_program_paths(verb: &str, program: &str) -> Result<Vec<String>, String> {
585 match verb {
586 "sed" => sed_program_paths(program),
587 "awk" => awk_program_paths(program),
588 _ => Ok(Vec::new()),
589 }
590}
591
592fn append_inline_program_paths(
593 verb: &str,
594 program: &str,
595 paths: &mut Vec<String>,
596) -> Result<(), String> {
597 paths.extend(inline_program_paths(verb, program)?);
598 Ok(())
599}
600
601fn has_brace_expansion_comma(source: &str) -> bool {
607 let mut braces = Vec::new();
608 let mut escaped = false;
609 for ch in source.chars() {
610 if escaped {
611 escaped = false;
612 continue;
613 }
614 match ch {
615 '\\' => escaped = true,
616 '{' => braces.push(false),
617 ',' => {
618 if let Some(brace) = braces.last_mut() {
619 *brace = true;
620 }
621 }
622 '}' if braces.pop().is_some_and(|has_comma| has_comma) => return true,
623 _ => {}
624 }
625 }
626 false
627}
628
629fn reject_shell_fed_program_source(verb: &str, source: &str) -> Result<(), String> {
630 let is_program_channel = source.is_empty()
633 || source == "-"
634 || source == "/dev/stdin"
635 || source
636 .strip_prefix("/dev/fd/")
637 .is_some_and(|fd| !fd.is_empty())
638 || source
639 .strip_prefix("/proc/self/fd/")
640 .is_some_and(|fd| !fd.is_empty())
641 || source.starts_with("<(")
642 || source.starts_with(">(")
643 || source.contains('$')
644 || source.contains('`')
645 || has_brace_expansion_comma(source);
646 if is_program_channel {
647 return Err(format!(
648 "{verb} script source '{source}' cannot be inspected in a governed session"
649 ));
650 }
651 Ok(())
652}
653
654fn append_program_source_path(
655 verb: &str,
656 source: &str,
657 paths: &mut Vec<String>,
658) -> Result<(), String> {
659 reject_shell_fed_program_source(verb, source)?;
660 paths.push(source.to_string());
661 Ok(())
662}
663
664fn reject_shell_fed_program_file_operands(verb: &str, args: &[String]) -> Result<(), String> {
671 for (index, arg) in args.iter().enumerate() {
672 if arg == "--" {
673 break;
674 }
675 let is_file_option = matches!(
676 (verb, arg.as_str()),
677 ("sed", "-f" | "--file") | ("awk", "-f" | "-E" | "--file")
678 );
679 if is_file_option {
680 let source = args.get(index + 1).map_or("", String::as_str);
681 reject_shell_fed_program_source(verb, source)?;
682 }
683 }
684 Ok(())
685}
686
687fn governed_path_arguments(verb: &str, tokens: &[String]) -> Result<Vec<String>, String> {
698 let Some(verb_index) = shell_verb_index(tokens) else {
699 return Ok(Vec::new());
700 };
701 let args = &tokens[verb_index + 1..];
702 if !matches!(verb, "sed" | "awk") {
703 return Ok(args
704 .iter()
705 .filter(|arg| !arg.starts_with('-'))
706 .cloned()
707 .collect());
708 }
709
710 reject_shell_fed_program_file_operands(verb, args)?;
711
712 let mut paths = Vec::new();
713 let mut explicit_program = false;
714 let mut positional_program_seen = false;
715 let mut options = true;
716 let mut index = 0;
717 while index < args.len() {
718 let arg = args[index].as_str();
719 if options && arg == "--" {
720 options = false;
721 index += 1;
722 continue;
723 }
724 if options && arg.starts_with('-') && arg != "-" {
725 match (verb, arg) {
726 ("sed", "-e" | "--expression") | ("awk", "-e" | "--source") => {
727 explicit_program = true;
728 if let Some(program) = args.get(index + 1) {
729 append_inline_program_paths(verb, program, &mut paths)?;
730 }
731 index += 2;
732 continue;
733 }
734 ("sed", "-f" | "--file") | ("awk", "-f" | "-E" | "--file") => {
735 explicit_program = true;
736 if let Some(source) = args.get(index + 1) {
737 append_program_source_path(verb, source, &mut paths)?;
738 }
739 index += 2;
740 continue;
741 }
742 ("awk", "-F" | "-v") => {
743 index += 2; continue;
745 }
746 _ => {}
747 }
748 if let Some(source) = arg.strip_prefix("--file=") {
749 explicit_program = true;
750 append_program_source_path(verb, source, &mut paths)?;
751 index += 1;
752 continue;
753 }
754 if let Some(program) = arg
755 .strip_prefix("--expression=")
756 .or_else(|| arg.strip_prefix("--source="))
757 {
758 explicit_program = true;
759 append_inline_program_paths(verb, program, &mut paths)?;
760 index += 1;
761 continue;
762 }
763
764 if verb == "sed" && !arg.starts_with("--") {
765 let cluster = &arg[1..];
770 let mut cursor = 0;
771 let mut handled = true;
772 while let Some(option) = cluster[cursor..].chars().next() {
773 cursor += option.len_utf8();
774 match option {
775 'n' | 'E' | 'r' | 's' | 'u' | 'z' => {}
776 'e' | 'f' => {
777 explicit_program = true;
778 let attached = &cluster[cursor..];
779 if option == 'e' {
780 if attached.is_empty() {
781 if let Some(program) = args.get(index + 1) {
782 append_inline_program_paths("sed", program, &mut paths)?;
783 }
784 index += 2;
785 } else {
786 append_inline_program_paths("sed", attached, &mut paths)?;
787 index += 1;
788 }
789 } else if attached.is_empty() {
790 if let Some(source) = args.get(index + 1) {
791 append_program_source_path("sed", source, &mut paths)?;
792 }
793 index += 2;
794 } else {
795 append_program_source_path("sed", attached, &mut paths)?;
796 index += 1;
797 }
798 break;
799 }
800 'i' => {
801 index += 1;
804 break;
805 }
806 _ => {
807 handled = false;
808 break;
809 }
810 }
811 }
812 if handled {
813 if cursor == cluster.len()
814 && !matches!(cluster.chars().last(), Some('e' | 'f' | 'i'))
815 {
816 index += 1;
817 }
818 continue;
819 }
820 }
821
822 let attached_file_source = arg
823 .strip_prefix("-f")
824 .filter(|source| !source.is_empty())
825 .or_else(|| {
826 if verb == "awk" {
827 arg.strip_prefix("-E").filter(|source| !source.is_empty())
828 } else {
829 None
830 }
831 });
832 if let Some(source) = attached_file_source {
833 explicit_program = true;
834 append_program_source_path(verb, source, &mut paths)?;
835 } else if let Some(program) = arg.strip_prefix("-e").filter(|p| !p.is_empty()) {
836 explicit_program = true;
837 append_inline_program_paths(verb, program, &mut paths)?;
838 }
839 index += 1;
840 continue;
841 }
842
843 if !explicit_program && !positional_program_seen {
844 positional_program_seen = true;
845 append_inline_program_paths(verb, arg, &mut paths)?;
846 } else {
847 paths.push(arg.to_string());
848 }
849 index += 1;
850 }
851 Ok(paths)
852}
853
854fn join_shell_line_continuations(command: &str) -> String {
859 let mut logical = String::with_capacity(command.len());
860 let mut chars = command.chars().peekable();
861 while let Some(ch) = chars.next() {
862 if ch == '\\' {
863 if chars.peek() == Some(&'\n') {
864 chars.next();
865 continue;
866 }
867 if chars.peek() == Some(&'\r') {
868 let mut lookahead = chars.clone();
869 if lookahead.next() == Some('\r') && lookahead.next() == Some('\n') {
870 chars.next();
871 chars.next();
872 continue;
873 }
874 }
875 }
876 logical.push(ch);
877 }
878 logical
879}
880
881#[derive(Debug, PartialEq, Eq)]
882enum ShellDeclarationToken {
883 Word { text: String, quoted: bool },
884 OpenParen,
885 CloseParen,
886 OpenBrace,
887 CloseBrace,
888 CommandBoundary,
889}
890
891fn shell_declaration_tokens(command: &str) -> Vec<ShellDeclarationToken> {
896 fn finish_word(tokens: &mut Vec<ShellDeclarationToken>, word: &mut String, quoted: &mut bool) {
897 if !word.is_empty() || *quoted {
898 tokens.push(ShellDeclarationToken::Word {
899 text: std::mem::take(word),
900 quoted: std::mem::take(quoted),
901 });
902 }
903 }
904
905 let mut tokens = Vec::new();
906 let mut word = String::new();
907 let mut word_quoted = false;
908 let logical_command = join_shell_line_continuations(command);
909 let segmented_command = split_shell_case_arm_bodies(&logical_command);
910 let mut chars = segmented_command.chars().peekable();
911 while let Some(ch) = chars.next() {
912 match ch {
913 '\\' => {
914 word_quoted = true;
915 if let Some(escaped) = chars.next() {
916 word.push(escaped);
917 }
918 }
919 '\'' | '"' => {
920 word_quoted = true;
921 let delimiter = ch;
922 while let Some(quoted) = chars.next() {
923 if quoted == delimiter {
924 break;
925 }
926 if delimiter == '"' && quoted == '\\' {
927 if let Some(escaped) = chars.next() {
928 word.push(escaped);
929 }
930 } else {
931 word.push(quoted);
932 }
933 }
934 }
935 '#' if word.is_empty() => {
936 finish_word(&mut tokens, &mut word, &mut word_quoted);
937 for comment in chars.by_ref() {
938 if comment == '\n' {
939 tokens.push(ShellDeclarationToken::CommandBoundary);
940 break;
941 }
942 }
943 }
944 '\n' | '\r' => {
945 finish_word(&mut tokens, &mut word, &mut word_quoted);
946 tokens.push(ShellDeclarationToken::CommandBoundary);
947 }
948 ch if ch.is_whitespace() => {
949 finish_word(&mut tokens, &mut word, &mut word_quoted);
950 }
951 ';' | ';' | '|' | '&' => {
952 finish_word(&mut tokens, &mut word, &mut word_quoted);
953 tokens.push(ShellDeclarationToken::CommandBoundary);
954 if chars.peek() == Some(&ch) {
955 chars.next();
956 }
957 }
958 '(' => {
959 finish_word(&mut tokens, &mut word, &mut word_quoted);
960 tokens.push(ShellDeclarationToken::OpenParen);
961 }
962 ')' => {
963 finish_word(&mut tokens, &mut word, &mut word_quoted);
964 tokens.push(ShellDeclarationToken::CloseParen);
965 }
966 '{' => {
967 finish_word(&mut tokens, &mut word, &mut word_quoted);
968 tokens.push(ShellDeclarationToken::OpenBrace);
969 }
970 '}' => {
971 finish_word(&mut tokens, &mut word, &mut word_quoted);
972 tokens.push(ShellDeclarationToken::CloseBrace);
973 }
974 _ => word.push(ch),
975 }
976 }
977 finish_word(&mut tokens, &mut word, &mut word_quoted);
978 tokens
979}
980
981pub(crate) fn contains_shell_function_declaration(command: &str) -> bool {
988 use ShellDeclarationToken::*;
989
990 let tokens = shell_declaration_tokens(command);
991 let mut command_position = true;
992 let mut index = 0;
993 while index < tokens.len() {
994 if command_position {
995 let posix_declaration = matches!(tokens.get(index), Some(Word { text, .. }) if !text.is_empty())
996 && matches!(tokens.get(index + 1), Some(OpenParen))
997 && matches!(tokens.get(index + 2), Some(CloseParen))
998 && matches!(tokens.get(index + 3), Some(OpenBrace));
999 let keyword_declaration = matches!(
1000 tokens.get(index),
1001 Some(Word { text, quoted: false }) if text == "function"
1002 ) && matches!(tokens.get(index + 1), Some(Word { text, .. }) if !text.is_empty())
1003 && (matches!(tokens.get(index + 2), Some(OpenBrace))
1004 || (matches!(tokens.get(index + 2), Some(OpenParen))
1005 && matches!(tokens.get(index + 3), Some(CloseParen))
1006 && matches!(tokens.get(index + 4), Some(OpenBrace))));
1007 if posix_declaration || keyword_declaration {
1008 return true;
1009 }
1010 }
1011
1012 match &tokens[index] {
1013 CommandBoundary | OpenBrace | OpenParen => command_position = true,
1014 Word {
1015 text,
1016 quoted: false,
1017 } if matches!(
1018 text.as_str(),
1019 "if" | "then" | "elif" | "else" | "while" | "until" | "do" | "time" | "!"
1020 ) =>
1021 {
1022 command_position = true
1023 }
1024 _ => command_position = false,
1025 }
1026 index += 1;
1027 }
1028 false
1029}
1030
1031struct DenyShellFunctionDeclaration;
1038
1039impl Inspector for DenyShellFunctionDeclaration {
1040 fn name(&self) -> &'static str {
1041 "coder.deny_shell_function_declaration"
1042 }
1043
1044 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
1045 let Some(command) = shell_command(tool, params) else {
1046 return InspectionResult::Allow;
1047 };
1048 if contains_shell_function_declaration(&command) {
1049 InspectionResult::Deny(
1050 "shell function declarations require explicit human approval and cannot be run by a coding model"
1051 .into(),
1052 )
1053 } else {
1054 InspectionResult::Allow
1055 }
1056 }
1057}
1058
1059#[derive(Clone, Copy)]
1060enum CaseScanPhase {
1061 Header { subject_seen: bool },
1062 Pattern,
1063 Body,
1064}
1065
1066fn shell_word_keeps_command_position(word: &str) -> bool {
1067 matches!(
1068 word,
1069 "{" | "}"
1070 | "if"
1071 | "then"
1072 | "elif"
1073 | "else"
1074 | "fi"
1075 | "for"
1076 | "do"
1077 | "done"
1078 | "while"
1079 | "until"
1080 | "function"
1081 | "time"
1082 | "!"
1083 )
1084}
1085
1086fn finish_case_scan_word(
1087 token: &mut String,
1088 token_started: &mut bool,
1089 token_quoted: &mut bool,
1090 cases: &mut Vec<CaseScanPhase>,
1091 command_position: &mut bool,
1092) {
1093 if !std::mem::take(token_started) {
1094 return;
1095 }
1096 let word = std::mem::take(token);
1097 let quoted = std::mem::take(token_quoted);
1098 match cases.last().copied() {
1099 Some(CaseScanPhase::Header { subject_seen }) => {
1100 if subject_seen && !quoted && word == "in" {
1101 *cases.last_mut().expect("case header exists") = CaseScanPhase::Pattern;
1102 *command_position = true;
1103 } else if !subject_seen {
1104 *cases.last_mut().expect("case header exists") =
1105 CaseScanPhase::Header { subject_seen: true };
1106 }
1107 }
1108 Some(CaseScanPhase::Pattern) => {
1109 if !quoted && word == "esac" && *command_position {
1110 cases.pop();
1111 *command_position = false;
1112 }
1113 }
1114 Some(CaseScanPhase::Body) | None => {
1115 if !quoted && word == "esac" && *command_position && !cases.is_empty() {
1116 cases.pop();
1117 *command_position = false;
1118 } else if !quoted && word == "case" && *command_position {
1119 cases.push(CaseScanPhase::Header {
1120 subject_seen: false,
1121 });
1122 *command_position = false;
1123 } else {
1124 *command_position =
1125 !quoted && *command_position && shell_word_keeps_command_position(&word);
1126 }
1127 }
1128 }
1129}
1130
1131fn split_shell_case_arm_bodies(command: &str) -> String {
1137 let mut output = String::with_capacity(command.len());
1138 let mut token = String::new();
1139 let mut token_started = false;
1140 let mut token_quoted = false;
1141 let mut quote = None;
1142 let mut nested_parentheses = 0usize;
1143 let mut cases = Vec::new();
1144 let mut command_position = true;
1145 let mut chars = command.chars().peekable();
1146
1147 while let Some(ch) = chars.next() {
1148 output.push(ch);
1149 if let Some(delimiter) = quote {
1150 if ch == delimiter {
1151 quote = None;
1152 } else if delimiter == '"' && ch == '\\' {
1153 if let Some(escaped) = chars.next() {
1154 output.push(escaped);
1155 token.push(escaped);
1156 token_started = true;
1157 }
1158 } else {
1159 token.push(ch);
1160 token_started = true;
1161 }
1162 continue;
1163 }
1164 if nested_parentheses > 0 {
1165 token.push(ch);
1166 token_started = true;
1167 match ch {
1168 '\\' => {
1169 if let Some(escaped) = chars.next() {
1170 output.push(escaped);
1171 token.push(escaped);
1172 }
1173 }
1174 '\'' | '"' => {
1175 quote = Some(ch);
1176 token_quoted = true;
1177 }
1178 '(' => nested_parentheses += 1,
1179 ')' => nested_parentheses -= 1,
1180 _ => {}
1181 }
1182 continue;
1183 }
1184
1185 match ch {
1186 '\\' => {
1187 if let Some(escaped) = chars.next() {
1188 output.push(escaped);
1189 token.push(escaped);
1190 token_started = true;
1191 token_quoted = true;
1192 }
1193 }
1194 '\'' | '"' => {
1195 quote = Some(ch);
1196 token_started = true;
1197 token_quoted = true;
1198 }
1199 ch if ch.is_whitespace() => {
1200 finish_case_scan_word(
1201 &mut token,
1202 &mut token_started,
1203 &mut token_quoted,
1204 &mut cases,
1205 &mut command_position,
1206 );
1207 if matches!(ch, '\n' | '\r') {
1208 command_position = true;
1209 }
1210 }
1211 ')' if matches!(cases.last(), Some(CaseScanPhase::Pattern)) => {
1212 let case_depth = cases.len();
1213 finish_case_scan_word(
1214 &mut token,
1215 &mut token_started,
1216 &mut token_quoted,
1217 &mut cases,
1218 &mut command_position,
1219 );
1220 if cases.len() == case_depth {
1223 *cases.last_mut().expect("case pattern remains") = CaseScanPhase::Body;
1224 command_position = true;
1225 output.push('\n');
1226 }
1227 }
1228 ';' => {
1229 finish_case_scan_word(
1230 &mut token,
1231 &mut token_started,
1232 &mut token_quoted,
1233 &mut cases,
1234 &mut command_position,
1235 );
1236 let mut arm_terminator = false;
1237 if chars.peek() == Some(&';') {
1238 output.push(chars.next().expect("peeked second semicolon"));
1239 arm_terminator = true;
1240 if chars.peek() == Some(&'&') {
1241 output.push(chars.next().expect("peeked case terminator ampersand"));
1242 }
1243 } else if chars.peek() == Some(&'&') {
1244 output.push(chars.next().expect("peeked case terminator ampersand"));
1245 arm_terminator = true;
1246 }
1247 if arm_terminator && matches!(cases.last(), Some(CaseScanPhase::Body)) {
1248 *cases.last_mut().expect("case body exists") = CaseScanPhase::Pattern;
1249 }
1250 command_position = true;
1251 }
1252 '|' | '&' => {
1253 finish_case_scan_word(
1254 &mut token,
1255 &mut token_started,
1256 &mut token_quoted,
1257 &mut cases,
1258 &mut command_position,
1259 );
1260 if chars.peek() == Some(&ch) {
1261 output.push(chars.next().expect("peeked shell operator"));
1262 }
1263 if !matches!(cases.last(), Some(CaseScanPhase::Pattern)) {
1264 command_position = true;
1265 }
1266 }
1267 '(' if token
1268 .chars()
1269 .last()
1270 .is_some_and(|prefix| matches!(prefix, '$' | '<' | '>'))
1271 || (matches!(cases.last(), Some(CaseScanPhase::Pattern))
1272 && token
1273 .chars()
1274 .last()
1275 .is_some_and(|prefix| matches!(prefix, '@' | '+' | '?' | '*' | '!'))) =>
1276 {
1277 token.push(ch);
1278 token_started = true;
1279 nested_parentheses = 1;
1280 }
1281 '(' => {
1282 finish_case_scan_word(
1283 &mut token,
1284 &mut token_started,
1285 &mut token_quoted,
1286 &mut cases,
1287 &mut command_position,
1288 );
1289 if !matches!(cases.last(), Some(CaseScanPhase::Pattern)) {
1290 command_position = true;
1291 }
1292 }
1293 ')' => {
1294 finish_case_scan_word(
1295 &mut token,
1296 &mut token_started,
1297 &mut token_quoted,
1298 &mut cases,
1299 &mut command_position,
1300 );
1301 command_position = false;
1302 }
1303 '{' | '}'
1304 if !token_started
1305 && chars.peek().is_none_or(|next| {
1306 next.is_whitespace() || matches!(next, ';' | ';' | '|' | '&')
1307 }) =>
1308 {
1309 command_position = true;
1310 }
1311 _ => {
1312 token.push(ch);
1313 token_started = true;
1314 }
1315 }
1316 }
1317 finish_case_scan_word(
1318 &mut token,
1319 &mut token_started,
1320 &mut token_quoted,
1321 &mut cases,
1322 &mut command_position,
1323 );
1324 output
1325}
1326
1327fn governed_shell_segments(command: &str) -> Vec<Vec<String>> {
1334 fn finish_token(
1335 token: &mut String,
1336 started: &mut bool,
1337 attached_empty: &mut bool,
1338 tokens: &mut Vec<String>,
1339 ) {
1340 if std::mem::take(started) {
1341 tokens.push(std::mem::take(token));
1342 if std::mem::take(attached_empty) {
1343 tokens.push(String::new());
1344 }
1345 }
1346 }
1347 fn finish_segment(
1348 token: &mut String,
1349 started: &mut bool,
1350 attached_empty: &mut bool,
1351 tokens: &mut Vec<String>,
1352 result: &mut Vec<Vec<String>>,
1353 ) {
1354 finish_token(token, started, attached_empty, tokens);
1355 if !tokens.is_empty() {
1356 result.push(std::mem::take(tokens));
1357 }
1358 }
1359
1360 let mut result = Vec::new();
1361 let mut tokens = Vec::new();
1362 let mut token = String::new();
1363 let mut token_started = false;
1364 let mut attached_empty = false;
1365 let mut quote = None;
1366 let mut arithmetic_depth = 0usize;
1367 let logical_command = split_shell_case_arm_bodies(&join_shell_line_continuations(command));
1368 let mut chars = logical_command.chars().peekable();
1369 while let Some(ch) = chars.next() {
1370 if arithmetic_depth > 0 {
1374 token.push(ch);
1375 token_started = true;
1376 attached_empty = false;
1377 match ch {
1378 '(' => arithmetic_depth += 1,
1379 ')' => arithmetic_depth -= 1,
1380 _ => {}
1381 }
1382 continue;
1383 }
1384 if let Some((delimiter, start_len, was_attached)) = quote {
1385 if ch == delimiter {
1386 quote = None;
1387 if was_attached && token.len() == start_len {
1388 attached_empty = true;
1389 }
1390 } else if delimiter == '"' && ch == '\\' {
1391 if chars
1394 .peek()
1395 .is_some_and(|next| matches!(next, '$' | '`' | '"' | '\\'))
1396 {
1397 token.push(chars.next().expect("peeked escaped character"));
1398 } else {
1399 token.push(ch);
1400 }
1401 attached_empty = false;
1402 } else {
1403 token.push(ch);
1404 attached_empty = false;
1405 }
1406 continue;
1407 }
1408 match ch {
1409 '\\' => {
1417 #[cfg(windows)]
1418 {
1419 token.push(ch);
1424 }
1425 #[cfg(not(windows))]
1426 {
1427 if let Some(escaped) = chars.next() {
1428 token.push(escaped);
1429 } else {
1430 token.push(ch);
1431 }
1432 }
1433 token_started = true;
1434 attached_empty = false;
1435 }
1436 '\'' | '"' => {
1437 quote = Some((ch, token.len(), token_started));
1438 token_started = true;
1439 }
1440 '\n' | '\r' => finish_segment(
1444 &mut token,
1445 &mut token_started,
1446 &mut attached_empty,
1447 &mut tokens,
1448 &mut result,
1449 ),
1450 '{' | '}'
1455 if !token_started
1456 && chars.peek().is_none_or(|next| {
1457 next.is_whitespace() || matches!(next, ';' | ';' | '|' | '&')
1458 }) =>
1459 {
1460 finish_segment(
1461 &mut token,
1462 &mut token_started,
1463 &mut attached_empty,
1464 &mut tokens,
1465 &mut result,
1466 )
1467 }
1468 ch if ch.is_whitespace() => finish_token(
1469 &mut token,
1470 &mut token_started,
1471 &mut attached_empty,
1472 &mut tokens,
1473 ),
1474 ';' | ';' | '|' | '&' => {
1475 finish_segment(
1476 &mut token,
1477 &mut token_started,
1478 &mut attached_empty,
1479 &mut tokens,
1480 &mut result,
1481 );
1482 if matches!(ch, '|' | '&') && chars.peek() == Some(&ch) {
1483 chars.next();
1484 }
1485 }
1486 '(' if matches!(token.chars().last(), Some('<' | '>')) => {
1491 token.push(ch);
1492 token_started = true;
1493 attached_empty = false;
1494 }
1495 '(' if chars.peek() == Some(&'(') => {
1496 token.push(ch);
1497 token.push(chars.next().expect("peeked arithmetic parenthesis"));
1498 token_started = true;
1499 attached_empty = false;
1500 arithmetic_depth = 2;
1501 }
1502 '(' | ')' => finish_segment(
1506 &mut token,
1507 &mut token_started,
1508 &mut attached_empty,
1509 &mut tokens,
1510 &mut result,
1511 ),
1512 _ => {
1513 token.push(ch);
1514 token_started = true;
1515 attached_empty = false;
1516 }
1517 }
1518 }
1519 finish_segment(
1520 &mut token,
1521 &mut token_started,
1522 &mut attached_empty,
1523 &mut tokens,
1524 &mut result,
1525 );
1526 result
1527}
1528
1529#[derive(Clone, Copy)]
1533struct CommandCarrier {
1534 name: &'static str,
1535 short_value_options: &'static [char],
1536 long_value_options: &'static [&'static str],
1537 leading_operands: usize,
1538 assignments: bool,
1539}
1540
1541const COMMAND_CARRIERS: &[CommandCarrier] = &[
1542 CommandCarrier {
1543 name: "env",
1544 short_value_options: &['a', 'C', 'S', 'u'],
1545 long_value_options: &["--argv0", "--chdir", "--split-string", "--unset"],
1546 leading_operands: 0,
1547 assignments: true,
1548 },
1549 CommandCarrier {
1550 name: "command",
1551 short_value_options: &[],
1552 long_value_options: &[],
1553 leading_operands: 0,
1554 assignments: false,
1555 },
1556 CommandCarrier {
1557 name: "exec",
1558 short_value_options: &['a'],
1559 long_value_options: &[],
1560 leading_operands: 0,
1561 assignments: false,
1562 },
1563 CommandCarrier {
1564 name: "nohup",
1565 short_value_options: &[],
1566 long_value_options: &[],
1567 leading_operands: 0,
1568 assignments: false,
1569 },
1570 CommandCarrier {
1571 name: "time",
1572 short_value_options: &['f', 'o'],
1573 long_value_options: &["--format", "--output"],
1574 leading_operands: 0,
1575 assignments: false,
1576 },
1577 CommandCarrier {
1578 name: "nice",
1579 short_value_options: &['n'],
1580 long_value_options: &["--adjustment"],
1581 leading_operands: 0,
1582 assignments: false,
1583 },
1584 CommandCarrier {
1585 name: "caffeinate",
1586 short_value_options: &['t', 'w'],
1587 long_value_options: &[],
1588 leading_operands: 0,
1589 assignments: false,
1590 },
1591 CommandCarrier {
1592 name: "script",
1593 short_value_options: &['B', 'F', 'I', 'O', 'T', 'c', 'm', 't'],
1594 long_value_options: &[
1595 "--command",
1596 "--log-in",
1597 "--log-io",
1598 "--log-out",
1599 "--log-timing",
1600 "--logging-format",
1601 ],
1602 leading_operands: 1,
1605 assignments: false,
1606 },
1607 CommandCarrier {
1608 name: "xargs",
1609 short_value_options: &['E', 'I', 'J', 'L', 'P', 'R', 'S', 'a', 'd', 'n', 's'],
1610 long_value_options: &[
1611 "--arg-file",
1612 "--delimiter",
1613 "--eof",
1614 "--max-args",
1615 "--max-chars",
1616 "--max-lines",
1617 "--max-procs",
1618 "--replace",
1619 ],
1620 leading_operands: 0,
1621 assignments: false,
1622 },
1623 CommandCarrier {
1624 name: "builtin",
1625 short_value_options: &[],
1626 long_value_options: &[],
1627 leading_operands: 0,
1628 assignments: false,
1629 },
1630 CommandCarrier {
1631 name: "busybox",
1634 short_value_options: &[],
1635 long_value_options: &[],
1636 leading_operands: 0,
1637 assignments: false,
1638 },
1639 CommandCarrier {
1640 name: "sudo",
1641 short_value_options: &['C', 'D', 'R', 'T', 'a', 'g', 'h', 'p', 'r', 't', 'u'],
1642 long_value_options: &[
1643 "--auth-type",
1644 "--chdir",
1645 "--chroot",
1646 "--close-from",
1647 "--command-timeout",
1648 "--group",
1649 "--host",
1650 "--prompt",
1651 "--role",
1652 "--type",
1653 "--user",
1654 ],
1655 leading_operands: 0,
1656 assignments: false,
1657 },
1658 CommandCarrier {
1659 name: "ionice",
1660 short_value_options: &['P', 'c', 'n', 'p', 'u'],
1661 long_value_options: &["--class", "--classdata", "--pgid", "--pid", "--uid"],
1662 leading_operands: 0,
1663 assignments: false,
1664 },
1665 CommandCarrier {
1666 name: "timeout",
1667 short_value_options: &['k', 's'],
1668 long_value_options: &["--kill-after", "--signal"],
1669 leading_operands: 1,
1671 assignments: false,
1672 },
1673];
1674
1675fn executable_name(raw: &str) -> String {
1676 let lower = raw.to_ascii_lowercase();
1677 let name = Path::new(&lower)
1678 .file_name()
1679 .and_then(|name| name.to_str())
1680 .unwrap_or(&lower);
1681 name.strip_suffix(".exe").unwrap_or(name).to_string()
1682}
1683
1684#[derive(Clone, Copy)]
1685struct ShellAssignmentParts<'a> {
1686 name: &'a str,
1687 subscript: Option<&'a str>,
1688 value: &'a str,
1689}
1690
1691fn shell_assignment_parts(token: &str) -> Option<ShellAssignmentParts<'_>> {
1692 let name_length = shell_name_len(token);
1693 if name_length == 0 {
1694 return None;
1695 }
1696 let name = &token[..name_length];
1697 let remainder = &token[name_length..];
1698 let (subscript, operator) = if remainder.starts_with('[') {
1699 let mut depth = 0usize;
1700 let mut close = None;
1701 for (index, ch) in remainder.char_indices() {
1702 match ch {
1703 '[' => depth += 1,
1704 ']' => {
1705 depth = depth.checked_sub(1)?;
1706 if depth == 0 {
1707 close = Some(index);
1708 break;
1709 }
1710 }
1711 _ => {}
1712 }
1713 }
1714 let close = close?;
1715 (Some(&remainder[1..close]), &remainder[close + 1..])
1716 } else {
1717 (None, remainder)
1718 };
1719 let value = operator
1723 .strip_prefix("+=")
1724 .or_else(|| operator.strip_prefix('='))?;
1725 Some(ShellAssignmentParts {
1726 name,
1727 subscript,
1728 value,
1729 })
1730}
1731
1732fn shell_subscript_contains_command_substitution(subscript: &str) -> bool {
1733 if subscript.contains('`') {
1734 return true;
1735 }
1736 let mut cursor = 0usize;
1737 while let Some(relative) = subscript[cursor..].find("$(") {
1738 let start = cursor + relative;
1739 if subscript[start + 2..].starts_with('(') {
1742 cursor = start + 3;
1743 } else {
1744 return true;
1745 }
1746 }
1747 false
1748}
1749
1750#[derive(Clone, Copy, PartialEq, Eq)]
1753enum SubscriptFlaw {
1754 CommandSubstitution,
1756 BreaksTokenization,
1760}
1761
1762fn subscript_flaw(subscript: &str) -> Option<SubscriptFlaw> {
1767 if shell_subscript_contains_command_substitution(subscript) {
1768 return Some(SubscriptFlaw::CommandSubstitution);
1769 }
1770 subscript
1771 .chars()
1772 .any(|ch| ch.is_whitespace() || matches!(ch, ';' | ';' | '|' | '&' | '\'' | '"' | '\\'))
1773 .then_some(SubscriptFlaw::BreaksTokenization)
1774}
1775
1776fn uninspectable_assignment_subscripts(command: &str) -> Vec<(&str, SubscriptFlaw)> {
1781 let bytes = command.as_bytes();
1782 let mut found = Vec::new();
1783 let mut index = 0usize;
1784 let mut quote = None;
1785 let mut word_boundary = true;
1786 while index < bytes.len() {
1787 let byte = bytes[index];
1788 if let Some(delimiter) = quote {
1789 if byte == delimiter {
1790 quote = None;
1791 } else if delimiter == b'"' && byte == b'\\' {
1792 index += usize::from(index + 1 < bytes.len());
1793 }
1794 index += 1;
1795 continue;
1796 }
1797 match byte {
1798 b'\\' => {
1799 index += 1 + usize::from(index + 1 < bytes.len());
1800 word_boundary = false;
1801 }
1802 b'\'' | b'"' => {
1803 quote = Some(byte);
1804 word_boundary = false;
1805 index += 1;
1806 }
1807 b' ' | b'\t' | b'\r' | b'\n' | b';' | b'|' | b'&' | b'(' => {
1808 word_boundary = true;
1809 index += 1;
1810 }
1811 b'_' | b'a'..=b'z' | b'A'..=b'Z' if word_boundary => {
1812 let name_start = index;
1813 index += 1;
1814 while index < bytes.len()
1815 && matches!(bytes[index], b'_' | b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9')
1816 {
1817 index += 1;
1818 }
1819 if bytes.get(index) == Some(&b'[') {
1820 let subscript_start = index + 1;
1821 let mut close_search = subscript_start;
1822 while let Some(relative) = command[close_search..].find(']') {
1823 let close = close_search + relative;
1824 let after = &command[close + 1..];
1825 if after.starts_with('=') || after.starts_with("+=") {
1826 let subscript = &command[subscript_start..close];
1827 if let Some(flaw) = subscript_flaw(subscript) {
1828 found.push((&command[name_start..index], flaw));
1829 }
1830 break;
1831 }
1832 close_search = close + 1;
1833 }
1834 }
1835 word_boundary = false;
1836 }
1837 _ => {
1838 word_boundary = false;
1839 index += 1;
1840 }
1841 }
1842 }
1843 found
1844}
1845
1846fn shell_assignment(token: &str) -> bool {
1847 shell_assignment_parts(token).is_some()
1848}
1849
1850fn compound_command_keyword(token: &str) -> bool {
1854 matches!(
1855 token,
1856 "{" | "}"
1857 | "if"
1858 | "then"
1859 | "elif"
1860 | "else"
1861 | "fi"
1862 | "for"
1863 | "do"
1864 | "done"
1865 | "while"
1866 | "until"
1867 | "case"
1868 | "esac"
1869 | "function"
1870 | "time"
1871 | "!"
1872 )
1873}
1874
1875fn shell_verb_index(tokens: &[String]) -> Option<usize> {
1887 let mut index = 0;
1888 while let Some(token) = tokens.get(index) {
1889 if token == "function" {
1890 index += 2;
1892 continue;
1893 }
1894 if let Some(span) = posix_function_head(&tokens[index..]) {
1895 index += span;
1896 continue;
1897 }
1898 if shell_assignment(token) {
1899 index += 1;
1900 continue;
1901 }
1902 if compound_command_keyword(token) && token != "time" {
1903 index += 1;
1904 continue;
1905 }
1906 return Some(index);
1907 }
1908 None
1909}
1910
1911fn posix_function_head(tokens: &[String]) -> Option<usize> {
1925 for span in 1..=3.min(tokens.len()) {
1926 let joined: String = tokens[..span].concat();
1927 let Some(name) = joined
1928 .strip_suffix("(){")
1929 .or_else(|| joined.strip_suffix("()"))
1930 else {
1931 continue;
1932 };
1933 if name.is_empty()
1934 || !name
1935 .chars()
1936 .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
1937 {
1938 continue;
1939 }
1940 if joined.ends_with('{') || tokens.get(span).is_some_and(|next| next == "{") {
1941 return Some(span);
1942 }
1943 }
1944 None
1945}
1946
1947fn leading_shell_assignments(command: &[String]) -> Vec<&str> {
1952 fn collect<'a>(command: &'a [String], start: usize, found: &mut Vec<&'a str>) {
1953 for token in &command[start..] {
1954 if compound_command_keyword(token) {
1955 continue;
1956 }
1957 if shell_assignment(token) {
1958 found.push(token);
1959 continue;
1960 }
1961 break;
1962 }
1963 }
1964
1965 let mut found = Vec::new();
1966 collect(command, 0, &mut found);
1967
1968 let case_header = command.first().is_some_and(|token| token == "case");
1969 let case_arm = command.first().is_some_and(|token| token.ends_with(')'));
1970 if case_header || case_arm {
1971 for (index, token) in command.iter().enumerate() {
1972 if token.ends_with(')') && !token.contains("$(") {
1973 collect(command, index + 1, &mut found);
1974 }
1975 }
1976 }
1977 found
1978}
1979
1980fn carrier_option_consumes_next(spec: CommandCarrier, option: &str) -> bool {
1981 if spec.long_value_options.contains(&option) {
1982 return true;
1983 }
1984 if option.starts_with("--") {
1985 return false;
1986 }
1987
1988 let mut options = option.strip_prefix('-').unwrap_or("").chars().peekable();
1989 while let Some(option) = options.next() {
1990 if spec.short_value_options.contains(&option) {
1991 return options.peek().is_none();
1994 }
1995 }
1996 false
1997}
1998
1999struct CarrierInvocation<'a> {
2000 command: &'a [String],
2001 assignments: Vec<&'a str>,
2002}
2003
2004fn carrier_command<'a>(
2009 tokens: &'a [String],
2010 verb_index: usize,
2011 verb: &str,
2012) -> Option<CarrierInvocation<'a>> {
2013 let spec = *COMMAND_CARRIERS.iter().find(|spec| spec.name == verb)?;
2014 let args = &tokens[verb_index + 1..];
2015 if verb == "command"
2019 && args
2020 .iter()
2021 .take_while(|arg| arg.as_str() != "--")
2022 .any(|arg| {
2023 arg.strip_prefix('-')
2024 .filter(|short| !short.starts_with('-'))
2025 .is_some_and(|short| short.contains('v') || short.contains('V'))
2026 })
2027 {
2028 return Some(CarrierInvocation {
2029 command: &[],
2030 assignments: Vec::new(),
2031 });
2032 }
2033 let mut index = 0;
2034 let mut options = true;
2035 let mut assignments = Vec::new();
2036 while index < args.len() {
2037 let arg = args[index].as_str();
2038 if options && arg == "--" {
2039 options = false;
2040 index += 1;
2041 continue;
2042 }
2043 if options && arg.starts_with('-') && arg != "-" {
2044 let consumes_next = !arg.contains('=') && carrier_option_consumes_next(spec, arg);
2045 index += 1 + usize::from(consumes_next && index + 1 < args.len());
2046 continue;
2047 }
2048 if spec.assignments && shell_assignment(arg) {
2049 assignments.push(arg);
2050 index += 1;
2051 continue;
2052 }
2053 break;
2054 }
2055 index = (index + spec.leading_operands).min(args.len());
2056 Some(CarrierInvocation {
2057 command: &args[index..],
2058 assignments,
2059 })
2060}
2061
2062fn has_short_command_flag(args: &[String], wanted: char, value_options: &[char]) -> bool {
2065 let mut index = 0;
2066 while index < args.len() {
2067 let arg = args[index].as_str();
2068 if arg == "--" || !arg.starts_with('-') || arg == "-" {
2069 break;
2070 }
2071 let Some(short) = arg
2072 .strip_prefix('-')
2073 .filter(|short| !short.starts_with('-'))
2074 else {
2075 index += 1;
2076 continue;
2077 };
2078 let mut options = short.chars().peekable();
2079 while let Some(option) = options.next() {
2080 if option == wanted {
2081 return true;
2082 }
2083 if value_options.contains(&option) {
2084 if options.peek().is_none() {
2085 index += 1;
2086 }
2087 break;
2088 }
2089 }
2090 index += 1;
2091 }
2092 false
2093}
2094
2095fn shell_has_script_operand(args: &[String]) -> bool {
2096 let mut index = 0;
2097 while index < args.len() {
2098 let arg = args[index].as_str();
2099 if arg == "--" {
2100 return index + 1 < args.len();
2101 }
2102 if !arg.starts_with('-') || arg == "-" {
2103 return true;
2104 }
2105 let consumes_next = matches!(arg, "-O" | "-o" | "--init-file" | "--rcfile");
2106 index += 1 + usize::from(consumes_next && index + 1 < args.len());
2107 }
2108 false
2109}
2110
2111fn nested_command_carrier(verb: &str, args: &[String]) -> bool {
2112 if verb == "eval" {
2113 return true;
2114 }
2115 if verb == "env" {
2119 return has_short_command_flag(args, 'S', &['a', 'C', 'S', 'u'])
2120 || args
2121 .iter()
2122 .any(|arg| arg == "--split-string" || arg.starts_with("--split-string="));
2123 }
2124 if matches!(
2125 verb,
2126 "bash" | "sh" | "zsh" | "dash" | "ksh" | "fish" | "ash"
2127 ) {
2128 return has_short_command_flag(args, 'c', &['O', 'o'])
2129 || (verb == "fish"
2130 && (has_short_command_flag(args, 'C', &['C'])
2131 || args.iter().any(|arg| {
2132 matches!(arg.as_str(), "--command" | "--init-command")
2133 || arg.starts_with("--command=")
2134 || arg.starts_with("--init-command=")
2135 })))
2136 || !shell_has_script_operand(args);
2137 }
2138 if verb == "script" {
2139 return has_short_command_flag(args, 'c', &['B', 'F', 'I', 'O', 'T', 'c', 'm', 't'])
2140 || args
2141 .iter()
2142 .any(|arg| arg == "--command" || arg.starts_with("--command="));
2143 }
2144 if verb == "perl" {
2145 const PERL_VALUE_OPTIONS: &[char] =
2148 &['0', 'C', 'D', 'F', 'I', 'M', 'V', 'd', 'i', 'l', 'm', 'x'];
2149 return has_short_command_flag(args, 'e', PERL_VALUE_OPTIONS)
2150 || has_short_command_flag(args, 'E', PERL_VALUE_OPTIONS);
2151 }
2152 if matches!(verb, "ruby" | "node" | "osascript") {
2153 let value_options: &[char] = match verb {
2154 "ruby" => &['0', 'C', 'E', 'F', 'I', 'S', 'i', 'r', 'x'],
2155 "node" => &['C', 'r'],
2156 "osascript" => &['l'],
2157 _ => unreachable!("matched interpreter"),
2158 };
2159 return has_short_command_flag(args, 'e', value_options)
2160 || (verb == "node"
2161 && args
2162 .iter()
2163 .any(|arg| arg == "--eval" || arg.starts_with("--eval=")));
2164 }
2165 if verb == "python"
2166 || verb == "py"
2167 || verb == "python3"
2168 || verb
2169 .strip_prefix("python3.")
2170 .is_some_and(|minor| minor.chars().all(|ch| ch.is_ascii_digit()))
2171 {
2172 return has_short_command_flag(args, 'c', &['W', 'X', 'Q', 'm']);
2173 }
2174 false
2175}
2176
2177struct LeadingShellVariable<'a> {
2178 modifier: &'a str,
2182 suffix: &'a str,
2184}
2185
2186fn shell_name_len(value: &str) -> usize {
2187 let mut chars = value.char_indices();
2188 let Some((_, first)) = chars.next() else {
2189 return 0;
2190 };
2191 if first != '_' && !first.is_ascii_alphabetic() {
2192 return 0;
2193 }
2194 chars
2195 .take_while(|(_, ch)| *ch == '_' || ch.is_ascii_alphanumeric())
2196 .last()
2197 .map_or(first.len_utf8(), |(index, ch)| index + ch.len_utf8())
2198}
2199
2200fn leading_shell_variable(candidate: &str) -> Option<LeadingShellVariable<'_>> {
2204 if let Some(rest) = candidate.strip_prefix("${") {
2205 let length = shell_name_len(rest);
2206 if length == 0 {
2207 return None;
2208 }
2209 let mut depth = 1usize;
2210 let mut close = None;
2211 let mut index = length;
2212 while index < rest.len() {
2213 if rest[index..].starts_with("${") {
2214 depth += 1;
2215 index += 2;
2216 continue;
2217 }
2218 let ch = rest[index..].chars().next().expect("index is in bounds");
2219 if ch == '}' {
2220 depth -= 1;
2221 if depth == 0 {
2222 close = Some(index);
2223 break;
2224 }
2225 }
2226 index += ch.len_utf8();
2227 }
2228 let close = close?;
2229 let suffix = &rest[close + 1..];
2230 if !suffix.is_empty() && !suffix.starts_with('/') {
2231 return None;
2232 }
2233 return Some(LeadingShellVariable {
2234 modifier: &rest[length..close],
2235 suffix,
2236 });
2237 }
2238
2239 let rest = candidate.strip_prefix('$')?;
2240 let length = shell_name_len(rest);
2241 if length == 0 {
2242 return None;
2243 }
2244 let suffix = &rest[length..];
2245 if !suffix.is_empty() && !suffix.starts_with('/') {
2246 return None;
2247 }
2248 Some(LeadingShellVariable {
2249 modifier: "",
2250 suffix,
2251 })
2252}
2253
2254#[derive(Clone, Copy)]
2255struct ShellVariableReference<'a> {
2256 name: &'a str,
2257 start: usize,
2258 end: usize,
2259}
2260
2261fn shell_variable_references(candidate: &str) -> Result<Vec<ShellVariableReference<'_>>, ()> {
2265 let mut references = Vec::new();
2266 let mut cursor = 0;
2267 while let Some(relative) = candidate[cursor..].find('$') {
2268 let start = cursor + relative;
2269 let after_dollar = start + 1;
2270 let rest = &candidate[after_dollar..];
2271 if let Some(braced) = rest.strip_prefix('{') {
2272 let length = shell_name_len(braced);
2273 if length == 0 || !braced[length..].starts_with('}') {
2274 return Err(());
2275 }
2276 let end = after_dollar + 1 + length + 1;
2277 references.push(ShellVariableReference {
2278 name: &braced[..length],
2279 start,
2280 end,
2281 });
2282 cursor = end;
2283 } else {
2284 let length = shell_name_len(rest);
2285 if length == 0 {
2286 return Err(());
2287 }
2288 let end = after_dollar + length;
2289 references.push(ShellVariableReference {
2290 name: &rest[..length],
2291 start,
2292 end,
2293 });
2294 cursor = end;
2295 }
2296 }
2297 Ok(references)
2298}
2299
2300fn resolved_shell_variable<'a>(
2301 gate: &'a DenyGovernedShellPathEscape,
2302 name: &str,
2303) -> Option<&'a Path> {
2304 match name {
2305 "PWD" => Some(gate.worktree.as_path()),
2306 "OLDPWD" if gate.oldpwd_is_worktree => Some(gate.worktree.as_path()),
2307 "CARGO_TARGET_DIR" => gate.cargo_target_dir.as_deref(),
2308 _ => None,
2309 }
2310}
2311
2312fn variable_operand_denial(
2316 gate: &DenyGovernedShellPathEscape,
2317 verb: &str,
2318 candidate: &str,
2319) -> Option<String> {
2320 if !candidate.contains('$') {
2321 return None;
2322 }
2323
2324 if let Some(operand) = leading_shell_variable(candidate) {
2327 if operand.suffix.is_empty()
2328 && operand.modifier.is_empty()
2329 && gate.worktree.join(candidate).exists()
2330 && stays_under(&gate.worktree, candidate)
2331 {
2332 return None;
2333 }
2334 }
2335
2336 let references = match shell_variable_references(candidate) {
2337 Ok(references) if !references.is_empty() => references,
2338 _ => {
2339 return Some(format!(
2340 "'{verb}' variable operand cannot be inspected: '{candidate}'"
2341 ));
2342 }
2343 };
2344 #[cfg(windows)]
2345 let safe_relative_prefix = references.first().is_some_and(|reference| {
2346 reference.start > 0
2347 && !Path::new(&candidate[..reference.start]).is_absolute()
2348 && !candidate[..reference.start].contains("..")
2349 && !candidate.contains("..")
2350 });
2351 let mut expanded = String::with_capacity(candidate.len());
2352 let mut copied = 0;
2353 for reference in references {
2354 let Some(value) = resolved_shell_variable(gate, reference.name) else {
2355 return Some(format!(
2356 "'{verb}' variable operand cannot be inspected: '{candidate}'"
2357 ));
2358 };
2359 expanded.push_str(&candidate[copied..reference.start]);
2360 expanded.push_str(&value.to_string_lossy());
2361 copied = reference.end;
2362 }
2363 expanded.push_str(&candidate[copied..]);
2364
2365 #[cfg(windows)]
2366 if safe_relative_prefix {
2367 return None;
2371 }
2372
2373 let expanded_path = Path::new(&expanded);
2374 let stays_governed = if expanded_path.is_absolute() {
2375 stays_under(&gate.worktree, &expanded)
2376 || gate
2377 .cargo_target_dir
2378 .as_deref()
2379 .is_some_and(|root| stays_under(root, &expanded))
2380 } else {
2381 stays_under(&gate.worktree, &expanded)
2382 };
2383 if stays_governed {
2384 None
2385 } else {
2386 Some(format!(
2387 "'{verb}' variable path '{candidate}' resolves outside its governed root"
2388 ))
2389 }
2390}
2391
2392fn governed_operand_prefix_variables(segment: &[String]) -> BTreeSet<String> {
2393 let mut names = BTreeSet::new();
2394 let mut command = segment;
2395 while let Some(verb_index) = shell_verb_index(command) {
2396 let verb = executable_name(&command[verb_index]);
2397 if READ_OR_CHDIR_VERBS.contains(&verb.as_str()) {
2398 if let Ok(paths) = governed_path_arguments(&verb, command) {
2399 for path in paths {
2400 let candidate = path
2401 .trim_matches(|c: char| matches!(c, '"' | '\'' | '(' | ')' | ',' | ';'));
2402 if let Ok(references) = shell_variable_references(candidate) {
2403 if let Some(reference) = references.first().filter(|item| item.start == 0) {
2404 names.insert(reference.name.to_string());
2405 }
2406 }
2407 }
2408 }
2409 }
2410 let Some(invocation) = carrier_command(command, verb_index, &verb) else {
2411 break;
2412 };
2413 if invocation.command.is_empty() {
2414 break;
2415 }
2416 command = invocation.command;
2417 }
2418 names
2419}
2420
2421fn shell_root_escape(candidate: &str) -> bool {
2422 ["HOME", "TMPDIR"].iter().any(|name| {
2423 let unbraced = format!("${name}");
2424 let unbraced_match = candidate.strip_prefix(&unbraced).is_some_and(|suffix| {
2425 suffix
2426 .chars()
2427 .next()
2428 .is_none_or(|ch| ch != '_' && !ch.is_ascii_alphanumeric())
2429 });
2430 let braced = format!("${{{name}");
2431 let braced_match = candidate.strip_prefix(&braced).is_some_and(|suffix| {
2432 suffix
2433 .chars()
2434 .next()
2435 .is_some_and(|ch| ch != '_' && !ch.is_ascii_alphanumeric())
2436 });
2437 unbraced_match || braced_match
2438 })
2439}
2440
2441fn execution_redirecting_variable(name: &str) -> bool {
2442 matches!(
2443 name,
2444 "BASH_ENV"
2445 | "ENV"
2446 | "PATH"
2447 | "LD_PRELOAD"
2448 | "PYTHONPATH"
2449 | "PERL5LIB"
2450 | "RUBYLIB"
2451 | "NODE_OPTIONS"
2452 | "CARGO_HOME"
2453 | "RUSTUP_HOME"
2454 | "GIT_EXEC_PATH"
2455 | "GIT_SSH_COMMAND"
2456 ) || name.starts_with("DYLD_")
2457}
2458
2459fn command_opens_editor(verb: &str, args: &[String]) -> bool {
2460 (verb == "git"
2461 && args.iter().any(|arg| {
2462 matches!(
2463 arg.as_str(),
2464 "add" | "commit" | "config" | "merge" | "rebase" | "tag"
2465 )
2466 }))
2467 || matches!(verb, "crontab" | "vipw" | "vigr" | "visudo")
2468}
2469
2470fn assignment_value_names_path(value: &str) -> bool {
2471 value.contains('/')
2472 || value.contains('\\')
2473 || value.starts_with('~')
2474 || shell_root_escape(value)
2475 || value.contains("..")
2476 || (value.as_bytes().get(1) == Some(&b':')
2477 && value
2478 .as_bytes()
2479 .first()
2480 .is_some_and(u8::is_ascii_alphabetic))
2481}
2482
2483fn effective_command(mut command: &[String]) -> Option<(String, &[String])> {
2484 loop {
2485 let verb_index = shell_verb_index(command)?;
2486 let current_verb = executable_name(&command[verb_index]);
2487 let args = &command[verb_index + 1..];
2488 let Some(invocation) = carrier_command(command, verb_index, ¤t_verb) else {
2489 return Some((current_verb, args));
2490 };
2491 if invocation.command.is_empty() {
2492 return Some((current_verb, args));
2493 }
2494 command = invocation.command;
2495 }
2496}
2497
2498struct AssignmentBuiltinOperands<'a> {
2499 assignments: Vec<&'a str>,
2500 removed_names: Vec<&'a str>,
2501}
2502
2503fn assignment_builtin_operands<'a>(
2508 command: &'a [String],
2509 verb_index: usize,
2510 verb: &str,
2511) -> AssignmentBuiltinOperands<'a> {
2512 let args = &command[verb_index + 1..];
2513 let assignment_builtin = matches!(
2514 verb,
2515 "export" | "readonly" | "declare" | "typeset" | "local"
2516 );
2517 let assignments = if assignment_builtin {
2518 args.iter()
2519 .map(String::as_str)
2520 .filter(|arg| shell_assignment(arg))
2521 .collect()
2522 } else {
2523 Vec::new()
2524 };
2525
2526 let removes_export_attribute = verb == "export"
2527 && args
2528 .iter()
2529 .take_while(|arg| arg.as_str() != "--")
2530 .filter_map(|arg| arg.strip_prefix('-'))
2531 .any(|options| !options.starts_with('-') && options.contains('n'));
2532 let removes_variables = verb == "unset" || removes_export_attribute;
2533 let removed_names = if removes_variables {
2534 args.iter()
2535 .skip_while(|arg| arg.starts_with('-') && arg.as_str() != "--")
2536 .filter(|arg| arg.as_str() != "--")
2537 .map(String::as_str)
2538 .filter(|arg| shell_name_len(arg) == arg.len())
2539 .collect()
2540 } else {
2541 Vec::new()
2542 };
2543
2544 AssignmentBuiltinOperands {
2545 assignments,
2546 removed_names,
2547 }
2548}
2549
2550fn assignment_name_denial(
2551 name: &str,
2552 opens_editor: bool,
2553 operand_prefix_variables: &BTreeSet<String>,
2554) -> Option<String> {
2555 if execution_redirecting_variable(name) || (matches!(name, "EDITOR" | "VISUAL") && opens_editor)
2556 {
2557 return Some(format!(
2558 "environment assignment '{name}' may redirect executable code in a governed session"
2559 ));
2560 }
2561 if matches!(
2562 name,
2563 "PWD" | "OLDPWD" | "CARGO_TARGET_DIR" | "HOME" | "TMPDIR" | "IFS" | "PATH"
2564 ) || operand_prefix_variables.contains(name)
2565 {
2566 return Some(format!(
2567 "environment assignment '{name}' may change a governed path operand"
2568 ));
2569 }
2570 None
2571}
2572
2573fn assignment_denial(
2574 worktree: &Path,
2575 command: &[String],
2576 operand_prefix_variables: &BTreeSet<String>,
2577) -> Option<String> {
2578 let opens_editor =
2579 effective_command(command).is_some_and(|(verb, args)| command_opens_editor(&verb, args));
2580 let mut current = command;
2581 loop {
2582 let verb_index = shell_verb_index(current).unwrap_or(current.len());
2585 let invocation = if verb_index < current.len() {
2586 let current_verb = executable_name(¤t[verb_index]);
2587 carrier_command(current, verb_index, ¤t_verb)
2588 } else {
2589 None
2590 };
2591 let carrier_assignments = invocation
2592 .as_ref()
2593 .into_iter()
2594 .flat_map(|invocation| invocation.assignments.iter().copied());
2595 let builtin_operands = if verb_index < current.len() {
2596 assignment_builtin_operands(current, verb_index, &executable_name(¤t[verb_index]))
2597 } else {
2598 AssignmentBuiltinOperands {
2599 assignments: Vec::new(),
2600 removed_names: Vec::new(),
2601 }
2602 };
2603 let assignments = leading_shell_assignments(current)
2604 .into_iter()
2605 .chain(carrier_assignments)
2606 .chain(builtin_operands.assignments);
2607 for assignment in assignments {
2608 let Some(parts) = shell_assignment_parts(assignment) else {
2609 continue;
2610 };
2611 if parts
2612 .subscript
2613 .is_some_and(shell_subscript_contains_command_substitution)
2614 {
2615 return Some(format!(
2616 "environment assignment '{}' has a subscript that cannot be inspected",
2617 parts.name
2618 ));
2619 }
2620 if let Some(reason) =
2621 assignment_name_denial(parts.name, opens_editor, operand_prefix_variables)
2622 {
2623 return Some(reason);
2624 }
2625 if assignment_value_names_path(parts.value) && !stays_under(worktree, parts.value) {
2626 return Some(format!(
2627 "environment assignment '{}' names path '{}' outside the governed repository",
2628 parts.name, parts.value
2629 ));
2630 }
2631 }
2632 for name in builtin_operands.removed_names {
2633 if let Some(reason) =
2634 assignment_name_denial(name, opens_editor, operand_prefix_variables)
2635 {
2636 return Some(reason);
2637 }
2638 }
2639 let Some(invocation) = invocation else {
2640 break;
2641 };
2642 if invocation.command.is_empty() {
2643 break;
2644 }
2645 current = invocation.command;
2646 }
2647 None
2648}
2649
2650fn uninspectable_subscript_denial(
2657 command: &str,
2658 segments: &[Vec<String>],
2659 operand_prefix_variables: &BTreeSet<String>,
2660) -> Option<String> {
2661 let candidates = uninspectable_assignment_subscripts(command);
2662 if candidates.is_empty() {
2663 return None;
2664 }
2665 let opens_editor = segments.iter().any(|segment| {
2666 effective_command(segment).is_some_and(|(verb, args)| command_opens_editor(&verb, args))
2667 });
2668 for (name, flaw) in candidates {
2669 match flaw {
2670 SubscriptFlaw::CommandSubstitution => {
2671 return Some(format!(
2672 "environment assignment '{name}' has a subscript that cannot be inspected"
2673 ));
2674 }
2675 SubscriptFlaw::BreaksTokenization => {
2676 if let Some(reason) =
2677 assignment_name_denial(name, opens_editor, operand_prefix_variables)
2678 {
2679 return Some(reason);
2680 }
2681 }
2682 }
2683 }
2684 None
2685}
2686
2687impl Inspector for DenyGovernedShellPathEscape {
2688 fn name(&self) -> &'static str {
2689 "governed_host.deny_shell_path_escape"
2690 }
2691
2692 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2693 let Some(cmd) = shell_command(tool, params) else {
2694 return InspectionResult::Allow;
2695 };
2696 let segments = governed_shell_segments(&cmd);
2697 let mut future_operand_prefix_variables = vec![BTreeSet::new(); segments.len()];
2698 let mut suffix_variables = BTreeSet::new();
2699 for (index, segment) in segments.iter().enumerate().rev() {
2700 suffix_variables.extend(governed_operand_prefix_variables(segment));
2701 future_operand_prefix_variables[index] = suffix_variables.clone();
2702 }
2703 if let Some(reason) = uninspectable_subscript_denial(&cmd, &segments, &suffix_variables) {
2708 return InspectionResult::Deny(reason);
2709 }
2710 for (index, seg) in segments.into_iter().enumerate() {
2711 if let Some(reason) = assignment_denial(
2712 &self.worktree,
2713 &seg,
2714 &future_operand_prefix_variables[index],
2715 ) {
2716 return InspectionResult::Deny(reason);
2717 }
2718 let mut command = seg.as_slice();
2719 while let Some(verb_index) = shell_verb_index(command) {
2720 let v = executable_name(&command[verb_index]);
2721 let args = &command[verb_index + 1..];
2722 if v == "busybox"
2723 && args
2724 .iter()
2725 .any(|arg| arg == "--install" || arg.starts_with("--install="))
2726 {
2727 return InspectionResult::Deny(
2728 "busybox --install may write outside the governed repository".into(),
2729 );
2730 }
2731 if nested_command_carrier(&v, args) {
2732 return InspectionResult::Deny(format!(
2733 "nested shell command through '{v}' cannot be inspected in a governed session"
2734 ));
2735 }
2736 if READ_OR_CHDIR_VERBS.contains(&v.as_str()) {
2737 let governed_args = match governed_path_arguments(&v, command) {
2738 Ok(paths) => paths,
2739 Err(reason) => return InspectionResult::Deny(reason),
2740 };
2741 for arg in governed_args {
2742 let candidate = arg.trim_matches(|c: char| {
2743 matches!(c, '"' | '\'' | '(' | ')' | ',' | ';')
2744 });
2745 if let Some(reason) = variable_operand_denial(self, &v, candidate) {
2746 return InspectionResult::Deny(reason);
2747 }
2748 let names_path = candidate.starts_with('~')
2749 || shell_root_escape(candidate)
2750 || is_abs_or_traversal(candidate)
2751 || self.worktree.join(candidate).exists();
2752 if names_path && !stays_under(&self.worktree, candidate) {
2753 return InspectionResult::Deny(format!(
2754 "'{v}' path '{candidate}' resolves outside the governed repository"
2755 ));
2756 }
2757 }
2758 }
2759
2760 let Some(invocation) = carrier_command(command, verb_index, &v) else {
2761 break;
2762 };
2763 if invocation.command.is_empty() {
2764 break;
2765 }
2766 command = invocation.command;
2767 }
2768 }
2769 InspectionResult::Allow
2770 }
2771}
2772
2773struct DenyGovernedFilePathEscape {
2776 worktree: PathBuf,
2777}
2778
2779impl Inspector for DenyGovernedFilePathEscape {
2780 fn name(&self) -> &'static str {
2781 "governed_host.deny_file_path_escape"
2782 }
2783
2784 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2785 if !matches!(
2786 tool,
2787 "read_file" | "write_file" | "edit_file" | "grep_files"
2788 ) {
2789 return InspectionResult::Allow;
2790 }
2791 let Some(path) = params.get("path").and_then(Value::as_str) else {
2792 return InspectionResult::Allow;
2793 };
2794 if stays_under(&self.worktree, path) {
2795 InspectionResult::Allow
2796 } else {
2797 InspectionResult::Deny(format!(
2798 "file access to '{path}' resolves outside the governed repository"
2799 ))
2800 }
2801 }
2802}
2803
2804pub fn governed_host_inspector_chain(worktree: &Path) -> InspectorChain {
2809 InspectorChain::new()
2810 .with(Box::new(DenyShellFunctionDeclaration))
2811 .with(Box::new(DenyGuiShellAutomation))
2812 .with(Box::new(DenyForcePushAndRemoteReconfiguration))
2813 .with(Box::new(DenyBroadGitStage))
2814 .with(Box::new(DenyHistoryRewrite))
2815 .with(Box::new(DenyPrivilegeEscalation))
2816 .with(Box::new(DenyCredentialAccess))
2817 .with(Box::new(DenyEnvironmentRepair))
2818 .with(Box::new(DenyDestructiveOutsideWorktree {
2819 worktree: worktree.to_path_buf(),
2820 }))
2821 .with(Box::new(DenyGovernedShellPathEscape::new(worktree)))
2822 .with(Box::new(DenyGovernedFilePathEscape {
2823 worktree: worktree.to_path_buf(),
2824 }))
2825}
2826
2827struct DenyGuiShellAutomation;
2831
2832impl Inspector for DenyGuiShellAutomation {
2833 fn name(&self) -> &'static str {
2834 "governed_host.deny_gui_shell_automation"
2835 }
2836
2837 fn inspect(&self, tool: &str, _params: &Value) -> InspectionResult {
2838 if matches!(tool, "run_applescript" | "run_powershell") {
2839 InspectionResult::Deny(
2840 "desktop-driven shell execution is not allowed; use the governed shell tool".into(),
2841 )
2842 } else {
2843 InspectionResult::Allow
2844 }
2845 }
2846}
2847
2848struct DenyBroadGitStage;
2851
2852impl Inspector for DenyBroadGitStage {
2853 fn name(&self) -> &'static str {
2854 "governed_host.deny_broad_git_stage"
2855 }
2856
2857 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2858 let Some(cmd) = shell_command(tool, params) else {
2859 return InspectionResult::Allow;
2860 };
2861 for seg in segments(&cmd) {
2862 if verb(&seg) != Some("git") {
2863 continue;
2864 }
2865 let add = seg.iter().position(|token| token == "add");
2866 if let Some(index) = add {
2867 if seg
2868 .iter()
2869 .skip(index + 1)
2870 .any(|token| matches!(token.as_str(), "." | "-A" | "--all" | "-u" | "--update"))
2871 {
2872 return InspectionResult::Deny(
2873 "broad git staging is not allowed; name only the files changed for this task"
2874 .into(),
2875 );
2876 }
2877 }
2878 if seg.iter().any(|token| token == "commit")
2879 && seg.iter().any(|token| {
2880 token == "--all"
2881 || token
2882 .strip_prefix('-')
2883 .filter(|short| !short.starts_with('-'))
2884 .is_some_and(|short| short.contains('a'))
2885 })
2886 {
2887 return InspectionResult::Deny(
2888 "git commit -a is not allowed; stage only explicit task files".into(),
2889 );
2890 }
2891 }
2892 InspectionResult::Allow
2893 }
2894}
2895
2896pub(crate) fn stays_under(root: &Path, candidate: &str) -> bool {
2900 if candidate.starts_with('~') || shell_root_escape(candidate) {
2905 return false;
2906 }
2907 if let Some(variable) = leading_shell_variable(candidate) {
2912 if !variable.suffix.is_empty()
2913 || !variable.modifier.is_empty()
2914 || !root.join(candidate).exists()
2915 {
2916 return false;
2917 }
2918 }
2919 let p = Path::new(candidate);
2920 let joined = if p.is_absolute() {
2921 p.to_path_buf()
2922 } else {
2923 root.join(p)
2924 };
2925 if joined.exists() {
2931 if let (Ok(real_root), Ok(real_candidate)) = (root.canonicalize(), joined.canonicalize()) {
2932 return path_starts_with(&real_candidate, &real_root);
2933 }
2934 return false;
2935 }
2936 if let Ok(real_root) = root.canonicalize() {
2937 let mut ancestor = joined.as_path();
2938 while !ancestor.exists() {
2939 let Some(parent) = ancestor.parent() else {
2940 return false;
2941 };
2942 ancestor = parent;
2943 }
2944 match ancestor.canonicalize() {
2945 Ok(real_ancestor) if path_starts_with(&real_ancestor, &real_root) => {}
2946 _ => return false,
2947 }
2948 }
2949 let mut stack: Vec<Component> = Vec::new();
2950 for c in joined.components() {
2951 match c {
2952 Component::CurDir => {}
2953 Component::ParentDir => {
2954 if stack.pop().is_none() {
2955 return false;
2956 }
2957 }
2958 other => stack.push(other),
2959 }
2960 }
2961 let normalized: PathBuf = stack.iter().collect();
2962 path_starts_with(&normalized, root)
2963}
2964
2965#[cfg(not(windows))]
2971fn path_starts_with(path: &Path, base: &Path) -> bool {
2972 path.starts_with(base)
2973}
2974
2975#[cfg(windows)]
2976fn path_starts_with(path: &Path, base: &Path) -> bool {
2977 fn key(p: &Path) -> String {
2978 let s = p.to_string_lossy().into_owned();
2979 let s = if let Some(r) = s.strip_prefix(r"\\?\UNC\") {
2980 format!(r"\\{r}")
2981 } else if let Some(r) = s.strip_prefix(r"\\?\") {
2982 r.to_string()
2983 } else {
2984 s
2985 };
2986 s.replace('/', "\\").to_ascii_lowercase()
2987 }
2988 let base_key = key(base);
2989 let base_trim = base_key.trim_end_matches('\\');
2990 let path_key = key(path);
2991 path_key == base_trim || path_key.starts_with(&format!("{base_trim}\\"))
2992}
2993
2994fn is_abs_or_traversal(arg: &str) -> bool {
3000 arg.starts_with('/')
3001 || arg.starts_with('\\')
3002 || arg.contains("..")
3003 || Path::new(arg).is_absolute()
3004}
3005
3006fn is_windows_switch(arg: &str) -> bool {
3012 #[cfg(not(windows))]
3013 {
3014 let _ = arg;
3015 false
3016 }
3017 #[cfg(windows)]
3018 {
3019 arg.strip_prefix('/')
3020 .map(|rest| {
3021 (1..=2).contains(&rest.len()) && rest.chars().all(|c| c.is_ascii_alphanumeric())
3022 })
3023 .unwrap_or(false)
3024 }
3025}
3026
3027fn segments(command: &str) -> Vec<Vec<String>> {
3034 let outer: Vec<Vec<String>> =
3035 split_shell_case_arm_bodies(&join_shell_line_continuations(command))
3036 .replace("&&", "\n")
3037 .replace("||", "\n")
3038 .replace([';', ';', '|'], "\n")
3039 .lines()
3040 .map(|seg| {
3041 seg.split_whitespace()
3042 .map(|t| t.trim_matches(|c| c == '"' || c == '\'').to_string())
3043 .filter(|t| !t.is_empty())
3044 .collect::<Vec<_>>()
3045 })
3046 .filter(|toks: &Vec<String>| !toks.is_empty())
3047 .collect();
3048
3049 let mut expanded = Vec::new();
3050 for segment in outer {
3051 expanded.push(segment.clone());
3052 let mut command = segment.as_slice();
3053 while let Some(verb_index) = shell_verb_index(command) {
3054 let v = executable_name(&command[verb_index]);
3055 let Some(invocation) = carrier_command(command, verb_index, &v) else {
3056 break;
3057 };
3058 if invocation.command.is_empty() {
3059 break;
3060 }
3061 expanded.push(invocation.command.to_vec());
3062 command = invocation.command;
3063 }
3064 }
3065 expanded
3066}
3067
3068fn verb(tokens: &[String]) -> Option<&str> {
3070 shell_verb_index(tokens).map(|index| tokens[index].as_str())
3071}
3072
3073fn shell_command(tool: &str, params: &Value) -> Option<String> {
3074 if tool != "shell" {
3075 return None;
3076 }
3077 params
3078 .get("command")
3079 .and_then(Value::as_str)
3080 .map(str::to_string)
3081}
3082
3083struct DenyGitRemoteMutation;
3086
3087impl Inspector for DenyGitRemoteMutation {
3088 fn name(&self) -> &'static str {
3089 "coder.deny_git_remote_mutation"
3090 }
3091
3092 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3093 let Some(cmd) = shell_command(tool, params) else {
3094 return InspectionResult::Allow;
3095 };
3096 for seg in segments(&cmd) {
3097 let is_git = verb(&seg) == Some("git");
3098 if !is_git {
3099 continue;
3100 }
3101 if seg.iter().any(|t| t == "push") {
3102 return InspectionResult::Deny(
3103 "git push is not allowed from a coder session — results are delivered \
3104 via the approved local branch"
3105 .into(),
3106 );
3107 }
3108 if seg.iter().any(|t| t == "remote")
3109 && seg
3110 .iter()
3111 .any(|t| t == "add" || t == "set-url" || t == "remove")
3112 {
3113 return InspectionResult::Deny("mutating git remotes is not allowed".into());
3114 }
3115 }
3116 InspectionResult::Allow
3117 }
3118}
3119
3120struct DenyForgePublication;
3145
3146const FORGE_VERBS: &[&str] = &["gh", "glab", "hub"];
3148
3149const FORGE_READS: &[(&str, &[&str])] = &[
3152 ("pr", &["view", "list", "diff", "checks", "status"]),
3153 ("mr", &["view", "list", "diff", "checks", "status"]),
3154 ("issue", &["view", "list"]),
3155 ("repo", &["view"]),
3156 ("run", &["view", "list", "watch"]),
3157 ("release", &["view", "list"]),
3158 ("workflow", &["view", "list"]),
3159 ("label", &["list"]),
3160 ("cache", &["list"]),
3161 ("gist", &["view", "list"]),
3162 ("auth", &["status"]),
3163 ("search", &[]),
3164 ("status", &[]),
3165 ("version", &[]),
3166];
3167
3168const FORGE_VALUE_FLAGS: &[&str] = &["-r", "--repo", "--hostname"];
3171
3172const PUBLICATION_COMMANDS: &[(&str, &[&str])] = &[
3174 ("npm", &["publish"]),
3175 ("pnpm", &["publish"]),
3176 ("yarn", &["publish"]),
3177 ("cargo", &["publish"]),
3178 ("gem", &["push"]),
3179 ("twine", &["upload"]),
3180 ("docker", &["push", "login"]),
3181];
3182
3183impl Inspector for DenyForgePublication {
3184 fn name(&self) -> &'static str {
3185 "coder.deny_forge_publication"
3186 }
3187
3188 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3189 let Some(cmd) = shell_command(tool, params) else {
3190 return InspectionResult::Allow;
3191 };
3192 for seg in segments(&cmd) {
3193 let Some(v) = verb(&seg) else { continue };
3194 let v = Path::new(&v.to_ascii_lowercase())
3197 .file_name()
3198 .map(|f| f.to_string_lossy().into_owned())
3199 .unwrap_or_default();
3200 let v = v.strip_suffix(".exe").unwrap_or(&v).to_string();
3201 let args: Vec<String> = seg.iter().skip(1).map(|a| a.to_ascii_lowercase()).collect();
3202
3203 if FORGE_VERBS.contains(&v.as_str()) {
3204 if let Some(reason) = forge_denial(&v, &args) {
3205 return InspectionResult::Deny(reason);
3206 }
3207 continue;
3208 }
3209
3210 for (mgr, subs) in PUBLICATION_COMMANDS {
3211 if v != *mgr {
3212 continue;
3213 }
3214 if leading_operands(&args).iter().any(|sub| subs.contains(sub)) {
3215 return InspectionResult::Deny(format!(
3216 "'{mgr}' publication is not allowed from a coder session — results \
3217 leave the worktree only through the approved merge branch"
3218 ));
3219 }
3220 }
3221 }
3222 InspectionResult::Allow
3223 }
3224}
3225
3226fn forge_operands(args: &[String]) -> Vec<&str> {
3229 let mut operands = Vec::new();
3230 let mut skip_value = false;
3231 for arg in args {
3232 if std::mem::take(&mut skip_value) {
3233 continue;
3234 }
3235 if FORGE_VALUE_FLAGS.contains(&arg.as_str()) {
3236 skip_value = true;
3237 continue;
3238 }
3239 if arg.starts_with('-') {
3240 continue;
3241 }
3242 operands.push(arg.as_str());
3243 }
3244 operands
3245}
3246
3247fn leading_operands(args: &[String]) -> Vec<&str> {
3266 args.iter()
3267 .map(String::as_str)
3268 .filter(|a| !a.starts_with('-') && !a.starts_with('+'))
3269 .take(2)
3270 .collect()
3271}
3272
3273fn forge_denial(verb: &str, args: &[String]) -> Option<String> {
3277 const BLOCKED: &str = "publishing from a coder session is not allowed — the runtime opens \
3278 the pull request after `coder.approve_merge`";
3279
3280 if args
3293 .iter()
3294 .any(|a| matches!(a.as_str(), "--version" | "--help"))
3295 {
3296 return None;
3297 }
3298 let operands = forge_operands(args);
3299 let Some(group) = operands.first().copied() else {
3300 return None; };
3302
3303 if group == "api" {
3306 let is_write_method = |v: &str| !v.is_empty() && v != "get";
3312 let explicit_method = args
3313 .windows(2)
3314 .any(|pair| matches!(pair[0].as_str(), "--method" | "-x") && is_write_method(&pair[1]))
3315 || args.iter().any(|a| {
3316 a.strip_prefix("--method=")
3317 .or_else(|| a.strip_prefix("-x"))
3318 .is_some_and(is_write_method)
3319 });
3320 let field_flag = |a: &String| {
3322 matches!(a.as_str(), "-f" | "--field" | "--raw-field" | "--input")
3323 || a.starts_with("--field=")
3324 || a.starts_with("--raw-field=")
3325 || a.starts_with("--input=")
3326 || a.starts_with("-f")
3327 };
3328 let graphql = operands.get(1).is_some_and(|o| *o == "graphql");
3333 let mutating_graphql = graphql
3334 && args
3335 .iter()
3336 .any(|a| a.contains("mutation") || a.contains("deletion"));
3337 let implicit_post = !graphql && args.iter().any(field_flag);
3338 return (explicit_method || implicit_post || mutating_graphql)
3339 .then(|| format!("'{verb} api' with a write method is not allowed — {BLOCKED}"));
3340 }
3341
3342 if group == "auth"
3348 && args
3349 .iter()
3350 .any(|a| a == "-t" || a == "--show-token" || a.starts_with("--show-token="))
3351 {
3352 return Some(format!(
3353 "'{verb} auth status --show-token' prints the forge credential — {BLOCKED}"
3354 ));
3355 }
3356
3357 let Some((_, subs)) = FORGE_READS.iter().find(|(g, _)| *g == group) else {
3358 return Some(format!("'{verb} {group}' is not allowed — {BLOCKED}"));
3359 };
3360 if subs.is_empty() {
3361 return None;
3362 }
3363 match operands.get(1).copied() {
3364 Some(sub) if subs.contains(&sub) => None,
3365 Some(sub) => Some(format!("'{verb} {group} {sub}' is not allowed — {BLOCKED}")),
3366 None => Some(format!(
3369 "'{verb} {group}' without a read-only subcommand is not allowed — {BLOCKED}"
3370 )),
3371 }
3372}
3373
3374struct DenyForcePushAndRemoteReconfiguration;
3377
3378impl Inspector for DenyForcePushAndRemoteReconfiguration {
3379 fn name(&self) -> &'static str {
3380 "governed_host.deny_force_push_and_remote_reconfiguration"
3381 }
3382
3383 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3384 let Some(cmd) = shell_command(tool, params) else {
3385 return InspectionResult::Allow;
3386 };
3387 for seg in segments(&cmd) {
3388 if verb(&seg) != Some("git") {
3389 continue;
3390 }
3391 let push = seg.iter().any(|token| token == "push");
3392 let forced = seg.iter().any(|token| {
3393 token == "--force"
3394 || token == "-f"
3395 || token.starts_with("--force-with-lease")
3396 || token.starts_with('+')
3397 });
3398 if push && forced {
3399 return InspectionResult::Deny("force-push is never allowed".into());
3400 }
3401 if seg.iter().any(|token| token == "remote")
3402 && seg.iter().any(|token| {
3403 token == "add" || token == "set-url" || token == "remove" || token == "rename"
3404 })
3405 {
3406 return InspectionResult::Deny(
3407 "mutating git remote configuration is not allowed".into(),
3408 );
3409 }
3410 }
3411 InspectionResult::Allow
3412 }
3413}
3414
3415struct DenyHistoryRewrite;
3418
3419impl Inspector for DenyHistoryRewrite {
3420 fn name(&self) -> &'static str {
3421 "coder.deny_history_rewrite"
3422 }
3423
3424 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3425 let Some(cmd) = shell_command(tool, params) else {
3426 return InspectionResult::Allow;
3427 };
3428 for seg in segments(&cmd) {
3429 if verb(&seg) != Some("git") {
3430 continue;
3431 }
3432 if seg.iter().any(|t| t == "rebase" || t == "filter-branch") {
3433 return InspectionResult::Deny("git history rewrite is not allowed".into());
3434 }
3435 if seg.iter().any(|t| t == "reset") && seg.iter().any(|t| t == "--hard") {
3436 return InspectionResult::Deny("git reset --hard is not allowed".into());
3437 }
3438 if seg.iter().any(|t| t == "worktree") && seg.iter().any(|t| t == "remove") {
3439 return InspectionResult::Deny(
3440 "removing worktrees is the runtime's job, not the agent's".into(),
3441 );
3442 }
3443 }
3444 InspectionResult::Allow
3445 }
3446}
3447
3448struct DenyPrivilegeEscalation;
3451
3452const PRIVILEGE_VERBS: &[&str] = &[
3453 "sudo",
3455 "doas",
3456 "su",
3457 "launchctl",
3458 "systemctl", "runas",
3461 "sc",
3462 "psexec",
3463];
3464
3465impl Inspector for DenyPrivilegeEscalation {
3466 fn name(&self) -> &'static str {
3467 "coder.deny_privilege_escalation"
3468 }
3469
3470 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3471 let Some(cmd) = shell_command(tool, params) else {
3472 return InspectionResult::Allow;
3473 };
3474 for seg in segments(&cmd) {
3475 if let Some(v) = verb(&seg) {
3476 if PRIVILEGE_VERBS.contains(&v.to_ascii_lowercase().as_str()) {
3477 return InspectionResult::Deny(format!(
3478 "'{v}' is not allowed in a coder session"
3479 ));
3480 }
3481 }
3482 }
3483 InspectionResult::Allow
3484 }
3485}
3486
3487struct DenyCredentialAccess;
3489
3490const CREDENTIAL_PATH_MARKERS: [&str; 6] = [
3491 "/.ssh",
3492 "/.aws",
3493 "/.gnupg",
3494 "/.kube",
3495 "/.car/secrets",
3496 "/.netrc",
3497];
3498
3499impl Inspector for DenyCredentialAccess {
3500 fn name(&self) -> &'static str {
3501 "coder.deny_credential_access"
3502 }
3503
3504 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3505 let haystacks: Vec<String> = if let Some(cmd) = shell_command(tool, params) {
3506 if cmd.contains("find-generic-password") || cmd.contains("find-internet-password") {
3507 return InspectionResult::Deny("keychain access is not allowed".into());
3508 }
3509 let cmd_lower = cmd.to_ascii_lowercase();
3511 if cmd_lower.contains("cmdkey") || cmd_lower.contains("vaultcmd") {
3512 return InspectionResult::Deny(
3513 "Windows Credential Manager access is not allowed".into(),
3514 );
3515 }
3516 let sensitive_env = [
3517 "_key",
3518 "_token",
3519 "_secret",
3520 "_password",
3521 "openai_",
3522 "anthropic_",
3523 "azure_client_",
3524 "github_token",
3525 "connection_string",
3526 ];
3527 if sensitive_env
3528 .iter()
3529 .any(|marker| cmd_lower.contains(marker))
3530 {
3531 return InspectionResult::Deny(
3532 "reading or expanding credential environment variables is not allowed".into(),
3533 );
3534 }
3535 for seg in segments(&cmd) {
3536 let Some(command) = verb(&seg).map(|value| value.to_ascii_lowercase()) else {
3537 continue;
3538 };
3539 if command == "env" && seg.len() == 1
3540 || command == "printenv"
3541 || command == "set" && seg.len() == 1
3542 {
3543 return InspectionResult::Deny(
3544 "dumping the process environment is not allowed".into(),
3545 );
3546 }
3547 }
3548 vec![cmd]
3549 } else if matches!(
3550 tool,
3551 "read_file" | "write_file" | "edit_file" | "grep_files"
3552 ) {
3553 params
3554 .get("path")
3555 .and_then(Value::as_str)
3556 .map(|p| vec![p.to_string()])
3557 .unwrap_or_default()
3558 } else {
3559 return InspectionResult::Allow;
3560 };
3561 for hay in &haystacks {
3562 let hay = hay.replace('\\', "/");
3566 let hay = hay
3567 .replace("~/", "/HOME/.")
3568 .replace("$HOME/", "/HOME/.")
3569 .replace("%USERPROFILE%/", "/HOME/.")
3570 .replace("%HOMEPATH%/", "/HOME/.");
3571 let hay = hay.replace("/HOME/..", "/."); for marker in CREDENTIAL_PATH_MARKERS {
3573 if hay.contains(marker) {
3574 return InspectionResult::Deny(format!(
3575 "access to credential path matching '{marker}' is not allowed"
3576 ));
3577 }
3578 }
3579 }
3580 InspectionResult::Allow
3581 }
3582}
3583
3584struct DenyDestructiveOutsideWorktree {
3587 worktree: PathBuf,
3588}
3589
3590const DESTRUCTIVE_VERBS: &[&str] = &[
3591 "rm", "rmdir", "mv", "cp", "chmod", "chown", "truncate", "dd", "del", "erase", "rd", "move", "copy", "format", "ren", "rename",
3596];
3597
3598impl Inspector for DenyDestructiveOutsideWorktree {
3599 fn name(&self) -> &'static str {
3600 "coder.deny_destructive_outside_worktree"
3601 }
3602
3603 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3604 let Some(cmd) = shell_command(tool, params) else {
3605 return InspectionResult::Allow;
3606 };
3607 for seg in segments(&cmd) {
3608 let Some(v) = verb(&seg) else { continue };
3609 let v_lower = v.to_ascii_lowercase();
3611 if !DESTRUCTIVE_VERBS.contains(&v_lower.as_str()) {
3612 continue;
3613 }
3614 for arg in seg
3616 .iter()
3617 .skip(1)
3618 .filter(|a| !a.starts_with('-') && !is_windows_switch(a))
3619 {
3620 if arg.starts_with('~') {
3621 return InspectionResult::Deny(format!(
3622 "'{v}' on a home-relative path ('{arg}') is not allowed"
3623 ));
3624 }
3625 if is_abs_or_traversal(arg) && !stays_under(&self.worktree, arg) {
3626 return InspectionResult::Deny(format!(
3627 "'{v}' outside the worktree ('{arg}') is not allowed"
3628 ));
3629 }
3630 }
3631 }
3632 InspectionResult::Allow
3633 }
3634}
3635
3636struct DenyEnvironmentRepair;
3652
3653const PACKAGE_MUTATIONS: &[(&str, &[&str])] = &[
3657 ("pip", &["install", "uninstall"]),
3658 ("pip3", &["install", "uninstall"]),
3659 ("conda", &["install", "remove", "uninstall", "update"]),
3660 ("poetry", &["add", "remove", "install", "update"]),
3661 ("uv", &["add", "remove", "sync"]),
3662 ("easy_install", &[]),
3663];
3664
3665const SHIM_FILES: &[&str] = &["sitecustomize.py", "usercustomize.py"];
3667
3668impl Inspector for DenyEnvironmentRepair {
3669 fn name(&self) -> &'static str {
3670 "coder.deny_environment_repair"
3671 }
3672
3673 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3674 if matches!(tool, "write_file" | "edit_file") {
3675 let path = params.get("path").and_then(Value::as_str).unwrap_or("");
3676 let base = Path::new(path)
3677 .file_name()
3678 .map(|f| f.to_string_lossy().to_ascii_lowercase())
3679 .unwrap_or_default();
3680 if SHIM_FILES.contains(&base.as_str()) {
3681 return InspectionResult::Deny(format!(
3682 "writing '{base}' changes how the interpreter loads, not what your code \
3683 does — the runtime re-runs the contract in the correct environment"
3684 ));
3685 }
3686 return InspectionResult::Allow;
3687 }
3688
3689 let Some(cmd) = shell_command(tool, params) else {
3690 return InspectionResult::Allow;
3691 };
3692 for seg in segments(&cmd) {
3693 let Some(v) = verb(&seg) else { continue };
3694 let v = Path::new(&v.to_ascii_lowercase())
3698 .file_name()
3699 .map(|f| f.to_string_lossy().into_owned())
3700 .unwrap_or_default();
3701 let v = v.strip_suffix(".exe").unwrap_or(&v).to_string();
3702 let args: Vec<String> = seg.iter().skip(1).map(|a| a.to_ascii_lowercase()).collect();
3703
3704 let module = args
3707 .iter()
3708 .position(|a| a == "-m")
3709 .and_then(|i| args.get(i + 1))
3710 .cloned();
3711 let (effective, effective_args): (String, Vec<String>) = match module {
3712 Some(m) if v.starts_with("python") || v.starts_with("py") => {
3713 let rest = args
3714 .iter()
3715 .skip_while(|a| **a != m)
3716 .skip(1)
3717 .cloned()
3718 .collect();
3719 (m, rest)
3720 }
3721 _ => (v.clone(), args.clone()),
3722 };
3723
3724 if effective == "venv" || effective == "virtualenv" {
3725 return InspectionResult::Deny(
3726 "creating an interpreter is environment repair, not part of the task — \
3727 the runtime re-runs the contract in the correct environment"
3728 .into(),
3729 );
3730 }
3731 for (mgr, subs) in PACKAGE_MUTATIONS {
3732 if effective != *mgr {
3733 continue;
3734 }
3735 let mutates = subs.is_empty()
3736 || effective_args.iter().any(|a| subs.contains(&a.as_str()))
3737 || (effective == "uv" && effective_args.iter().any(|a| a == "install"));
3739 if mutates {
3740 return InspectionResult::Deny(format!(
3741 "'{mgr}' package mutation is environment repair, not part of the task \
3742 — the runtime re-runs the contract in the correct environment"
3743 ));
3744 }
3745 }
3746 }
3747 InspectionResult::Allow
3748 }
3749}
3750
3751struct DenyPathEscape {
3755 worktree: PathBuf,
3756}
3757
3758impl Inspector for DenyPathEscape {
3759 fn name(&self) -> &'static str {
3760 "coder.deny_path_escape"
3761 }
3762
3763 fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3764 if !matches!(tool, "write_file" | "edit_file") {
3765 return InspectionResult::Allow;
3766 }
3767 let Some(path) = params.get("path").and_then(Value::as_str) else {
3768 return InspectionResult::Allow; };
3770 if stays_under(&self.worktree, path) {
3771 InspectionResult::Allow
3772 } else {
3773 InspectionResult::Deny(format!("write to '{path}' resolves outside the worktree"))
3774 }
3775 }
3776}
3777
3778#[cfg(test)]
3779mod tests {
3780 use super::*;
3781 use serde_json::json;
3782
3783 fn chain() -> InspectorChain {
3784 coder_inspector_chain(Path::new("/wt"))
3785 }
3786
3787 fn denied(tool: &str, params: Value) -> bool {
3788 chain().check(tool, ¶ms).is_some()
3789 }
3790
3791 fn sh(cmd: &str) -> Value {
3792 json!({ "command": cmd })
3793 }
3794
3795 fn write_policy(dir: &Path, body: &str) {
3796 std::fs::create_dir_all(dir).unwrap();
3797 std::fs::write(dir.join("rules.toml"), body).unwrap();
3798 }
3799
3800 #[test]
3801 fn coder_chain_merges_machine_and_project_deny_rules() {
3802 let root = tempfile::tempdir().unwrap();
3803 let repo = root.path().join("repo");
3804 let machine = root.path().join("machine-policies");
3805 let project = repo.join(".car").join("policies");
3806 std::fs::create_dir_all(&repo).unwrap();
3807 write_policy(&machine, "deny_tool = [\"write_file\"]\n");
3808 write_policy(&project, "deny_keyword = [\"DO NOT RUN\"]\n");
3809
3810 let policy =
3811 coder_inspector_chain_from_policy_dirs(&repo, &[machine.clone(), project.clone()])
3812 .unwrap();
3813 assert_eq!(
3816 policy.denied_tools.iter().cloned().collect::<Vec<_>>(),
3817 vec!["write_file".to_string()]
3818 );
3819 let chain = policy.chain;
3820 assert!(chain
3821 .check("write_file", &json!({"path": "x", "content": "ok"}))
3822 .is_some());
3823 assert!(chain
3824 .check("shell", &json!({"command": "echo DO NOT RUN"}))
3825 .is_some());
3826 assert!(chain.check("read_file", &json!({"path": "x"})).is_none());
3827 }
3828
3829 #[test]
3830 fn built_in_denial_reason_wins_before_project_policy() {
3831 let root = tempfile::tempdir().unwrap();
3832 let policies = root.path().join("policies");
3833 write_policy(&policies, "deny_tool = [\"shell\"]\n");
3834 let chain = coder_inspector_chain_from_policy_dirs(root.path(), &[policies])
3835 .unwrap()
3836 .chain;
3837
3838 let reason = chain
3839 .check("shell", &sh("git push origin main"))
3840 .expect("both rules deny");
3841 assert!(
3842 reason.contains("git push"),
3843 "built-in reason must win: {reason}"
3844 );
3845 assert!(
3846 !reason.contains("operator policy"),
3847 "wrong precedence: {reason}"
3848 );
3849 }
3850
3851 #[test]
3852 fn coder_chain_denies_shell_function_declarations() {
3853 for command in [
3854 ":(){ :|:& };:",
3855 "function f { printf harmless; }",
3856 "f() { printf harmless; }",
3857 "(f() { printf harmless; })",
3858 "case x in x) f() { printf harmless; };; esac",
3859 ] {
3860 let reason = chain()
3861 .check("shell", &sh(command))
3862 .unwrap_or_else(|| panic!("function declaration must be denied: {command}"));
3863 assert!(
3864 reason.contains("shell function declaration"),
3865 "unexpected denial for {command}: {reason}"
3866 );
3867 }
3868 }
3869
3870 #[test]
3871 fn coder_chain_allows_function_declaration_text_inside_quotes() {
3872 for command in [
3873 "printf '%s\\n' ':(){ :|:& };:'",
3874 "echo \"function f { printf harmless; }\"",
3875 "grep 'f() { printf harmless; }' README.md",
3876 ] {
3877 assert!(
3878 chain().check("shell", &sh(command)).is_none(),
3879 "quoted declaration text must stay inert: {command}"
3880 );
3881 }
3882 }
3883
3884 #[test]
3885 fn malformed_or_unenforced_policy_refuses_chain_construction() {
3886 let root = tempfile::tempdir().unwrap();
3887 let malformed = root.path().join("malformed");
3888 write_policy(&malformed, "deny_tool = [not valid TOML\n");
3889 assert!(coder_inspector_chain_from_policy_dirs(root.path(), &[malformed]).is_err());
3890
3891 let trace = root.path().join("trace");
3892 write_policy(
3893 &trace,
3894 "[[trace_rule]]\nkind = \"never\"\ntool = \"deploy\"\n",
3895 );
3896 let err = coder_inspector_chain_from_policy_dirs(root.path(), &[trace])
3897 .err()
3898 .expect("trace rules are deliberately unenforced");
3899 assert!(err.to_string().contains("not enforced"), "{err}");
3900 }
3901
3902 #[test]
3903 fn denies_package_mutation_and_interpreter_creation() {
3904 for cmd in [
3905 "pip install requests",
3906 "pip3 uninstall -y six",
3907 "python -m pip install --upgrade pip",
3908 "/usr/bin/python3.11 -m pip install pytest",
3909 "conda install numpy",
3910 "poetry add httpx",
3911 "uv pip install ruff",
3912 "python -m venv .venv",
3913 "virtualenv env",
3914 "easy_install foo",
3915 "cd /wt && pip install -e .",
3916 ] {
3917 assert!(denied("shell", sh(cmd)), "should be denied: {cmd}");
3918 }
3919 }
3920
3921 #[test]
3922 fn allows_read_only_package_queries_and_real_test_runs() {
3923 for cmd in [
3926 "pip list",
3927 "pip show pytest",
3928 "python -m pytest -q tests/test_x.py",
3929 "/wt/.venv/bin/python -m pytest -q tests/test_x.py",
3930 "cargo test -p car-engine",
3931 "npm test",
3932 ] {
3933 assert!(!denied("shell", sh(cmd)), "should be allowed: {cmd}");
3934 }
3935 }
3936
3937 #[test]
3938 fn denies_interpreter_shims_but_not_ordinary_test_config() {
3939 assert!(denied(
3940 "write_file",
3941 json!({ "path": "sitecustomize.py", "content": "x" })
3942 ));
3943 assert!(denied(
3944 "write_file",
3945 json!({ "path": "src/usercustomize.py", "content": "x" })
3946 ));
3947 for path in ["conftest.py", "pyproject.toml", "tox.ini", "setup.cfg"] {
3950 assert!(
3951 !denied("write_file", json!({ "path": path, "content": "x" })),
3952 "must stay allowed: {path}"
3953 );
3954 }
3955 }
3956
3957 #[test]
3958 fn git_push_and_remote_mutation_denied() {
3959 assert!(denied("shell", sh("git push origin main")));
3960 assert!(denied("shell", sh("cargo test && git push --force")));
3961 assert!(denied("shell", sh("git remote add evil https://x")));
3962 assert!(denied("shell", sh("git remote set-url origin https://x")));
3963 assert!(!denied("shell", sh("git remote -v")));
3965 assert!(!denied("shell", sh("git commit -m 'x'")));
3966 assert!(!denied("shell", sh("git status && git diff")));
3967 assert!(!denied("shell", sh("echo push")));
3969 }
3970
3971 #[test]
3974 fn forge_publication_denied_but_reads_allowed() {
3975 for cmd in [
3976 "gh pr create --fill",
3977 "gh pr merge --admin",
3978 "gh api --method DELETE /repos/o/r/branches/main/protection",
3979 "gh api -X POST /repos/o/r/issues",
3980 "gh api repos/o/r/issues -f title=x",
3981 "gh release create v9.9.9 ./x",
3982 "gh auth token",
3983 "gh repo fork",
3984 "glab mr create",
3985 "npm publish",
3986 "cargo publish",
3987 "docker push img",
3988 "docker login ghcr.io",
3989 "cargo test && gh pr create",
3990 "/opt/homebrew/bin/gh pr create --fill",
3991 "gh release create v9.9.9 --notes -h",
3998 "gh pr create --title -h --body b --head mybranch --base main",
3999 "gh auth status -t",
4003 "gh auth status --show-token",
4004 "gh api -XPOST repos/o/r/pulls --input=-",
4007 "gh api -XPOST repos/o/r/pulls --field=title=x",
4008 "gh api --method=post repos/o/r/pulls",
4009 "gh api repos/o/r/issues --raw-field=title=x",
4010 "gh api graphql --field=query=mutation{createpullrequest}",
4012 "cargo +stable publish",
4014 "docker image push img",
4015 "npm --workspace x publish",
4016 ] {
4017 assert!(denied("shell", sh(cmd)), "should be denied: {cmd}");
4018 }
4019
4020 for cmd in [
4022 "gh pr view 12",
4023 "gh pr checks",
4024 "gh pr diff 12",
4025 "gh issue list",
4026 "gh run view 5",
4027 "gh run watch 5",
4028 "gh api repos/o/r",
4029 "gh api --method GET /repos/o/r",
4030 "gh --repo o/r pr view 12",
4031 "gh auth status",
4032 "gh --version",
4033 "/opt/homebrew/bin/gh pr list",
4034 "echo gh pr create",
4036 "cargo test -p car-engine",
4038 "npm run build",
4039 "docker build -t img .",
4040 "gh api graphql -f query=query{viewer{login}}",
4044 ] {
4045 assert!(!denied("shell", sh(cmd)), "should be allowed: {cmd}");
4046 }
4047 }
4048
4049 #[test]
4054 fn governed_host_still_allows_ci_reads_and_approved_push() {
4055 let chain = governed_host_inspector_chain(Path::new("/wt"));
4056 for command in [
4057 "gh run list",
4058 "az pipelines runs list",
4059 "git push origin HEAD:main",
4060 ] {
4061 assert!(
4062 chain.check("shell", &sh(command)).is_none(),
4063 "governed host must still allow {command}"
4064 );
4065 }
4066 }
4067
4068 #[test]
4069 fn governed_host_allows_only_normal_push_shape() {
4070 let chain = governed_host_inspector_chain(Path::new("/wt"));
4071 assert!(chain
4072 .check("shell", &sh("git push origin HEAD:main"))
4073 .is_none());
4074 for command in [
4075 "git push --force origin main",
4076 "git push --force-with-lease origin main",
4077 "git push origin +HEAD:main",
4078 "git remote set-url origin https://evil",
4079 "git rebase -i HEAD~2",
4080 "git add .",
4081 "git add -A",
4082 "git commit -am fix",
4083 ] {
4084 assert!(
4085 chain.check("shell", &sh(command)).is_some(),
4086 "must deny {command}"
4087 );
4088 }
4089 }
4090
4091 #[test]
4092 fn governed_host_denies_direct_reads_outside_repository() {
4093 let temp = tempfile::tempdir().unwrap();
4094 let repo = temp.path().join("repo");
4095 let outside = temp.path().join("outside.txt");
4096 std::fs::create_dir(&repo).unwrap();
4097 std::fs::write(&outside, "secret").unwrap();
4098 let chain = governed_host_inspector_chain(&repo);
4099
4100 assert!(chain
4101 .check("read_file", &json!({"path": outside}))
4102 .is_some());
4103 assert!(chain
4104 .check("shell", &sh(&format!("cat {}", outside.display())))
4105 .is_some());
4106 assert!(chain.check("shell", &sh("cd ..")).is_some());
4107 assert!(chain.check("shell", &sh(r"cat \../outside.txt")).is_some());
4108 for path in [
4109 "~/notes.txt",
4110 "$HOME/notes.txt",
4111 "${HOME}/notes.txt",
4112 "${HOME:-/tmp}/notes.txt",
4113 "${HOME:=/tmp}/notes.txt",
4114 "$TMPDIR/notes.txt",
4115 "${TMPDIR}/notes.txt",
4116 "${TMPDIR:-/tmp}/notes.txt",
4117 ] {
4118 assert!(
4119 chain.check("read_file", &json!({"path": path})).is_some(),
4120 "expanded shell root must be denied by read_file: {path}"
4121 );
4122 }
4123 for command in [
4124 "cat ~/notes.txt",
4125 "cat ~someone/notes.txt",
4126 "cat $HOME/notes.txt",
4127 "cat ${HOME}/notes.txt",
4128 "cat ${HOME:-/tmp}/notes.txt",
4129 "cat ${HOME:=/tmp}/notes.txt",
4130 "cat $TMPDIR/notes.txt",
4131 "cat ${TMPDIR}/notes.txt",
4132 "cat ${TMPDIR:=/tmp}/notes.txt",
4133 "sed -f ~/evil.sed file",
4134 ] {
4135 assert!(
4136 chain.check("shell", &sh(command)).is_some(),
4137 "home-relative path must be denied: {command}"
4138 );
4139 }
4140 assert!(chain.check("shell", &sh("cat src/lib.rs")).is_none());
4141
4142 #[cfg(unix)]
4143 {
4144 std::os::unix::fs::symlink(&outside, repo.join("escape")).unwrap();
4145 assert!(chain
4146 .check("read_file", &json!({"path": "escape"}))
4147 .is_some());
4148 assert!(chain.check("shell", &sh("cat escape")).is_some());
4149 }
4150 }
4151
4152 #[test]
4153 fn governed_host_denies_uninspectable_variable_operands() {
4154 let temp = tempfile::tempdir().unwrap();
4155 let repo = temp.path().join("repo");
4156 let cargo_target = temp.path().join("cargo-target");
4157 std::fs::create_dir_all(repo.join("src")).unwrap();
4158 std::fs::create_dir_all(cargo_target.join("debug")).unwrap();
4159 std::fs::write(repo.join("src/x"), "x\n").unwrap();
4160 std::fs::write(repo.join("src/lib.rs"), "pub fn control() {}\n").unwrap();
4161 std::fs::write(repo.join("$HOME_fixture"), "literal\n").unwrap();
4162 let chain = governed_host_inspector_chain(&repo);
4163
4164 for command in [
4165 "cat $NOPE/etc/hosts",
4166 "cat ${NOPE}/x",
4167 "cat ${NOPE:-${PWD}}/x",
4168 "cat $NOPE",
4169 "cat $MISSING_fixture",
4170 "cat $HOME_fixture/inside",
4171 "cat ${HOME_fixture}/inside",
4172 "cat ${PWD%repo}/x",
4173 "cat $1/etc/hosts",
4174 "cat ${1}",
4175 "cat $@",
4176 "cat $*",
4177 "cat $?",
4178 "cat $-",
4179 "cat $$",
4180 "cat $!",
4181 "cat src/$x",
4182 "cat src/${x}/file",
4183 "cat src/deeper/$x",
4184 "cat src/file$x",
4185 "cat src/$1",
4186 "cat $PWD/src/$x",
4187 ] {
4188 let reason = chain
4189 .check("shell", &sh(command))
4190 .unwrap_or_else(|| panic!("variable operand must be denied: {command}"));
4191 assert!(
4192 reason.contains("variable operand cannot be inspected"),
4193 "unexpected variable denial for {command}: {reason}"
4194 );
4195 }
4196
4197 for command in [
4198 "cat $PWD/src/x",
4199 "cat ${PWD}/src/x",
4200 "cat src/$PWD",
4201 "cat src/${PWD}/x",
4202 "cat src/lib.rs",
4203 "cat $HOME_fixture",
4204 "env RUST_LOG=debug cargo test",
4205 ] {
4206 assert!(
4207 chain.check("shell", &sh(command)).is_none(),
4208 "resolvable or repo-prefixed control must stay allowed: {command}"
4209 );
4210 }
4211
4212 let path_gate = DenyGovernedShellPathEscape {
4215 worktree: repo.clone(),
4216 oldpwd_is_worktree: true,
4217 cargo_target_dir: Some(cargo_target.clone()),
4218 };
4219 for command in [
4220 "cat $OLDPWD/src/x",
4221 "cat $CARGO_TARGET_DIR/debug/output",
4222 "cat src/$OLDPWD/x",
4223 "cat src/$CARGO_TARGET_DIR/output",
4224 ] {
4225 assert!(
4226 matches!(
4227 path_gate.inspect("shell", &sh(command)),
4228 InspectionResult::Allow
4229 ),
4230 "pinned variable root must stay allowed: {command}"
4231 );
4232 }
4233 assert!(matches!(
4234 path_gate.inspect("shell", &sh("cat $CARGO_TARGET_DIR/../outside")),
4235 InspectionResult::Deny(reason)
4236 if reason.contains("resolves outside its governed root")
4237 ));
4238 }
4239
4240 #[test]
4241 fn governed_host_gates_every_shell_command_segment() {
4242 let temp = tempfile::tempdir().unwrap();
4243 let repo = temp.path().join("repo");
4244 let outside = temp.path().join("outside.txt");
4245 std::fs::create_dir(&repo).unwrap();
4246 std::fs::write(&outside, "secret").unwrap();
4247 let chain = governed_host_inspector_chain(&repo);
4248
4249 for separator in ["\n", "\r\n", ";", "&&", "||"] {
4250 let command = format!("printf harmless{separator}cat {}", outside.display());
4251 assert!(
4252 chain.check("shell", &sh(&command)).is_some(),
4253 "the command after separator {separator:?} must be path-gated"
4254 );
4255 }
4256 }
4257
4258 #[test]
4259 fn governed_host_inspects_case_arm_body_verbs() {
4260 let chain = governed_host_inspector_chain(Path::new("/wt"));
4261 for (command, bare) in [
4262 ("case x in x) sudo ls;; esac", "sudo ls"),
4263 ("case x in x) cat /etc/passwd;; esac", "cat /etc/passwd"),
4264 ("case x in x) PWD=/etc;; esac", "PWD=/etc"),
4265 ("case \"\" in x) sudo ls;; esac", "sudo ls"),
4266 ("case \"$(printf in)\" in x) sudo ls;; esac", "sudo ls"),
4267 ("case $(printf in) in x) sudo ls;; esac", "sudo ls"),
4268 ] {
4269 let expected = chain
4270 .check("shell", &sh(bare))
4271 .unwrap_or_else(|| panic!("bare command must deny: {bare}"));
4272 let actual = chain
4273 .check("shell", &sh(command))
4274 .unwrap_or_else(|| panic!("case arm body must deny: {command}"));
4275 assert_eq!(actual, expected, "case arm must preserve the bare reason");
4276 }
4277 }
4278
4279 #[test]
4280 fn governed_host_inspects_nested_case_arm_body_verbs() {
4281 let chain = governed_host_inspector_chain(Path::new("/wt"));
4282 let bare = "sudo ls";
4283 let expected = chain
4284 .check("shell", &sh(bare))
4285 .expect("bare sudo must deny");
4286 for command in [
4287 "case x in x) case y in y) sudo ls;; esac;; esac",
4288 "{ case x in x) sudo ls;; esac; }",
4289 "if true; then case x in x) sudo ls;; esac; fi",
4290 "(case x in x) sudo ls;; esac)",
4291 ] {
4292 let actual = chain
4293 .check("shell", &sh(command))
4294 .unwrap_or_else(|| panic!("nested or wrapped case arm body must deny: {command}"));
4295 assert_eq!(
4296 actual, expected,
4297 "nested or wrapped case must preserve the bare reason"
4298 );
4299 }
4300 }
4301
4302 #[test]
4303 fn governed_host_inspects_multiple_case_arms_and_patterns() {
4304 let chain = governed_host_inspector_chain(Path::new("/wt"));
4305 let expected = chain
4306 .check("shell", &sh("sudo ls"))
4307 .expect("bare sudo must deny");
4308 for command in [
4309 "case x in a) ls src;; b) sudo ls;; esac",
4310 "case x in a|b) sudo ls;; esac",
4311 "case x in a) ls src;& b) sudo ls;; esac",
4312 "case x in a) ls src;;& b) sudo ls;; esac",
4313 ] {
4314 let actual = chain
4315 .check("shell", &sh(command))
4316 .unwrap_or_else(|| panic!("later case arm body must deny: {command}"));
4317 assert_eq!(actual, expected, "case arm must preserve the bare reason");
4318 }
4319
4320 for command in [
4321 "echo \"x)\"",
4322 "echo ')'",
4323 "echo case x in x) sudo ls",
4324 "case x in \"x)\") ls src;; esac",
4325 "case x in x\\)) ls src;; esac",
4326 ] {
4327 assert!(
4328 chain.check("shell", &sh(command)).is_none(),
4329 "quoted closing parenthesis must stay inert: {command}"
4330 );
4331 }
4332 }
4333
4334 #[test]
4335 fn governed_host_joins_continued_lines_before_path_gating() {
4336 let temp = tempfile::tempdir().unwrap();
4337 let repo = temp.path().join("repo");
4338 let outside = temp.path().join("outside.txt");
4339 std::fs::create_dir_all(repo.join("src")).unwrap();
4340 std::fs::write(repo.join("src/lib.rs"), "pub fn example() {}\n").unwrap();
4341 std::fs::write(&outside, "secret").unwrap();
4342 let chain = governed_host_inspector_chain(&repo);
4343
4344 for verb in ["cat", "rm"] {
4345 let command = format!("{verb} \\\n{}", outside.display());
4346 assert!(
4347 chain.check("shell", &sh(&command)).is_some(),
4348 "a continued {verb} path must remain governed: {command:?}"
4349 );
4350 }
4351 assert!(
4352 chain
4353 .check("shell", &sh("sed -n '/pub fn/p' \\\n src/lib.rs"))
4354 .is_none(),
4355 "a continued repository-local sed operand must remain allowed"
4356 );
4357 }
4358
4359 #[test]
4360 fn governed_host_gates_paths_embedded_in_sed_and_awk_programs() {
4361 let temp = tempfile::tempdir().unwrap();
4362 let repo = temp.path().join("repo");
4363 let outside = temp.path().join("outside.txt");
4364 std::fs::create_dir(&repo).unwrap();
4365 std::fs::write(repo.join("file"), "x\n").unwrap();
4366 std::fs::write(repo.join("script.sed"), "p\n").unwrap();
4367 std::fs::write(&outside, "secret").unwrap();
4368 let chain = governed_host_inspector_chain(&repo);
4369 let outside = outside.display();
4370
4371 for command in [
4372 format!("sed 'r {outside}' file"),
4373 format!("sed -n 'R {outside}' file"),
4374 format!("sed -e '1w {outside}' file"),
4375 format!("sed '/x/W {outside}' file"),
4376 format!("awk '{{ getline < \"{outside}\" }}' file"),
4377 format!("awk '{{ getline line < \"{outside}\" }}' file"),
4378 format!("awk '{{ print $0 > \"{outside}\" }}' file"),
4379 format!("awk '{{ print $0 > (\"{outside}\") }}' file"),
4380 format!("awk '{{ printf \"%s\", $0 >> \"{outside}\" }}' file"),
4381 format!("sed -n 's/x/y/w {outside}' file"),
4384 format!("sed -n '/x/s//y/gw {outside}' file"),
4385 format!("sed -n 's/x/;/w {outside}' file"),
4386 format!("sed -n 's;x;y;w {outside}' file"),
4387 format!("sed -n 's/x/{{/w {outside}' file"),
4388 format!("sed -n 'r{outside}' file"),
4390 format!("sed -n 'R{outside}' file"),
4391 format!("sed -n 'w{outside}' file"),
4392 format!("sed -n 'W{outside}' file"),
4393 format!("sed -nf {outside} file"),
4395 format!("sed -ne 'r {outside}' file"),
4396 format!("sed -i.bak 's/x/y/' {outside}"),
4397 format!("awk 'BEGIN {{ system(\"cat {outside}\") }}'"),
4400 format!("awk 'BEGIN {{ \"cat {outside}\" | getline line }}'"),
4401 format!("awk 'BEGIN {{ \"cat {outside}\" |& getline line }}'"),
4402 format!("awk '{{ print $0 | \"cat > {outside}\" }}' file"),
4403 format!("awk '{{ printf \"%s\", $0 | \"cat > {outside}\" }}' file"),
4404 format!("sed -n '1e cat {outside}' file"),
4405 "sed -n 's/x/y/e' file".to_string(),
4406 ] {
4407 assert!(
4408 chain.check("shell", &sh(&command)).is_some(),
4409 "dangerous embedded operand or command execution must be denied: {command}"
4410 );
4411 }
4412
4413 for separator in ["\n", "\r\n"] {
4414 let command = format!("printf harmless{separator}sed -n 's/x/y/w {outside}' file");
4415 assert!(
4416 chain.check("shell", &sh(&command)).is_some(),
4417 "a sed write after {separator:?} must remain governed"
4418 );
4419 }
4420 for continuation in ["\\\n", "\\\r\n"] {
4421 let command = format!("sed -n 's/x/y/w {continuation}{outside}' file");
4422 assert!(
4423 chain.check("shell", &sh(&command)).is_some(),
4424 "a continued sed write must remain governed: {command:?}"
4425 );
4426 }
4427
4428 for command in [
4429 "sed 'r file' file",
4430 "sed -e '1w generated.txt' file",
4431 "awk '{ getline < \"file\" }' file",
4432 "awk '{ print $0 > \"generated.txt\" }' file",
4433 "sed -nf script.sed file",
4434 "sed -ne '/x/p' file",
4435 "sed -i.bak 's/x/y/' file",
4436 "sed 'y/x/;/' file",
4437 "sed ':example; /x/p' file",
4438 ] {
4439 assert!(
4440 chain.check("shell", &sh(command)).is_none(),
4441 "repository-relative embedded file operand must remain allowed: {command}"
4442 );
4443 }
4444 }
4445
4446 #[test]
4447 fn governed_host_denies_shell_fed_sed_and_awk_programs() {
4448 let temp = tempfile::tempdir().unwrap();
4449 let repo = temp.path().join("repo");
4450 let outside = temp.path().join("outside.txt");
4451 std::fs::create_dir(&repo).unwrap();
4452 std::fs::write(repo.join("file"), "x\n").unwrap();
4453 std::fs::write(&outside, "secret").unwrap();
4454 let chain = governed_host_inspector_chain(&repo);
4455 let outside = outside.display();
4456
4457 for command in [
4458 format!("sed -f - file <<'EOF'\nw {outside}\nEOF"),
4460 format!("awk -f /dev/stdin file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
4461 format!("sed -f - file <<< 'w {outside}'"),
4463 format!("printf 'w {outside}\\n' | sed -f - file"),
4464 format!("sed -f <(printf 'w {outside}\\n') file"),
4467 format!("awk -f /dev/fd/0 file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
4468 "awk -f /proc/self/fd/0 file".to_string(),
4469 format!("sed -e '' -f - file <<'EOF'\nw {outside}\nEOF"),
4472 format!("awk -e '' -f - file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
4473 "sed -f '' file".to_string(),
4476 "sed -f\"\" file".to_string(),
4477 "sed -f $(printf -) file".to_string(),
4478 "awk -f `printf -` file".to_string(),
4479 "sed -f >(printf p) file".to_string(),
4480 r"sed -f \- file".to_string(),
4482 r"awk -f \- file".to_string(),
4483 "busybox awk -f - file".to_string(),
4484 r"sed --file=\- file".to_string(),
4485 "sed -f {-,} file".to_string(),
4488 "x=-; sed -f $x file".to_string(),
4489 "x=-; sed -f \"$x\" file".to_string(),
4490 ] {
4491 let reason = chain
4492 .check("shell", &sh(&command))
4493 .unwrap_or_else(|| panic!("shell-fed program must be denied: {command}"));
4494 assert!(
4495 reason.contains("script source") && reason.contains("cannot be inspected"),
4496 "program channels must not be misclassified as governed paths: {reason}"
4497 );
4498 }
4499 }
4500
4501 #[test]
4502 fn governed_host_denies_nested_shell_command_carriers() {
4503 let chain = governed_host_inspector_chain(Path::new("/wt"));
4504 for command in [
4505 "eval 'sed -f - file'",
4506 "bash -c 'sed -f - file'",
4507 "sh -c 'cat /etc/passwd'",
4508 "zsh -c 'awk -f - file'",
4509 "dash -c 'cat /etc/passwd'",
4510 "ksh -c 'cat /etc/passwd'",
4511 "fish -c 'cat /etc/passwd'",
4512 "busybox sh -c 'cat /etc/passwd'",
4513 "busybox ash -c 'cat /etc/passwd'",
4514 "perl -e 'open F, q(/etc/passwd)'",
4515 "perl -E 'say qx(cat /etc/passwd)'",
4516 "perl -wE 'say qx(cat /etc/passwd)'",
4517 "python3 -c 'open(\"/etc/passwd\").read()'",
4518 "python3.14 -c 'open(\"/etc/passwd\").read()'",
4519 "ruby -e 'puts File.read(\"/etc/passwd\")'",
4520 "node -e 'require(\"fs\").readFileSync(\"/etc/passwd\")'",
4521 "osascript -e 'do shell script \"cat /etc/passwd\"'",
4522 "script -c 'cat /etc/passwd'",
4523 "bash -lc 'cat /etc/passwd'",
4524 "MODE=check /bin/bash --noprofile -c 'cat /etc/passwd'",
4525 "env -S 'cat /etc/passwd'",
4526 "bash",
4527 "nice bash",
4528 "nohup bash",
4529 ] {
4530 let reason = chain
4531 .check("shell", &sh(command))
4532 .unwrap_or_else(|| panic!("nested shell carrier must be denied: {command}"));
4533 assert!(
4534 reason.contains("nested shell command"),
4535 "unexpected nested-shell denial reason: {reason}"
4536 );
4537 }
4538 assert!(chain.check("shell", &sh("echo eval")).is_none());
4539 assert!(chain.check("shell", &sh("bash script.sh")).is_none());
4540 assert!(chain
4541 .check("shell", &sh("nice bash scripts/check.sh"))
4542 .is_none());
4543 assert!(chain.check("shell", &sh("python3 script.py")).is_none());
4544 assert!(chain.check("shell", &sh("python3 -E script.py")).is_none());
4545 assert!(chain
4546 .check("shell", &sh("ruby -E UTF-8 script.rb"))
4547 .is_none());
4548 assert!(chain.check("shell", &sh("node script.js")).is_none());
4549 assert!(chain
4550 .check("shell", &sh("perl -MExtUtils::MakeMaker scripts/build.pl"))
4551 .is_none());
4552 assert!(chain.check("shell", &sh("command -v sudo")).is_none());
4553 }
4554
4555 #[test]
4556 fn compound_command_keywords_do_not_take_the_verb_slot() {
4557 for keyword in [
4558 "{", "}", "if", "then", "elif", "else", "fi", "for", "do", "done", "while", "until",
4559 "case", "esac", "function", "time", "!",
4560 ] {
4561 assert!(
4562 compound_command_keyword(keyword),
4563 "shell grammar word must not be classified as a verb: {keyword}"
4564 );
4565 }
4566 for control in ["echo", "a{b}", "{2,}", "important", "timer"] {
4567 assert!(
4568 !compound_command_keyword(control),
4569 "ordinary word must remain a possible verb: {control}"
4570 );
4571 }
4572 }
4573
4574 #[test]
4575 fn governed_host_denies_assignments_inside_compound_commands() {
4576 let chain = governed_host_inspector_chain(Path::new("/wt"));
4577 for (command, bare_assignment) in [
4578 ("{ PWD=/etc; }", "PWD=/etc"),
4579 ("if HOME=/tmp; then :; fi", "HOME=/tmp"),
4580 ("if false; then :; elif PATH=/tmp; then :; fi", "PATH=/tmp"),
4581 ("if false; then :; else IFS=:; fi", "IFS=:"),
4582 ("for i in 1; do PWD=/etc; done", "PWD=/etc"),
4583 ("while HOME=/tmp; do break; done", "HOME=/tmp"),
4584 ("until HOME=/tmp; false; do break; done", "HOME=/tmp"),
4585 ("case x in x) IFS=:;; esac", "IFS=:"),
4586 ("time PATH=/tmp true", "PATH=/tmp"),
4591 ("! HOME=/tmp", "HOME=/tmp"),
4592 ] {
4593 let expected = chain
4594 .check("shell", &sh(bare_assignment))
4595 .unwrap_or_else(|| {
4596 panic!("bare protected assignment must deny: {bare_assignment}")
4597 });
4598 let actual = chain
4599 .check("shell", &sh(command))
4600 .unwrap_or_else(|| panic!("compound assignment must deny: {command}"));
4601 assert_eq!(
4602 actual, expected,
4603 "compound syntax must preserve the existing assignment reason: {command}"
4604 );
4605 }
4606 }
4607
4608 #[test]
4609 fn governed_host_denies_compound_assignments_after_shell_separators() {
4610 let chain = governed_host_inspector_chain(Path::new("/wt"));
4611 for command in [
4612 "{ true; PWD=/etc; }",
4613 "{ true && PATH=/tmp; }",
4614 "{ false || HOME=/tmp; }",
4615 "{ printf x | IFS=:; }",
4616 "{ true\nPWD=/etc\n}",
4617 ] {
4618 let reason = chain
4619 .check("shell", &sh(command))
4620 .unwrap_or_else(|| panic!("separated compound assignment must deny: {command:?}"));
4621 assert!(
4622 reason.contains("environment assignment"),
4623 "unexpected assignment reason for {command:?}: {reason}"
4624 );
4625 }
4626 }
4627
4628 #[test]
4629 fn governed_host_compound_syntax_controls_stay_allowed() {
4630 let chain = governed_host_inspector_chain(Path::new("/wt"));
4631 for command in [
4632 "echo '{ PWD=/etc; }'",
4633 "echo '{'",
4634 "cargo test 'a{b}'",
4635 "grep -E '{2,}' src/lib.rs",
4636 ] {
4637 assert!(
4638 chain.check("shell", &sh(command)).is_none(),
4639 "quoted or embedded brace must not become shell grammar: {command}"
4640 );
4641 }
4642 }
4643
4644 #[test]
4645 fn governed_host_compound_paths_preserve_later_prefix_checks() {
4646 let chain = governed_host_inspector_chain(Path::new("/wt"));
4647 for command in [
4648 "source=repo-local; if true; then cat $source/file; fi",
4649 "source=repo-local; { cat $source/file; }",
4650 ] {
4651 let reason = chain
4652 .check("shell", &sh(command))
4653 .unwrap_or_else(|| panic!("compound path-prefix assignment must deny: {command}"));
4654 assert_eq!(
4655 reason,
4656 "environment assignment 'source' may change a governed path operand"
4657 );
4658 }
4659 }
4660
4661 fn compound_wrappings(bare: &str) -> Vec<String> {
4671 vec![
4672 format!("{{ {bare}; }}"),
4673 format!("if true; then {bare}; fi"),
4674 format!("if false; then :; else {bare}; fi"),
4675 format!("for i in 1; do {bare}; done"),
4676 format!("while true; do {bare}; done"),
4677 format!("until false; do {bare}; done"),
4678 format!("! {bare}"),
4679 ]
4680 }
4681
4682 fn function_declaration_wrappings(bare: &str) -> Vec<String> {
4686 vec![
4687 format!("function wrapped {{ {bare}; }}; wrapped"),
4688 format!("wrapped() {{ {bare}; }}; wrapped"),
4691 format!("wrapped () {{ {bare}; }}; wrapped"),
4692 format!("wrapped(){{ {bare}; }}; wrapped"),
4693 ]
4694 }
4695
4696 #[test]
4697 fn governed_host_function_declarations_deny_categorically() {
4698 let chain = governed_host_inspector_chain(Path::new("/wt"));
4699 for bare in ["ls src", "PWD=/etc", "sudo ls", "cat /etc/passwd"] {
4707 for wrapped in function_declaration_wrappings(bare) {
4708 let reason = chain
4709 .check("shell", &sh(&wrapped))
4710 .unwrap_or_else(|| panic!("declaration must deny: {wrapped}"));
4711 assert_eq!(
4712 reason,
4713 "shell function declarations require explicit human approval and cannot be run by a coding model",
4714 "declaration must be refused categorically: {wrapped}"
4715 );
4716 }
4717 }
4718 }
4719
4720 #[test]
4721 fn shell_verb_index_skips_the_declared_function_name() {
4722 let tokens =
4723 |line: &str| -> Vec<String> { line.split_whitespace().map(str::to_string).collect() };
4724 let declaration = tokens("function wrapped { sudo ls");
4725 assert_eq!(
4726 verb(&declaration),
4727 Some("sudo"),
4728 "the declared name must not hide the body's verb"
4729 );
4730 let ordinary = tokens("grep function src/lib.rs");
4732 assert_eq!(verb(&ordinary), Some("grep"));
4733 for head in [
4735 "f() { sudo ls",
4736 "f(){ sudo ls",
4737 "f () { sudo ls",
4738 "f ( ) { sudo ls",
4739 ] {
4740 assert_eq!(
4741 verb(&tokens(head)),
4742 Some("sudo"),
4743 "POSIX declaration head must not hide the body's verb: {head}"
4744 );
4745 }
4746 for ordinary in [
4749 "grep () file",
4750 "grep ()",
4751 "echo f()",
4752 "grep main() src",
4753 "cargo test foo()",
4754 "python -c f()",
4755 ] {
4756 let parsed = tokens(ordinary);
4757 assert_eq!(
4758 verb(&parsed),
4759 ordinary.split_whitespace().next(),
4760 "a parenthesised argument is not a declaration head: {ordinary}"
4761 );
4762 }
4763 assert!(
4767 posix_function_head(&tokens("( sudo ls )")).is_none(),
4768 "a bare subshell paren is not a function declaration head"
4769 );
4770 let timed = tokens("time { cargo build");
4771 assert_eq!(verb(&timed), Some("time"));
4772 }
4773
4774 #[test]
4775 fn governed_host_inspects_verbs_inside_compound_commands() {
4776 let chain = governed_host_inspector_chain(Path::new("/wt"));
4777 for bare in [
4779 "cat /etc/passwd",
4781 "cat ../outside/file",
4783 "head -1 /etc/passwd",
4785 "sudo ls",
4787 "git push --force origin main",
4789 "git remote set-url origin http://evil",
4790 "git add -A",
4792 "git rebase -i HEAD~3",
4794 "rm -rf /Users/other",
4796 "cat $f",
4798 "wc -l \"$f\"",
4799 ] {
4800 let expected = chain
4801 .check("shell", &sh(bare))
4802 .unwrap_or_else(|| panic!("bare command must deny: {bare}"));
4803 for wrapped in compound_wrappings(bare) {
4804 let actual = chain
4805 .check("shell", &sh(&wrapped))
4806 .unwrap_or_else(|| panic!("compound command must deny: {wrapped}"));
4807 assert_eq!(
4808 actual, expected,
4809 "compound syntax must preserve the bare verdict for {bare:?}: {wrapped:?}"
4810 );
4811 }
4812 }
4813 }
4814
4815 #[test]
4816 fn governed_host_inspects_loop_body_variable_operands() {
4817 let chain = governed_host_inspector_chain(Path::new("/wt"));
4818 for (loop_form, bare) in [
4822 ("for f in src/*.rs; do cat $f; done", "cat $f"),
4823 ("while read -r f; do cat $f; done", "cat $f"),
4824 ("for f in src/*.rs; do wc -l \"$f\"; done", "wc -l \"$f\""),
4825 ] {
4826 let expected = chain
4827 .check("shell", &sh(bare))
4828 .unwrap_or_else(|| panic!("bare command must deny: {bare}"));
4829 let actual = chain
4830 .check("shell", &sh(loop_form))
4831 .unwrap_or_else(|| panic!("loop body must deny: {loop_form}"));
4832 assert_eq!(
4833 actual, expected,
4834 "loop body must preserve the bare verdict: {loop_form}"
4835 );
4836 assert!(
4837 expected.contains("variable operand cannot be inspected"),
4838 "unexpected reason for {bare}: {expected}"
4839 );
4840 }
4841 }
4842
4843 #[test]
4844 fn governed_host_compound_verb_controls_stay_allowed() {
4845 let chain = governed_host_inspector_chain(Path::new("/wt"));
4846 for bare in ["ls src", "wc -l src/lib.rs", "cargo test"] {
4847 assert!(
4848 chain.check("shell", &sh(bare)).is_none(),
4849 "control must stay allowed bare: {bare}"
4850 );
4851 for wrapped in compound_wrappings(bare) {
4852 assert!(
4853 chain.check("shell", &sh(&wrapped)).is_none(),
4854 "compound syntax must not invent a denial: {wrapped}"
4855 );
4856 }
4857 }
4858 for command in [
4859 "{ cargo build; cargo test; }",
4860 "time { cargo build; }",
4861 "find . -name x -exec echo {} \\;",
4862 "xargs -I{} cat {}",
4863 "echo a{b,c}",
4864 "echo \"f()\"",
4867 "grep 'main()' src",
4868 "cargo test 'foo()'",
4869 "grep '()' src/lib.rs",
4870 ] {
4871 assert!(
4872 chain.check("shell", &sh(command)).is_none(),
4873 "brace-bearing control must stay allowed: {command}"
4874 );
4875 }
4876 let reason = chain
4879 .check("shell", &sh("python -c \"f()\""))
4880 .expect("an inline python program is a nested shell carrier");
4881 assert!(
4882 reason.contains("nested shell command through 'python'"),
4883 "parenthesised argument must not cost python its verb: {reason}"
4884 );
4885 }
4886
4887 #[test]
4888 fn governed_host_inspects_command_environment_assignments() {
4889 let temp = tempfile::tempdir().unwrap();
4890 let repo = temp.path().join("repo");
4891 let outside = temp.path().join("outside");
4892 std::fs::create_dir_all(repo.join("target")).unwrap();
4893 std::fs::create_dir_all(&outside).unwrap();
4894 let chain = governed_host_inspector_chain(&repo);
4895
4896 for name in [
4897 "BASH_ENV",
4898 "ENV",
4899 "PATH",
4900 "LD_PRELOAD",
4901 "DYLD_INSERT_LIBRARIES",
4902 "PYTHONPATH",
4903 "PERL5LIB",
4904 "RUBYLIB",
4905 "NODE_OPTIONS",
4906 "CARGO_HOME",
4907 "RUSTUP_HOME",
4908 "GIT_EXEC_PATH",
4909 "GIT_SSH_COMMAND",
4910 ] {
4911 let command = format!("env {name}=repo-local cargo test");
4912 let reason = chain
4913 .check("shell", &sh(&command))
4914 .unwrap_or_else(|| panic!("redirecting assignment must be denied: {command}"));
4915 assert!(reason.contains("redirect executable code"), "{reason}");
4916 }
4917
4918 for command in [
4919 "BASH_ENV=repo-local bash script.sh".to_string(),
4920 format!("env CACHE_DIR={} cargo test", outside.display()),
4921 "env EDITOR=repo-editor git commit".to_string(),
4922 "VISUAL=repo-editor git rebase --interactive main".to_string(),
4923 "env CARGO_TARGET_DIR=target cargo test".to_string(),
4924 ] {
4925 assert!(
4926 chain.check("shell", &sh(&command)).is_some(),
4927 "unsafe assignment must be denied: {command}"
4928 );
4929 }
4930
4931 for command in [
4932 "env RUST_LOG=debug cargo test".to_string(),
4933 "RUST_LOG=debug".to_string(),
4934 "RUST_LOG[0]=debug; cargo test".to_string(),
4935 "RUST_LOG[$index]=debug; cargo test".to_string(),
4936 "RUST_LOG[$((1+1))]=debug; cargo test".to_string(),
4937 "RUST_LOG[ 0 ]=debug; cargo test".to_string(),
4940 "RUST_LOG[0 ]=debug; cargo test".to_string(),
4941 "RUST_LOG[ 0]=debug; cargo test".to_string(),
4942 "RUST_LOG[\t0]=debug; cargo test".to_string(),
4943 "RUST_LOG[ 0 ]+=,debug; cargo test".to_string(),
4944 "cargo test 'a[0]'".to_string(),
4945 "CACHE_KIND=local".to_string(),
4946 "env EDITOR=vim cargo test".to_string(),
4947 ] {
4948 assert!(
4949 chain.check("shell", &sh(&command)).is_none(),
4950 "plain assignment must stay allowed: {command}"
4951 );
4952 }
4953
4954 for command in [
4955 "PWD=repo-local",
4956 "OLDPWD=repo-local",
4957 "CARGO_TARGET_DIR=target",
4958 "HOME=repo-local",
4959 "TMPDIR=tmp",
4960 "IFS=: ",
4961 "PATH=bin",
4962 "PWD=/etc; cat $PWD/passwd",
4963 "PWD[0]=/etc; cat $PWD/passwd",
4964 "PWD[0]+=/x",
4965 "PWD[ 0 ]=/etc; cat $PWD/passwd",
4969 "PWD[0 ]=/etc; cat $PWD/passwd",
4970 "PWD[ 0]=/etc; cat $PWD/passwd",
4971 "PWD[\t0]=/etc; cat $PWD/passwd",
4972 "PWD[\t0]+=/x",
4973 "env PWD[0]=/etc cat $PWD/passwd",
4974 "source=../../outside; cat $source/file",
4975 "source[0]=repo-local; cat $source/file",
4976 "env input=../../outside cat $input/file",
4977 ] {
4978 let reason = chain
4979 .check("shell", &sh(command))
4980 .unwrap_or_else(|| panic!("path-changing assignment must be denied: {command}"));
4981 assert!(
4982 reason.contains("environment assignment"),
4983 "unexpected assignment denial for {command}: {reason}"
4984 );
4985 }
4986 }
4987
4988 #[test]
4989 fn governed_host_inspects_assignments_inside_subshells() {
4990 let chain = governed_host_inspector_chain(Path::new("/wt"));
4991
4992 for command in [
4993 "(PWD=/etc; cat $PWD/passwd)",
4994 "( PWD=/etc; cat $PWD/passwd )",
4995 "(printf harmless; (PWD=/etc; cat $PWD/passwd))",
4996 "printf harmless $(PWD=/etc; cat $PWD/passwd)",
4997 ] {
4998 let reason = chain
4999 .check("shell", &sh(command))
5000 .unwrap_or_else(|| panic!("subshell assignment must be denied: {command}"));
5001 assert_eq!(
5002 reason, "environment assignment 'PWD' may change a governed path operand",
5003 "unexpected subshell-assignment denial for {command}"
5004 );
5005 }
5006
5007 let reason = chain
5008 .check("shell", &sh("(cat /etc/passwd)"))
5009 .expect("a governed verb inside a subshell must still be inspected");
5010 assert!(
5011 reason.contains("outside the governed repository"),
5012 "unexpected subshell-path denial: {reason}"
5013 );
5014
5015 for command in [
5016 "echo \"(a=b)\"",
5017 "grep -E \"(x|y)\" file",
5018 "find . ( -name a -o -name b )",
5019 "((count += 1))",
5020 "((total = (count + 1) * 2))",
5021 "echo $((1 + (2 * 3)))",
5022 ] {
5023 assert!(
5024 chain.check("shell", &sh(command)).is_none(),
5025 "quoted parentheses, find expressions, and arithmetic must stay allowed: {command}"
5026 );
5027 }
5028 }
5029
5030 #[test]
5031 fn governed_host_inspects_assignment_builtins() {
5032 let chain = governed_host_inspector_chain(Path::new("/wt"));
5033
5034 for command in [
5035 "export PWD=/etc; cat $PWD/passwd",
5036 "readonly PWD=/etc; cat $PWD/passwd",
5037 "declare PWD=/etc; cat $PWD/passwd",
5038 "typeset PWD=/etc; cat $PWD/passwd",
5039 "local PWD=/etc; cat $PWD/passwd",
5040 "export PWD[1]=/etc; cat $PWD/passwd",
5041 "export PWD[ 1 ]=/etc; cat $PWD/passwd",
5042 "declare -a PWD[\t0]=/etc; cat $PWD/passwd",
5043 "readonly PWD[1]=/etc; cat $PWD/passwd",
5044 "declare PATH[0]=/tmp; cargo test",
5045 "declare -a arr[0]=/etc",
5046 "typeset HOME[0]=/tmp; cargo test",
5047 "local PWD[1]=/etc; cat $PWD/passwd",
5048 "unset PWD",
5049 "export -n PWD",
5050 ] {
5051 let reason = chain
5052 .check("shell", &sh(command))
5053 .unwrap_or_else(|| panic!("assignment builtin must be denied: {command}"));
5054 assert!(
5055 reason.contains("environment assignment"),
5056 "unexpected assignment-builtin denial for {command}: {reason}"
5057 );
5058 }
5059
5060 for builtin in ["export", "readonly", "declare", "typeset", "local"] {
5061 for assignment in ["source=repo-local", "source[0]=repo-local"] {
5062 let command = format!("{builtin} {assignment}; cat $source/file");
5063 let reason = chain.check("shell", &sh(&command)).unwrap_or_else(|| {
5064 panic!("later operand-prefix assignment must be denied: {command}")
5065 });
5066 assert!(reason.contains("environment assignment"), "{reason}");
5067 }
5068 }
5069
5070 for command in [
5071 "PWD+=/../../../etc; cat $PWD/passwd",
5072 "env PWD+=/../../../etc cat $PWD/passwd",
5073 "export PWD+=/../../../etc; cat $PWD/passwd",
5074 "readonly PWD+=/../../../etc; cat $PWD/passwd",
5075 "declare PATH+=/../../../bin; cargo test",
5076 "typeset PWD+=/../../../etc; cat $PWD/passwd",
5077 "local PWD+=/../../../etc; cat $PWD/passwd",
5078 ] {
5079 let reason = chain
5080 .check("shell", &sh(command))
5081 .unwrap_or_else(|| panic!("append assignment must be denied: {command}"));
5082 assert!(
5083 reason.contains("environment assignment"),
5084 "unexpected append-assignment denial for {command}: {reason}"
5085 );
5086 }
5087
5088 for command in [
5089 "RUST_LOG[$(id)]=debug; cargo test",
5090 "RUST_LOG[$(printf 0)]=debug; cargo test",
5091 "env RUST_LOG[$(id)]=debug cargo test",
5092 "export RUST_LOG[$(id)]=debug; cargo test",
5093 "readonly RUST_LOG[`printf 0`]=debug; cargo test",
5094 "declare RUST_LOG[$(id)]=debug; cargo test",
5095 "typeset RUST_LOG[$(id)]=debug; cargo test",
5096 "local RUST_LOG[$(id)]=debug; cargo test",
5097 ] {
5098 let reason = chain.check("shell", &sh(command)).unwrap_or_else(|| {
5099 panic!("command-substitution subscript must be denied: {command}")
5100 });
5101 assert!(reason.contains("cannot be inspected"), "{reason}");
5102 }
5103
5104 for command in [
5105 "export RUST_LOG=debug; cargo test",
5106 "export RUST_LOG+=,debug; cargo test",
5107 "export RUST_LOG[0]=debug; cargo test",
5108 "export RUST_LOG[ 0 ]=debug; cargo test",
5109 "declare RUST_LOG[\t0]=debug; cargo test",
5110 "declare RUST_LOG[$index]=debug; cargo test",
5111 "typeset RUST_LOG[$((1+1))]=debug; cargo test",
5112 "echo 'RUST_LOG[$(id)]=debug'",
5113 "local x=1",
5114 "unset RUST_LOG",
5115 "export -n RUST_LOG",
5116 ] {
5117 assert!(
5118 chain.check("shell", &sh(command)).is_none(),
5119 "harmless assignment builtin must stay allowed: {command}"
5120 );
5121 }
5122 }
5123
5124 #[test]
5125 fn governed_host_denies_busybox_install_mode() {
5126 let chain = governed_host_inspector_chain(Path::new("/wt"));
5127 for command in [
5128 "busybox --install",
5129 "busybox --install -s",
5130 "busybox --install=/tmp/bin",
5131 ] {
5132 let reason = chain
5133 .check("shell", &sh(command))
5134 .unwrap_or_else(|| panic!("busybox install must be denied: {command}"));
5135 assert!(reason.contains("busybox --install"), "{reason}");
5136 }
5137 assert!(chain
5138 .check("shell", &sh("busybox grep foo src/input.txt"))
5139 .is_none());
5140 }
5141
5142 #[test]
5143 fn governed_host_unwraps_prefix_command_carriers() {
5144 let temp = tempfile::tempdir().unwrap();
5145 let repo = temp.path().join("repo");
5146 let outside = temp.path().join("outside.txt");
5147 std::fs::create_dir_all(repo.join("src")).unwrap();
5148 std::fs::write(repo.join("src/input.txt"), "foo\n").unwrap();
5149 std::fs::write(&outside, "secret\n").unwrap();
5150 let chain = governed_host_inspector_chain(&repo);
5151 let path_gate = DenyGovernedShellPathEscape::new(&repo);
5152 let outside = outside.display();
5153
5154 for command in [
5155 format!("env FOO=1 bash -c 'cat {outside}'"),
5156 format!("env -i -u FOO FOO=1 cat {outside}"),
5157 format!("command -p cat {outside}"),
5158 format!("exec -a reader cat {outside}"),
5159 format!("nohup -- cat {outside}"),
5160 format!("time -f '%E' cat {outside}"),
5161 format!("nice --adjustment 5 cat {outside}"),
5162 format!("caffeinate -t 1 cat {outside}"),
5163 format!("script -q transcript cat {outside}"),
5164 format!("xargs -0P 2 cat {outside}"),
5165 "builtin eval 'cat /etc/passwd'".to_string(),
5166 format!("sudo -u root cat {outside}"),
5167 format!("ionice -c 2 cat {outside}"),
5168 format!("timeout --signal TERM 5 cat {outside}"),
5169 format!("env -i time nice -n 1 cat {outside}"),
5170 ] {
5171 assert!(
5172 chain.check("shell", &sh(&command)).is_some(),
5173 "carrier must not hide the governed command: {command}"
5174 );
5175 }
5176
5177 assert!(matches!(
5181 path_gate.inspect("shell", &sh("env")),
5182 InspectionResult::Allow
5183 ));
5184 for command in [
5185 "time cargo build",
5186 "xargs -0 grep foo src/input.txt",
5187 "busybox grep foo src/input.txt",
5188 ] {
5189 assert!(
5190 chain.check("shell", &sh(command)).is_none(),
5191 "safe carrier control must stay allowed: {command}"
5192 );
5193 }
5194 }
5195
5196 #[test]
5197 fn prefix_carriers_do_not_bypass_other_shell_inspectors() {
5198 for command in [
5199 "env FOO=1 git push origin main",
5200 "command gh pr create --fill",
5201 "time cargo publish",
5202 "nice -n 1 pip install requests",
5203 ] {
5204 assert!(
5205 denied("shell", sh(command)),
5206 "must deny carried command: {command}"
5207 );
5208 }
5209 }
5210
5211 #[test]
5212 fn governed_host_distinguishes_sed_and_awk_programs_from_paths() {
5213 let temp = tempfile::tempdir().unwrap();
5214 let repo = temp.path().join("repo");
5215 std::fs::create_dir_all(repo.join("src")).unwrap();
5216 std::fs::write(repo.join("src/lib.rs"), "pub fn example() {}\n").unwrap();
5217 std::fs::write(repo.join("file.rs"), "pub fn example() {}\n").unwrap();
5218 std::fs::write(repo.join("file"), "x\n").unwrap();
5219 std::fs::write(repo.join("script.sed"), "p\n").unwrap();
5220 std::fs::write(repo.join("-"), "p\n").unwrap();
5221 #[cfg(unix)]
5222 {
5223 let mut escaped_dash = repo.clone();
5228 escaped_dash.push(r"\-");
5229 std::fs::write(escaped_dash, "p\n").unwrap();
5230 }
5231 let chain = governed_host_inspector_chain(&repo);
5232
5233 assert!(
5234 chain
5235 .check("shell", &sh("sed -n /pub fn/p file.rs"))
5236 .is_none(),
5237 "a sed address is a program, not the absolute path /pub"
5238 );
5239 assert!(
5240 chain
5241 .check("shell", &sh("sed -n '/pub fn/p' src/lib.rs"))
5242 .is_none(),
5243 "a quoted sed address is a program, not the absolute path /pub"
5244 );
5245 assert!(
5246 chain
5247 .check("shell", &sh("sed -e '/pub fn/p' src/lib.rs"))
5248 .is_none(),
5249 "a sed -e operand is an inline program, not a path"
5250 );
5251 assert!(
5252 chain
5253 .check("shell", &sh("sed -n '/pub \\/etc/p' src/lib.rs"))
5254 .is_none(),
5255 "quoted whitespace must not split one sed program into path operands"
5256 );
5257 assert!(
5258 chain.check("shell", &sh("awk /pub/ file.rs")).is_none(),
5259 "an awk pattern is a program, not the absolute path /pub"
5260 );
5261 assert!(
5262 chain.check("shell", &sh("awk '/x/{print}' file")).is_none(),
5263 "an awk pattern-action is a program, not a path"
5264 );
5265 assert!(
5266 chain
5267 .check("shell", &sh("awk '/x/ { print \"/etc\" }' file"))
5268 .is_none(),
5269 "a quoted awk program remains one non-path argument"
5270 );
5271 assert!(
5272 chain.check("shell", &sh("sed -n p /etc/passwd")).is_some(),
5273 "sed input files remain governed"
5274 );
5275 assert!(
5276 chain.check("shell", &sh("sed -f /etc/evil")).is_some(),
5277 "sed -f names a script file and must remain governed"
5278 );
5279 assert!(
5280 chain.check("shell", &sh("awk -E/path file")).is_some(),
5281 "an attached awk -E script path must remain governed"
5282 );
5283 assert!(
5284 chain
5285 .check("shell", &sh("sed -f script.sed file"))
5286 .is_none(),
5287 "a literal repository script must remain allowed"
5288 );
5289 assert!(
5290 chain
5291 .check("shell", &sh("sed -e '' -f script.sed file"))
5292 .is_none(),
5293 "empty -e padding must not deny a literal repository script"
5294 );
5295 assert!(
5296 chain.check("shell", &sh("sed -f ./- file")).is_none(),
5297 "./- is a literal repository path, not sed's stdin marker"
5298 );
5299 #[cfg(unix)]
5300 {
5301 for command in [r"sed -f '\-' file", r#"sed -f "\-" file"#] {
5302 assert!(
5303 chain.check("shell", &sh(command)).is_none(),
5304 "a backslash preserved by shell quotes remains a repository filename: {command}"
5305 );
5306 }
5307 }
5308 }
5309
5310 #[test]
5311 fn governed_host_denies_gui_shell_automation() {
5312 let chain = governed_host_inspector_chain(Path::new("/wt"));
5313 assert!(chain
5314 .check(
5315 "run_applescript",
5316 &json!({"script": "tell application \"Terminal\" to do script \"az deploy\""})
5317 )
5318 .is_some());
5319 assert!(chain
5320 .check("run_powershell", &json!({"script": "az deploy"}))
5321 .is_some());
5322 }
5323
5324 #[test]
5325 fn history_rewrite_denied() {
5326 assert!(denied("shell", sh("git rebase -i HEAD~3")));
5327 assert!(denied("shell", sh("git reset --hard HEAD~1")));
5328 assert!(denied("shell", sh("git filter-branch --all")));
5329 assert!(denied("shell", sh("git worktree remove /wt")));
5330 assert!(!denied("shell", sh("git reset HEAD file.txt"))); }
5332
5333 #[test]
5334 fn privilege_escalation_denied() {
5335 assert!(denied("shell", sh("sudo rm -rf /tmp/x")));
5336 assert!(denied("shell", sh("doas pkg_add x")));
5337 assert!(denied("shell", sh("FOO=1 sudo make install")));
5338 assert!(denied("shell", sh("launchctl unload foo")));
5339 assert!(!denied("shell", sh("echo sudo"))); }
5341
5342 #[test]
5343 fn credential_access_denied_for_shell_and_file_tools() {
5344 assert!(denied("shell", sh("cat ~/.ssh/id_rsa")));
5345 assert!(denied("shell", sh("cat $HOME/.aws/credentials")));
5346 assert!(denied("shell", sh("security find-generic-password -s x")));
5347 assert!(denied("read_file", json!({"path": "/Users/u/.ssh/id_rsa"})));
5348 assert!(denied("read_file", json!({"path": "~/.netrc"})));
5349 assert!(!denied("read_file", json!({"path": "src/main.rs"})));
5350 }
5353
5354 #[test]
5355 fn destructive_ops_scoped_to_worktree() {
5356 assert!(denied("shell", sh("rm -rf /etc")));
5357 assert!(denied("shell", sh("rm -rf ../other-checkout")));
5358 assert!(denied("shell", sh("mv target ~/elsewhere")));
5359 assert!(denied("shell", sh("chmod 777 /usr/local/bin/x")));
5360 assert!(!denied("shell", sh("rm -rf target/debug")));
5362 assert!(!denied("shell", sh("rm /wt/scratch.txt")));
5363 assert!(!denied("shell", sh("cp a.txt b.txt")));
5364 }
5365
5366 #[test]
5367 fn write_path_escape_denied_but_reads_allowed() {
5368 assert!(denied(
5369 "write_file",
5370 json!({"path": "/etc/hosts", "content": "x"})
5371 ));
5372 assert!(denied("edit_file", json!({"path": "../outside.txt"})));
5373 assert!(!denied(
5374 "write_file",
5375 json!({"path": "src/new.rs", "content": "x"})
5376 ));
5377 assert!(!denied(
5378 "write_file",
5379 json!({"path": "/wt/src/new.rs", "content": "x"})
5380 ));
5381 assert!(!denied(
5384 "read_file",
5385 json!({"path": "/usr/include/stdio.h"})
5386 ));
5387 }
5388
5389 #[test]
5390 fn stays_under_is_lexical_and_strict() {
5391 let root = Path::new("/wt");
5392 assert!(stays_under(root, "src/x.rs"));
5393 assert!(stays_under(root, "a/../b.txt"));
5394 assert!(stays_under(root, "/wt/deep/file"));
5395 assert!(!stays_under(root, "../escape"));
5396 assert!(!stays_under(root, "a/../../escape"));
5397 assert!(!stays_under(root, "/etc/passwd"));
5398 assert!(!stays_under(root, "/wtevil/file")); assert!(!stays_under(root, "~"));
5400 assert!(!stays_under(root, "~/outside"));
5401 assert!(!stays_under(root, "~someone/outside"));
5402 assert!(!stays_under(root, "$HOME"));
5403 assert!(!stays_under(root, "$HOME/outside"));
5404 assert!(!stays_under(root, "${HOME}/outside"));
5405 assert!(!stays_under(root, "${HOME:-/tmp}/outside"));
5406 assert!(!stays_under(root, "${HOME:=/tmp}/outside"));
5407 assert!(!stays_under(root, "$TMPDIR/outside"));
5408 assert!(!stays_under(root, "${TMPDIR}/outside"));
5409 assert!(!stays_under(root, "${TMPDIR:-/tmp}/outside"));
5410 assert!(!stays_under(root, "${TMPDIR:=/tmp}/outside"));
5411 assert!(!stays_under(root, "$HOME_fixture"));
5412 assert!(!stays_under(root, "${HOME_fixture}/inside"));
5413 assert!(stays_under(root, "src/$x"));
5414 assert!(stays_under(root, "src/~fixture"));
5415
5416 let temp = tempfile::tempdir().unwrap();
5417 std::fs::write(temp.path().join("$HOME_fixture"), "literal").unwrap();
5418 assert!(stays_under(temp.path(), "$HOME_fixture"));
5419 }
5420
5421 #[cfg(windows)]
5422 #[test]
5423 fn windows_destructive_and_privilege_denied() {
5424 let chain = coder_inspector_chain(Path::new(r"C:\wt"));
5425 let denied = |cmd: &str| chain.check("shell", &sh(cmd)).is_some();
5426 assert!(denied(r"del C:\Windows\System32\drivers\etc\hosts"));
5428 assert!(denied(r"rd /s /q C:\Windows"));
5429 assert!(denied(r"del /q C:\Users\victim\file")); assert!(denied(r"move C:\wt\keep.txt C:\Users\public\stolen.txt"));
5431 assert!(denied("runas /user:Administrator cmd"));
5433 assert!(denied("sc stop windefend"));
5434 assert!(!denied(r"del C:\wt\target\debug\app.exe"));
5436 assert!(!denied(r"del build\out.txt"));
5437 assert!(!denied("dir")); }
5439
5440 #[cfg(windows)]
5441 #[test]
5442 fn windows_credential_access_denied() {
5443 let chain = coder_inspector_chain(Path::new(r"C:\wt"));
5444 assert!(chain
5445 .check("shell", &sh(r"type %USERPROFILE%\.ssh\id_rsa"))
5446 .is_some());
5447 assert!(chain.check("shell", &sh("cmdkey /list")).is_some());
5448 assert!(chain
5449 .check(
5450 "read_file",
5451 &json!({"path": r"C:\Users\u\.aws\credentials"})
5452 )
5453 .is_some());
5454 assert!(chain
5456 .check("read_file", &json!({"path": r"C:\wt\src\main.rs"}))
5457 .is_none());
5458 }
5459
5460 #[cfg(windows)]
5461 #[test]
5462 fn stays_under_handles_verbatim_prefix_and_case() {
5463 let root = Path::new(r"\\?\C:\wt");
5467 assert!(stays_under(root, r"C:\WT\src\main.rs"));
5468 assert!(stays_under(root, r"c:\wt\src\main.rs"));
5469 assert!(!stays_under(root, r"C:\other\x"));
5470 assert!(!stays_under(root, r"C:\wtevil\x")); }
5472}