Skip to main content

car_server_core/coder/
policy.rs

1//! The coder's inspector chain — policy hardening for host tool execution.
2//!
3//! Every tool call the coder makes (model-proposed AND contract checks) passes
4//! through this chain before dispatch; first Deny wins. CAR's built-ins run
5//! first, followed by the operator's additive rules from `<CAR_HOME>/policies/`
6//! and `<worktree>/.car/policies/`. The checks are deliberately conservative
7//! token/substring matchers, not a shell parser — they block the unambiguous
8//! footguns. The one exception in shape is
9//! [`DenyForgePublication`], which pairs a read-only *allowlist* for the forge
10//! CLIs (`gh`, `glab`, `hub`) with a small publication blacklist, because the
11//! set of read-only `gh` subcommands is finite and the set of mutating ones is
12//! not. This is hardening, not a sandbox — a shell alias or a script wrapping
13//! the binary still gets through; the real gates are
14//! contract confirmation and merge approval (see `coder::` module docs). Prefix
15//! command carriers are unwrapped recursively so the command they launch still
16//! reaches each applicable inspector.
17
18use std::collections::{BTreeSet, HashMap};
19use std::path::{Component, Path, PathBuf};
20
21use car_ir::Action;
22use car_policy::{InspectionResult, Inspector, InspectorChain, PolicyEngine, PolicyRules};
23use car_state::StateStore;
24use serde_json::Value;
25
26/// Build the standard coder chain for a worktree.
27pub fn coder_inspector_chain(worktree: &Path) -> InspectorChain {
28    InspectorChain::new()
29        .with(Box::new(DenyGitRemoteMutation))
30        .with(Box::new(DenyForgePublication))
31        .with(Box::new(DenyHistoryRewrite))
32        .with(Box::new(DenyPrivilegeEscalation))
33        .with(Box::new(DenyCredentialAccess))
34        .with(Box::new(DenyEnvironmentRepair))
35        .with(Box::new(DenyDestructiveOutsideWorktree {
36            worktree: worktree.to_path_buf(),
37        }))
38        .with(Box::new(DenyPathEscape {
39            worktree: worktree.to_path_buf(),
40        }))
41}
42
43/// Build the standard coder chain plus every operator-authored policy that
44/// governs this session.
45///
46/// The source order deliberately matches `car_policy::tool_gate`: machine-wide
47/// rules first, then rules committed with the project. All current rule kinds
48/// are prohibitions, so merging cannot relax a built-in or an earlier rule.
49/// Built-ins remain ahead of the declarative inspector because first-Deny-wins
50/// decides which actionable reason the model sees.
51///
52/// Tool names stay exact. A policy for a surface-specific tool the coder does
53/// not expose (for example Claude Code's `WebFetch`) is loaded but has nothing
54/// to match; CAR does not guess aliases between tools with different schemas.
55/// Stateful `trace_rule` is also deliberately excluded: the shared loader
56/// rejects it as unenforced instead of silently claiming it took effect.
57/// The chain, plus the tools the operator's rules forbid **outright**.
58///
59/// Both come from one load of the policy files. The denied set is returned
60/// rather than left inside the chain because it answers a question dispatch
61/// cannot: which tools should never have been offered to the model in the first
62/// place. See [`car_policy::PolicyEngine::blanket_denied_tools`].
63pub struct CoderPolicy {
64    pub chain: InspectorChain,
65    pub denied_tools: BTreeSet<String>,
66}
67
68pub fn coder_inspector_chain_with_project_policies(
69    worktree: &Path,
70) -> Result<CoderPolicy, car_policy::PolicyLoadError> {
71    let dirs = [
72        car_home::root_or_relative().join("policies"),
73        worktree.join(".car").join("policies"),
74    ];
75    coder_inspector_chain_from_policy_dirs(worktree, &dirs)
76}
77
78fn coder_inspector_chain_from_policy_dirs(
79    worktree: &Path,
80    dirs: &[PathBuf],
81) -> Result<CoderPolicy, car_policy::PolicyLoadError> {
82    let mut rules = PolicyRules::default();
83    for dir in dirs {
84        rules.merge(car_policy::load_policy_dir(dir)?);
85    }
86
87    let mut engine = PolicyEngine::new();
88    rules.apply(&mut engine);
89    // Read before the engine moves into the inspector.
90    let denied_tools = engine.blanket_denied_tools();
91    Ok(CoderPolicy {
92        chain: coder_inspector_chain(worktree).with(Box::new(ProjectPolicyInspector {
93            engine,
94            state: StateStore::new(),
95        })),
96        denied_tools,
97    })
98}
99
100/// Adapter from the declarative `PolicyEngine` to the coder's dispatch-time
101/// inspector seam. One instance lives for the session, so rate-limit windows
102/// cover the whole run rather than resetting for every tool call.
103struct ProjectPolicyInspector {
104    engine: PolicyEngine,
105    state: StateStore,
106}
107
108impl Inspector for ProjectPolicyInspector {
109    fn name(&self) -> &'static str {
110        "project_policy"
111    }
112
113    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
114        // Action is non-exhaustive outside car-ir; use its constructor so a new
115        // field cannot make this adapter silently construct a stale shape.
116        let mut action = Action::tool_call(tool);
117        action.id = "coder-policy-check".to_string();
118        action.parameters = params
119            .as_object()
120            .map(|m| {
121                m.iter()
122                    .map(|(key, value)| (key.clone(), value.clone()))
123                    .collect::<HashMap<_, _>>()
124            })
125            .unwrap_or_default();
126
127        match self.engine.check(&action, &self.state).into_iter().next() {
128            Some(violation) => InspectionResult::Deny(format!(
129                "operator policy '{}': {}",
130                violation.policy_name, violation.reason
131            )),
132            None => InspectionResult::Allow,
133        }
134    }
135}
136
137/// Governed host execution is deliberately narrower than the legacy coder:
138/// direct shell reads and directory changes must remain under the selected
139/// repository. Toolchains may still load their own executables and libraries;
140/// this gate prevents the model from naming host files as command operands.
141struct DenyGovernedShellPathEscape {
142    worktree: PathBuf,
143    /// `OLDPWD` is inspectable only when the child inherits the repository
144    /// root itself. Any other value would make a variable-prefixed operand an
145    /// off-repository path.
146    oldpwd_is_worktree: bool,
147    /// An inherited target directory is a second governed read root. Cargo's
148    /// config-file target is deliberately absent here: `$CARGO_TARGET_DIR`
149    /// expands to nothing unless the environment variable itself is pinned.
150    cargo_target_dir: Option<PathBuf>,
151}
152
153impl DenyGovernedShellPathEscape {
154    fn new(worktree: &Path) -> Self {
155        let inherited_path = |name: &str| {
156            std::env::var_os(name).and_then(|value| {
157                let path = PathBuf::from(value);
158                (!path.as_os_str().is_empty()).then(|| {
159                    if path.is_absolute() {
160                        path
161                    } else {
162                        worktree.join(path)
163                    }
164                })
165            })
166        };
167        let oldpwd_is_worktree = inherited_path("OLDPWD").is_some_and(|oldpwd| {
168            match (oldpwd.canonicalize(), worktree.canonicalize()) {
169                (Ok(oldpwd), Ok(worktree)) => oldpwd == worktree,
170                _ => false,
171            }
172        });
173        Self {
174            worktree: worktree.to_path_buf(),
175            oldpwd_is_worktree,
176            cargo_target_dir: inherited_path("CARGO_TARGET_DIR"),
177        }
178    }
179}
180
181const READ_OR_CHDIR_VERBS: &[&str] = &[
182    "cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "sed", "awk", "find",
183    "ls", "stat", "wc", "strings", "readlink", "realpath", "cd", "type",
184];
185
186/// Advance the byte index past whitespace in an inline program.
187fn skip_program_whitespace(program: &str, index: &mut usize) {
188    while let Some(ch) = program[*index..].chars().next() {
189        if !ch.is_whitespace() {
190            break;
191        }
192        *index += ch.len_utf8();
193    }
194}
195
196/// Consume one sed address at the current byte index. The path-looking text in
197/// `/regex/` remains program syntax; only the command that follows the address
198/// may introduce an r/R/w/W file operand.
199fn consume_sed_address(program: &str, index: &mut usize) -> bool {
200    let Some(first) = program[*index..].chars().next() else {
201        return false;
202    };
203    match first {
204        '0'..='9' => {
205            while let Some(ch) = program[*index..].chars().next() {
206                if !ch.is_ascii_digit() {
207                    break;
208                }
209                *index += ch.len_utf8();
210            }
211            true
212        }
213        '$' => {
214            *index += 1;
215            true
216        }
217        '/' => {
218            *index += 1;
219            let mut escaped = false;
220            while let Some(ch) = program[*index..].chars().next() {
221                *index += ch.len_utf8();
222                if escaped {
223                    escaped = false;
224                } else if ch == '\\' {
225                    escaped = true;
226                } else if ch == '/' {
227                    return true;
228                }
229            }
230            true
231        }
232        // GNU/BSD sed alternate regex address: \%regex% (the character after
233        // the backslash is the delimiter).
234        '\\' => {
235            *index += 1;
236            let Some(delimiter) = program[*index..].chars().next() else {
237                return true;
238            };
239            *index += delimiter.len_utf8();
240            let mut escaped = false;
241            while let Some(ch) = program[*index..].chars().next() {
242                *index += ch.len_utf8();
243                if escaped {
244                    escaped = false;
245                } else if ch == '\\' {
246                    escaped = true;
247                } else if ch == delimiter {
248                    return true;
249                }
250            }
251            true
252        }
253        _ => false,
254    }
255}
256
257/// Consume one delimiter-terminated sed field, honoring escaped delimiters.
258fn consume_sed_delimited_field(statement: &str, index: &mut usize, delimiter: char) -> bool {
259    let mut escaped = false;
260    while let Some(ch) = statement[*index..].chars().next() {
261        *index += ch.len_utf8();
262        if escaped {
263            escaped = false;
264        } else if ch == '\\' {
265            escaped = true;
266        } else if ch == delimiter {
267            return true;
268        }
269    }
270    false
271}
272
273fn sed_line_end(program: &str, index: usize) -> usize {
274    program[index..]
275        .find('\n')
276        .map_or(program.len(), |offset| index + offset)
277}
278
279fn skip_to_sed_separator(program: &str, index: &mut usize) {
280    while let Some(ch) = program[*index..].chars().next() {
281        if matches!(ch, ';' | '\n' | '{' | '}') {
282            break;
283        }
284        *index += ch.len_utf8();
285    }
286}
287
288/// File operands embedded in one inline sed program. The program is walked as
289/// sed syntax rather than split blindly: separators inside regex,
290/// substitutions, or transliterations do not hide a later file operand or
291/// become a false command. GNU sed's `e` command and substitution flag execute
292/// a shell and are refused outright.
293fn sed_program_paths(program: &str) -> Result<Vec<String>, String> {
294    let mut paths = Vec::new();
295    let mut index = 0;
296    while index < program.len() {
297        skip_program_whitespace(program, &mut index);
298        while let Some(separator) = program[index..].chars().next() {
299            if !matches!(separator, ';' | '{' | '}') {
300                break;
301            }
302            index += separator.len_utf8();
303            skip_program_whitespace(program, &mut index);
304        }
305        if index == program.len() {
306            break;
307        }
308
309        loop {
310            if !consume_sed_address(program, &mut index) {
311                break;
312            }
313            skip_program_whitespace(program, &mut index);
314            if program[index..].starts_with(',') || program[index..].starts_with('~') {
315                index += 1;
316                skip_program_whitespace(program, &mut index);
317                continue;
318            }
319            break;
320        }
321        skip_program_whitespace(program, &mut index);
322        if program[index..].starts_with('!') {
323            index += 1;
324            skip_program_whitespace(program, &mut index);
325        }
326        let Some(command) = program[index..].chars().next() else {
327            break;
328        };
329        index += command.len_utf8();
330        match command {
331            'r' | 'R' | 'w' | 'W' => {
332                // sed accepts both `w FILE` and `wFILE`. These commands consume
333                // the remainder of their line as the filename.
334                let end = sed_line_end(program, index);
335                let operand = program[index..end].trim();
336                if !operand.is_empty() {
337                    paths.push(operand.to_string());
338                }
339                index = end;
340            }
341            's' => {
342                let Some(delimiter) = program[index..].chars().next() else {
343                    break;
344                };
345                index += delimiter.len_utf8();
346                if !consume_sed_delimited_field(program, &mut index, delimiter)
347                    || !consume_sed_delimited_field(program, &mut index, delimiter)
348                {
349                    continue;
350                }
351                while let Some(flag) = program[index..].chars().next() {
352                    match flag {
353                        'e' => {
354                            return Err(
355                                "sed shell execution through the e flag is not allowed in a governed session"
356                                    .to_string(),
357                            )
358                        }
359                        'w' => {
360                            index += flag.len_utf8();
361                            let end = sed_line_end(program, index);
362                            let operand = program[index..end].trim();
363                            if !operand.is_empty() {
364                                paths.push(operand.to_string());
365                            }
366                            index = end;
367                            break;
368                        }
369                        ';' | '\n' | '{' | '}' => break,
370                        ch if ch.is_whitespace()
371                            || ch.is_ascii_digit()
372                            || matches!(ch, 'g' | 'i' | 'I' | 'm' | 'M' | 'p') =>
373                        {
374                            index += ch.len_utf8();
375                        }
376                        _ => {
377                            // Malformed/unknown flags cannot safely be parsed
378                            // as another command. Skip to the next separator.
379                            skip_to_sed_separator(program, &mut index);
380                            break;
381                        }
382                    }
383                }
384            }
385            'y' => {
386                // Transliteration has two delimiter-terminated fields, either
387                // of which may contain characters that otherwise separate
388                // commands (for example `y/a/;/`).
389                let Some(delimiter) = program[index..].chars().next() else {
390                    break;
391                };
392                index += delimiter.len_utf8();
393                if !consume_sed_delimited_field(program, &mut index, delimiter)
394                    || !consume_sed_delimited_field(program, &mut index, delimiter)
395                {
396                    continue;
397                }
398            }
399            'e' => return Err(
400                "sed shell execution through the e command is not allowed in a governed session"
401                    .to_string(),
402            ),
403            // These commands consume text or a filename-like label through the
404            // end of the physical program line. Do not reinterpret its bytes as
405            // more commands.
406            'a' | 'c' | 'i' | '#' => index = sed_line_end(program, index),
407            // Labels and optional numeric arguments run until a separator.
408            ':' | 'b' | 'l' | 'q' | 'Q' | 't' | 'T' | 'v' => {
409                skip_to_sed_separator(program, &mut index)
410            }
411            _ => {}
412        }
413    }
414    Ok(paths)
415}
416
417fn awk_program_tokens(program: &str) -> Vec<String> {
418    fn finish(token: &mut String, result: &mut Vec<String>) {
419        if !token.is_empty() {
420            result.push(std::mem::take(token));
421        }
422    }
423
424    let mut result = Vec::new();
425    let mut token = String::new();
426    let mut quote = None;
427    let mut escaped = false;
428    let mut chars = program.chars().peekable();
429    while let Some(ch) = chars.next() {
430        if let Some(delimiter) = quote {
431            if escaped {
432                token.push(ch);
433                escaped = false;
434            } else if ch == '\\' {
435                escaped = true;
436            } else if ch == delimiter {
437                quote = None;
438                finish(&mut token, &mut result);
439            } else {
440                token.push(ch);
441            }
442            continue;
443        }
444        match ch {
445            '"' | '\'' => {
446                finish(&mut token, &mut result);
447                quote = Some(ch);
448            }
449            ch if ch.is_whitespace() => finish(&mut token, &mut result),
450            '<' | '>' | '|' => {
451                finish(&mut token, &mut result);
452                let mut operator = ch.to_string();
453                if (ch == '>' && chars.peek() == Some(&'>'))
454                    || (ch == '|' && chars.peek() == Some(&'&'))
455                {
456                    let joined = chars.next().expect("peeked operator suffix");
457                    operator.push(joined);
458                }
459                result.push(operator);
460            }
461            ';' | '{' | '}' | '(' | ')' | ',' => {
462                finish(&mut token, &mut result);
463                result.push(ch.to_string());
464            }
465            _ => token.push(ch),
466        }
467    }
468    finish(&mut token, &mut result);
469    result
470}
471
472/// File operands embedded in one inline awk program. `getline` reads only when
473/// paired with `<`; print/printf write only through `>` or `>>`. General shell
474/// execution through `system()` or a command pipe is refused outright so the
475/// inner command cannot bypass the rest of the governed inspector chain.
476fn awk_program_paths(program: &str) -> Result<Vec<String>, String> {
477    let tokens = awk_program_tokens(program);
478    if tokens
479        .windows(2)
480        .any(|pair| pair[0] == "system" && pair[1] == "(")
481    {
482        return Err("awk system() is not allowed in a governed session".to_string());
483    }
484    for (index, token) in tokens.iter().enumerate() {
485        if !matches!(token.as_str(), "|" | "|&") {
486            continue;
487        }
488        let start = tokens[..index]
489            .iter()
490            .rposition(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
491            .map_or(0, |position| position + 1);
492        let end = tokens[index + 1..]
493            .iter()
494            .position(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
495            .map_or(tokens.len(), |offset| index + 1 + offset);
496        if tokens[start..index]
497            .iter()
498            .any(|candidate| matches!(candidate.as_str(), "print" | "printf"))
499            || tokens[index + 1..end]
500                .iter()
501                .any(|candidate| candidate == "getline")
502        {
503            return Err("awk command pipes are not allowed in a governed session".to_string());
504        }
505    }
506
507    let mut paths = Vec::new();
508    for (index, token) in tokens.iter().enumerate() {
509        let wanted = match token.as_str() {
510            "getline" => &["<"][..],
511            "print" | "printf" => &[">", ">>"][..],
512            _ => continue,
513        };
514        let end = tokens[index + 1..]
515            .iter()
516            .position(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
517            .map_or(tokens.len(), |offset| index + 1 + offset);
518        let Some(operator) = tokens[index + 1..end]
519            .iter()
520            .position(|candidate| wanted.contains(&candidate.as_str()))
521            .map(|offset| index + 1 + offset)
522        else {
523            continue;
524        };
525        if let Some(path) = tokens[operator + 1..end]
526            .iter()
527            .find(|candidate| candidate.as_str() != "(")
528        {
529            paths.push(path.clone());
530        }
531    }
532    Ok(paths)
533}
534
535fn inline_program_paths(verb: &str, program: &str) -> Result<Vec<String>, String> {
536    match verb {
537        "sed" => sed_program_paths(program),
538        "awk" => awk_program_paths(program),
539        _ => Ok(Vec::new()),
540    }
541}
542
543fn append_inline_program_paths(
544    verb: &str,
545    program: &str,
546    paths: &mut Vec<String>,
547) -> Result<(), String> {
548    paths.extend(inline_program_paths(verb, program)?);
549    Ok(())
550}
551
552/// Record a sed/awk script file, refusing sources whose contents arrive over a
553/// shell channel or whose path is produced by word expansion. The governed
554/// tokenizer cannot resolve heredoc, pipe, here-string, parameter, command,
555/// process, or brace expansion before the shell runs, so accepting one as an
556/// ordinary path would skip embedded file-I/O and shell-execution checks.
557fn has_brace_expansion_comma(source: &str) -> bool {
558    let mut braces = Vec::new();
559    let mut escaped = false;
560    for ch in source.chars() {
561        if escaped {
562            escaped = false;
563            continue;
564        }
565        match ch {
566            '\\' => escaped = true,
567            '{' => braces.push(false),
568            ',' => {
569                if let Some(brace) = braces.last_mut() {
570                    *brace = true;
571                }
572            }
573            '}' if braces.pop().is_some_and(|has_comma| has_comma) => return true,
574            _ => {}
575        }
576    }
577    false
578}
579
580fn reject_shell_fed_program_source(verb: &str, source: &str) -> Result<(), String> {
581    // `./-` is intentionally different from `-`: it names a literal
582    // repository file and has the same governed-path posture as `script.sed`.
583    let is_program_channel = source.is_empty()
584        || source == "-"
585        || source == "/dev/stdin"
586        || source
587            .strip_prefix("/dev/fd/")
588            .is_some_and(|fd| !fd.is_empty())
589        || source
590            .strip_prefix("/proc/self/fd/")
591            .is_some_and(|fd| !fd.is_empty())
592        || source.starts_with("<(")
593        || source.starts_with(">(")
594        || source.contains('$')
595        || source.contains('`')
596        || has_brace_expansion_comma(source);
597    if is_program_channel {
598        return Err(format!(
599            "{verb} script source '{source}' cannot be inspected in a governed session"
600        ));
601    }
602    Ok(())
603}
604
605fn append_program_source_path(
606    verb: &str,
607    source: &str,
608    paths: &mut Vec<String>,
609) -> Result<(), String> {
610    reject_shell_fed_program_source(verb, source)?;
611    paths.push(source.to_string());
612    Ok(())
613}
614
615/// Inspect every standalone script-file option by its own token position.
616///
617/// This is intentionally independent of preceding options: an empty quoted
618/// `-e ''` operand must not let sequential argument consumption hide a later
619/// `-f -`. Normal literal sources are collected by the full parser below; this
620/// pass only rejects sources whose bytes the shell supplies at execution time.
621fn reject_shell_fed_program_file_operands(verb: &str, args: &[String]) -> Result<(), String> {
622    for (index, arg) in args.iter().enumerate() {
623        if arg == "--" {
624            break;
625        }
626        let is_file_option = matches!(
627            (verb, arg.as_str()),
628            ("sed", "-f" | "--file") | ("awk", "-f" | "-E" | "--file")
629        );
630        if is_file_option {
631            let source = args.get(index + 1).map_or("", String::as_str);
632            reject_shell_fed_program_source(verb, source)?;
633        }
634    }
635    Ok(())
636}
637
638/// Arguments that can name files for a governed read command.
639///
640/// Most verbs use every non-option operand. `sed` and `awk` are different: the
641/// first positional operand is a program, and regex addresses commonly begin
642/// with `/`, so treating it as a path turns `sed -n '/pub fn/p' src/lib.rs`
643/// into an attempted read of `/pub`. Their `-f` operands are real program
644/// files and remain governed; shell-fed `-f` channels are refused because their
645/// programs cannot be associated safely with the command. `-e` operands are
646/// inline programs whose embedded file I/O is extracted and whose
647/// command-execution forms are refused.
648fn governed_path_arguments(verb: &str, tokens: &[String]) -> Result<Vec<String>, String> {
649    let Some(verb_index) = tokens.iter().position(|token| !token.contains('=')) else {
650        return Ok(Vec::new());
651    };
652    let args = &tokens[verb_index + 1..];
653    if !matches!(verb, "sed" | "awk") {
654        return Ok(args
655            .iter()
656            .filter(|arg| !arg.starts_with('-'))
657            .cloned()
658            .collect());
659    }
660
661    reject_shell_fed_program_file_operands(verb, args)?;
662
663    let mut paths = Vec::new();
664    let mut explicit_program = false;
665    let mut positional_program_seen = false;
666    let mut options = true;
667    let mut index = 0;
668    while index < args.len() {
669        let arg = args[index].as_str();
670        if options && arg == "--" {
671            options = false;
672            index += 1;
673            continue;
674        }
675        if options && arg.starts_with('-') && arg != "-" {
676            match (verb, arg) {
677                ("sed", "-e" | "--expression") | ("awk", "-e" | "--source") => {
678                    explicit_program = true;
679                    if let Some(program) = args.get(index + 1) {
680                        append_inline_program_paths(verb, program, &mut paths)?;
681                    }
682                    index += 2;
683                    continue;
684                }
685                ("sed", "-f" | "--file") | ("awk", "-f" | "-E" | "--file") => {
686                    explicit_program = true;
687                    if let Some(source) = args.get(index + 1) {
688                        append_program_source_path(verb, source, &mut paths)?;
689                    }
690                    index += 2;
691                    continue;
692                }
693                ("awk", "-F" | "-v") => {
694                    index += 2; // field separator / variable assignment
695                    continue;
696                }
697                _ => {}
698            }
699            if let Some(source) = arg.strip_prefix("--file=") {
700                explicit_program = true;
701                append_program_source_path(verb, source, &mut paths)?;
702                index += 1;
703                continue;
704            }
705            if let Some(program) = arg
706                .strip_prefix("--expression=")
707                .or_else(|| arg.strip_prefix("--source="))
708            {
709                explicit_program = true;
710                append_inline_program_paths(verb, program, &mut paths)?;
711                index += 1;
712                continue;
713            }
714
715            if verb == "sed" && !arg.starts_with("--") {
716                // sed permits no-operand flags to be bundled before the final
717                // option that consumes a program or script file: -ne and -nf.
718                // In-place editing also accepts an attached backup suffix
719                // (-i.bak), whose remainder is not another option.
720                let cluster = &arg[1..];
721                let mut cursor = 0;
722                let mut handled = true;
723                while let Some(option) = cluster[cursor..].chars().next() {
724                    cursor += option.len_utf8();
725                    match option {
726                        'n' | 'E' | 'r' | 's' | 'u' | 'z' => {}
727                        'e' | 'f' => {
728                            explicit_program = true;
729                            let attached = &cluster[cursor..];
730                            if option == 'e' {
731                                if attached.is_empty() {
732                                    if let Some(program) = args.get(index + 1) {
733                                        append_inline_program_paths("sed", program, &mut paths)?;
734                                    }
735                                    index += 2;
736                                } else {
737                                    append_inline_program_paths("sed", attached, &mut paths)?;
738                                    index += 1;
739                                }
740                            } else if attached.is_empty() {
741                                if let Some(source) = args.get(index + 1) {
742                                    append_program_source_path("sed", source, &mut paths)?;
743                                }
744                                index += 2;
745                            } else {
746                                append_program_source_path("sed", attached, &mut paths)?;
747                                index += 1;
748                            }
749                            break;
750                        }
751                        'i' => {
752                            // Everything after i is the backup suffix. The next
753                            // argument is still the positional sed program.
754                            index += 1;
755                            break;
756                        }
757                        _ => {
758                            handled = false;
759                            break;
760                        }
761                    }
762                }
763                if handled {
764                    if cursor == cluster.len()
765                        && !matches!(cluster.chars().last(), Some('e' | 'f' | 'i'))
766                    {
767                        index += 1;
768                    }
769                    continue;
770                }
771            }
772
773            let attached_file_source = arg
774                .strip_prefix("-f")
775                .filter(|source| !source.is_empty())
776                .or_else(|| {
777                    if verb == "awk" {
778                        arg.strip_prefix("-E").filter(|source| !source.is_empty())
779                    } else {
780                        None
781                    }
782                });
783            if let Some(source) = attached_file_source {
784                explicit_program = true;
785                append_program_source_path(verb, source, &mut paths)?;
786            } else if let Some(program) = arg.strip_prefix("-e").filter(|p| !p.is_empty()) {
787                explicit_program = true;
788                append_inline_program_paths(verb, program, &mut paths)?;
789            }
790            index += 1;
791            continue;
792        }
793
794        if !explicit_program && !positional_program_seen {
795            positional_program_seen = true;
796            append_inline_program_paths(verb, arg, &mut paths)?;
797        } else {
798            paths.push(arg.to_string());
799        }
800        index += 1;
801    }
802    Ok(paths)
803}
804
805/// Remove physical line continuations before either policy tokenizer splits
806/// commands at newlines. A shell removes backslash-newline (and the CRLF form)
807/// before tokenization, so leaving the pair intact fabricates a command boundary
808/// where the shell sees none and can move an outside path into verb position.
809fn join_shell_line_continuations(command: &str) -> String {
810    let mut logical = String::with_capacity(command.len());
811    let mut chars = command.chars().peekable();
812    while let Some(ch) = chars.next() {
813        if ch == '\\' {
814            if chars.peek() == Some(&'\n') {
815                chars.next();
816                continue;
817            }
818            if chars.peek() == Some(&'\r') {
819                let mut lookahead = chars.clone();
820                if lookahead.next() == Some('\r') && lookahead.next() == Some('\n') {
821                    chars.next();
822                    chars.next();
823                    continue;
824                }
825            }
826        }
827        logical.push(ch);
828    }
829    logical
830}
831
832/// Tokenize governed read commands while retaining quoted programs as one
833/// argument. The general policy lexer intentionally splits quoted whitespace
834/// conservatively, but doing that here changes one `sed` script into several
835/// apparent operands and recreates the path false positive this gate avoids.
836/// An empty quote attached to another token is retained as an empty following
837/// operand so `-f""` cannot silently consume the next argument as its source.
838fn governed_shell_segments(command: &str) -> Vec<Vec<String>> {
839    fn finish_token(
840        token: &mut String,
841        started: &mut bool,
842        attached_empty: &mut bool,
843        tokens: &mut Vec<String>,
844    ) {
845        if std::mem::take(started) {
846            tokens.push(std::mem::take(token));
847            if std::mem::take(attached_empty) {
848                tokens.push(String::new());
849            }
850        }
851    }
852    fn finish_segment(
853        token: &mut String,
854        started: &mut bool,
855        attached_empty: &mut bool,
856        tokens: &mut Vec<String>,
857        result: &mut Vec<Vec<String>>,
858    ) {
859        finish_token(token, started, attached_empty, tokens);
860        if !tokens.is_empty() {
861            result.push(std::mem::take(tokens));
862        }
863    }
864
865    let mut result = Vec::new();
866    let mut tokens = Vec::new();
867    let mut token = String::new();
868    let mut token_started = false;
869    let mut attached_empty = false;
870    let mut quote = None;
871    let logical_command = join_shell_line_continuations(command);
872    let mut chars = logical_command.chars().peekable();
873    while let Some(ch) = chars.next() {
874        if let Some((delimiter, start_len, was_attached)) = quote {
875            if ch == delimiter {
876                quote = None;
877                if was_attached && token.len() == start_len {
878                    attached_empty = true;
879                }
880            } else if delimiter == '"' && ch == '\\' {
881                // In double quotes the shell removes a backslash only before
882                // $, `, ", and \. Single quotes preserve every backslash.
883                if chars
884                    .peek()
885                    .is_some_and(|next| matches!(next, '$' | '`' | '"' | '\\'))
886                {
887                    token.push(chars.next().expect("peeked escaped character"));
888                } else {
889                    token.push(ch);
890                }
891                attached_empty = false;
892            } else {
893                token.push(ch);
894                attached_empty = false;
895            }
896            continue;
897        }
898        match ch {
899            // Outside quotes, the shell removes a backslash and treats the
900            // following byte literally before argv reaches the command. Keep
901            // that byte in this token so `\-`, `\/etc`, escaped whitespace,
902            // and escaped separators reach the same policy checks as the
903            // argument the command actually receives. Quoted backslashes are
904            // handled above according to the quote kind because sed/awk
905            // programs depend on escapes that the shell preserves.
906            '\\' => {
907                if let Some(escaped) = chars.next() {
908                    token.push(escaped);
909                } else {
910                    token.push(ch);
911                }
912                token_started = true;
913                attached_empty = false;
914            }
915            '\'' | '"' => {
916                quote = Some((ch, token.len(), token_started));
917                token_started = true;
918            }
919            // A shell newline starts a new command exactly like `;`. Handle it
920            // before generic whitespace or the next line becomes an argument
921            // to the first verb and can bypass that line's path gate.
922            '\n' | '\r' => finish_segment(
923                &mut token,
924                &mut token_started,
925                &mut attached_empty,
926                &mut tokens,
927                &mut result,
928            ),
929            ch if ch.is_whitespace() => finish_token(
930                &mut token,
931                &mut token_started,
932                &mut attached_empty,
933                &mut tokens,
934            ),
935            ';' | ';' | '|' | '&' => {
936                finish_segment(
937                    &mut token,
938                    &mut token_started,
939                    &mut attached_empty,
940                    &mut tokens,
941                    &mut result,
942                );
943                if matches!(ch, '|' | '&') && chars.peek() == Some(&ch) {
944                    chars.next();
945                }
946            }
947            _ => {
948                token.push(ch);
949                token_started = true;
950                attached_empty = false;
951            }
952        }
953    }
954    finish_segment(
955        &mut token,
956        &mut token_started,
957        &mut attached_empty,
958        &mut tokens,
959        &mut result,
960    );
961    result
962}
963
964/// A command that launches the remaining arguments without changing their
965/// meaning. Each carrier owns a different option grammar, so keep the arity in
966/// one table rather than guessing that every option is a flag.
967#[derive(Clone, Copy)]
968struct CommandCarrier {
969    name: &'static str,
970    short_value_options: &'static [char],
971    long_value_options: &'static [&'static str],
972    leading_operands: usize,
973    assignments: bool,
974}
975
976const COMMAND_CARRIERS: &[CommandCarrier] = &[
977    CommandCarrier {
978        name: "env",
979        short_value_options: &['a', 'C', 'S', 'u'],
980        long_value_options: &["--argv0", "--chdir", "--split-string", "--unset"],
981        leading_operands: 0,
982        assignments: true,
983    },
984    CommandCarrier {
985        name: "command",
986        short_value_options: &[],
987        long_value_options: &[],
988        leading_operands: 0,
989        assignments: false,
990    },
991    CommandCarrier {
992        name: "exec",
993        short_value_options: &['a'],
994        long_value_options: &[],
995        leading_operands: 0,
996        assignments: false,
997    },
998    CommandCarrier {
999        name: "nohup",
1000        short_value_options: &[],
1001        long_value_options: &[],
1002        leading_operands: 0,
1003        assignments: false,
1004    },
1005    CommandCarrier {
1006        name: "time",
1007        short_value_options: &['f', 'o'],
1008        long_value_options: &["--format", "--output"],
1009        leading_operands: 0,
1010        assignments: false,
1011    },
1012    CommandCarrier {
1013        name: "nice",
1014        short_value_options: &['n'],
1015        long_value_options: &["--adjustment"],
1016        leading_operands: 0,
1017        assignments: false,
1018    },
1019    CommandCarrier {
1020        name: "caffeinate",
1021        short_value_options: &['t', 'w'],
1022        long_value_options: &[],
1023        leading_operands: 0,
1024        assignments: false,
1025    },
1026    CommandCarrier {
1027        name: "script",
1028        short_value_options: &['B', 'F', 'I', 'O', 'T', 'c', 'm', 't'],
1029        long_value_options: &[
1030            "--command",
1031            "--log-in",
1032            "--log-io",
1033            "--log-out",
1034            "--log-timing",
1035            "--logging-format",
1036        ],
1037        // BSD script accepts `file [command ...]`; the file is the carrier's
1038        // operand, not the launched command.
1039        leading_operands: 1,
1040        assignments: false,
1041    },
1042    CommandCarrier {
1043        name: "xargs",
1044        short_value_options: &['E', 'I', 'J', 'L', 'P', 'R', 'S', 'a', 'd', 'n', 's'],
1045        long_value_options: &[
1046            "--arg-file",
1047            "--delimiter",
1048            "--eof",
1049            "--max-args",
1050            "--max-chars",
1051            "--max-lines",
1052            "--max-procs",
1053            "--replace",
1054        ],
1055        leading_operands: 0,
1056        assignments: false,
1057    },
1058    CommandCarrier {
1059        name: "builtin",
1060        short_value_options: &[],
1061        long_value_options: &[],
1062        leading_operands: 0,
1063        assignments: false,
1064    },
1065    CommandCarrier {
1066        // BusyBox dispatches its first non-option operand as an applet. Expose
1067        // that applet to the same inspectors as a standalone command.
1068        name: "busybox",
1069        short_value_options: &[],
1070        long_value_options: &[],
1071        leading_operands: 0,
1072        assignments: false,
1073    },
1074    CommandCarrier {
1075        name: "sudo",
1076        short_value_options: &['C', 'D', 'R', 'T', 'a', 'g', 'h', 'p', 'r', 't', 'u'],
1077        long_value_options: &[
1078            "--auth-type",
1079            "--chdir",
1080            "--chroot",
1081            "--close-from",
1082            "--command-timeout",
1083            "--group",
1084            "--host",
1085            "--prompt",
1086            "--role",
1087            "--type",
1088            "--user",
1089        ],
1090        leading_operands: 0,
1091        assignments: false,
1092    },
1093    CommandCarrier {
1094        name: "ionice",
1095        short_value_options: &['P', 'c', 'n', 'p', 'u'],
1096        long_value_options: &["--class", "--classdata", "--pgid", "--pid", "--uid"],
1097        leading_operands: 0,
1098        assignments: false,
1099    },
1100    CommandCarrier {
1101        name: "timeout",
1102        short_value_options: &['k', 's'],
1103        long_value_options: &["--kill-after", "--signal"],
1104        // The duration belongs to timeout; the next operand is the command.
1105        leading_operands: 1,
1106        assignments: false,
1107    },
1108];
1109
1110fn executable_name(raw: &str) -> String {
1111    let lower = raw.to_ascii_lowercase();
1112    let name = Path::new(&lower)
1113        .file_name()
1114        .and_then(|name| name.to_str())
1115        .unwrap_or(&lower);
1116    name.strip_suffix(".exe").unwrap_or(name).to_string()
1117}
1118
1119#[derive(Clone, Copy)]
1120struct ShellAssignmentParts<'a> {
1121    name: &'a str,
1122    subscript: Option<&'a str>,
1123    value: &'a str,
1124}
1125
1126fn shell_assignment_parts(token: &str) -> Option<ShellAssignmentParts<'_>> {
1127    let name_length = shell_name_len(token);
1128    if name_length == 0 {
1129        return None;
1130    }
1131    let name = &token[..name_length];
1132    let remainder = &token[name_length..];
1133    let (subscript, operator) = if remainder.starts_with('[') {
1134        let mut depth = 0usize;
1135        let mut close = None;
1136        for (index, ch) in remainder.char_indices() {
1137            match ch {
1138                '[' => depth += 1,
1139                ']' => {
1140                    depth = depth.checked_sub(1)?;
1141                    if depth == 0 {
1142                        close = Some(index);
1143                        break;
1144                    }
1145                }
1146                _ => {}
1147            }
1148        }
1149        let close = close?;
1150        (Some(&remainder[1..close]), &remainder[close + 1..])
1151    } else {
1152        (None, remainder)
1153    };
1154    // Shell append assignments have the same name and safety implications as
1155    // ordinary assignments; `+=` is the operator after either a scalar name or
1156    // an array subscript.
1157    let value = operator
1158        .strip_prefix("+=")
1159        .or_else(|| operator.strip_prefix('='))?;
1160    Some(ShellAssignmentParts {
1161        name,
1162        subscript,
1163        value,
1164    })
1165}
1166
1167fn shell_subscript_contains_command_substitution(subscript: &str) -> bool {
1168    if subscript.contains('`') {
1169        return true;
1170    }
1171    let mut cursor = 0usize;
1172    while let Some(relative) = subscript[cursor..].find("$(") {
1173        let start = cursor + relative;
1174        // `$((...))` is arithmetic expansion, not command substitution. Keep
1175        // scanning its contents because they may themselves contain `$(...)`.
1176        if subscript[start + 2..].starts_with('(') {
1177            cursor = start + 3;
1178        } else {
1179            return true;
1180        }
1181    }
1182    false
1183}
1184
1185/// Why an array-assignment subscript found in the raw command cannot be trusted
1186/// to reach the tokenised assignment check intact.
1187#[derive(Clone, Copy, PartialEq, Eq)]
1188enum SubscriptFlaw {
1189    /// The subscript runs a command, so its value cannot be inspected at all.
1190    CommandSubstitution,
1191    /// The subscript holds characters the deliberately small tokenizer splits
1192    /// on or rewrites, so `NAME[subscript]=value` never reaches
1193    /// `shell_assignment_parts` as a single assignment token.
1194    BreaksTokenization,
1195}
1196
1197/// Classify a raw subscript. A command substitution cannot be inspected at all;
1198/// whitespace, a shell separator, a quote, or a backslash escape hides the
1199/// assignment from the tokenised path in exactly the same way. One rule covers
1200/// both rather than a special case per spelling.
1201fn subscript_flaw(subscript: &str) -> Option<SubscriptFlaw> {
1202    if shell_subscript_contains_command_substitution(subscript) {
1203        return Some(SubscriptFlaw::CommandSubstitution);
1204    }
1205    subscript
1206        .chars()
1207        .any(|ch| ch.is_whitespace() || matches!(ch, ';' | ';' | '|' | '&' | '\'' | '"' | '\\'))
1208        .then_some(SubscriptFlaw::BreaksTokenization)
1209}
1210
1211/// Find the array-assignment subscripts in the raw command that the tokenised
1212/// assignment check cannot see intact. Single-quoted text is inert and is
1213/// skipped. A candidate must begin at a shell word boundary and have a closing
1214/// bracket followed immediately by `=` or `+=`.
1215fn uninspectable_assignment_subscripts(command: &str) -> Vec<(&str, SubscriptFlaw)> {
1216    let bytes = command.as_bytes();
1217    let mut found = Vec::new();
1218    let mut index = 0usize;
1219    let mut quote = None;
1220    let mut word_boundary = true;
1221    while index < bytes.len() {
1222        let byte = bytes[index];
1223        if let Some(delimiter) = quote {
1224            if byte == delimiter {
1225                quote = None;
1226            } else if delimiter == b'"' && byte == b'\\' {
1227                index += usize::from(index + 1 < bytes.len());
1228            }
1229            index += 1;
1230            continue;
1231        }
1232        match byte {
1233            b'\\' => {
1234                index += 1 + usize::from(index + 1 < bytes.len());
1235                word_boundary = false;
1236            }
1237            b'\'' | b'"' => {
1238                quote = Some(byte);
1239                word_boundary = false;
1240                index += 1;
1241            }
1242            b' ' | b'\t' | b'\r' | b'\n' | b';' | b'|' | b'&' | b'(' => {
1243                word_boundary = true;
1244                index += 1;
1245            }
1246            b'_' | b'a'..=b'z' | b'A'..=b'Z' if word_boundary => {
1247                let name_start = index;
1248                index += 1;
1249                while index < bytes.len()
1250                    && matches!(bytes[index], b'_' | b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9')
1251                {
1252                    index += 1;
1253                }
1254                if bytes.get(index) == Some(&b'[') {
1255                    let subscript_start = index + 1;
1256                    let mut close_search = subscript_start;
1257                    while let Some(relative) = command[close_search..].find(']') {
1258                        let close = close_search + relative;
1259                        let after = &command[close + 1..];
1260                        if after.starts_with('=') || after.starts_with("+=") {
1261                            let subscript = &command[subscript_start..close];
1262                            if let Some(flaw) = subscript_flaw(subscript) {
1263                                found.push((&command[name_start..index], flaw));
1264                            }
1265                            break;
1266                        }
1267                        close_search = close + 1;
1268                    }
1269                }
1270                word_boundary = false;
1271            }
1272            _ => {
1273                word_boundary = false;
1274                index += 1;
1275            }
1276        }
1277    }
1278    found
1279}
1280
1281fn shell_assignment(token: &str) -> bool {
1282    shell_assignment_parts(token).is_some()
1283}
1284
1285fn carrier_option_consumes_next(spec: CommandCarrier, option: &str) -> bool {
1286    if spec.long_value_options.contains(&option) {
1287        return true;
1288    }
1289    if option.starts_with("--") {
1290        return false;
1291    }
1292
1293    let mut options = option.strip_prefix('-').unwrap_or("").chars().peekable();
1294    while let Some(option) = options.next() {
1295        if spec.short_value_options.contains(&option) {
1296            // A value attached in the same token consumes the rest of the
1297            // cluster; only a final value-taking option consumes argv[i + 1].
1298            return options.peek().is_none();
1299        }
1300    }
1301    false
1302}
1303
1304struct CarrierInvocation<'a> {
1305    command: &'a [String],
1306    assignments: Vec<&'a str>,
1307}
1308
1309/// Return the command portion of one carrier invocation. `None` means the verb
1310/// is not a carrier; an empty `command` means it is a valid carrier-only
1311/// invocation. Assignments are returned separately so governed sessions can
1312/// inspect the environment that changes the launched command's meaning.
1313fn carrier_command<'a>(
1314    tokens: &'a [String],
1315    verb_index: usize,
1316    verb: &str,
1317) -> Option<CarrierInvocation<'a>> {
1318    let spec = *COMMAND_CARRIERS.iter().find(|spec| spec.name == verb)?;
1319    let args = &tokens[verb_index + 1..];
1320    // `command -v/-V` queries the shell's command table; it does not launch
1321    // the named operand. Treat it as a terminal carrier invocation so asking
1322    // whether (for example) sudo exists does not trigger sudo's policy.
1323    if verb == "command"
1324        && args
1325            .iter()
1326            .take_while(|arg| arg.as_str() != "--")
1327            .any(|arg| {
1328                arg.strip_prefix('-')
1329                    .filter(|short| !short.starts_with('-'))
1330                    .is_some_and(|short| short.contains('v') || short.contains('V'))
1331            })
1332    {
1333        return Some(CarrierInvocation {
1334            command: &[],
1335            assignments: Vec::new(),
1336        });
1337    }
1338    let mut index = 0;
1339    let mut options = true;
1340    let mut assignments = Vec::new();
1341    while index < args.len() {
1342        let arg = args[index].as_str();
1343        if options && arg == "--" {
1344            options = false;
1345            index += 1;
1346            continue;
1347        }
1348        if options && arg.starts_with('-') && arg != "-" {
1349            let consumes_next = !arg.contains('=') && carrier_option_consumes_next(spec, arg);
1350            index += 1 + usize::from(consumes_next && index + 1 < args.len());
1351            continue;
1352        }
1353        if spec.assignments && shell_assignment(arg) {
1354            assignments.push(arg);
1355            index += 1;
1356            continue;
1357        }
1358        break;
1359    }
1360    index = (index + spec.leading_operands).min(args.len());
1361    Some(CarrierInvocation {
1362        command: &args[index..],
1363        assignments,
1364    })
1365}
1366
1367/// Find a command-string flag in a short-option cluster, stopping when an
1368/// option consumes the rest of that token (or the following argument).
1369fn has_short_command_flag(args: &[String], wanted: char, value_options: &[char]) -> bool {
1370    let mut index = 0;
1371    while index < args.len() {
1372        let arg = args[index].as_str();
1373        if arg == "--" || !arg.starts_with('-') || arg == "-" {
1374            break;
1375        }
1376        let Some(short) = arg
1377            .strip_prefix('-')
1378            .filter(|short| !short.starts_with('-'))
1379        else {
1380            index += 1;
1381            continue;
1382        };
1383        let mut options = short.chars().peekable();
1384        while let Some(option) = options.next() {
1385            if option == wanted {
1386                return true;
1387            }
1388            if value_options.contains(&option) {
1389                if options.peek().is_none() {
1390                    index += 1;
1391                }
1392                break;
1393            }
1394        }
1395        index += 1;
1396    }
1397    false
1398}
1399
1400fn shell_has_script_operand(args: &[String]) -> bool {
1401    let mut index = 0;
1402    while index < args.len() {
1403        let arg = args[index].as_str();
1404        if arg == "--" {
1405            return index + 1 < args.len();
1406        }
1407        if !arg.starts_with('-') || arg == "-" {
1408            return true;
1409        }
1410        let consumes_next = matches!(arg, "-O" | "-o" | "--init-file" | "--rcfile");
1411        index += 1 + usize::from(consumes_next && index + 1 < args.len());
1412    }
1413    false
1414}
1415
1416fn nested_command_carrier(verb: &str, args: &[String]) -> bool {
1417    if verb == "eval" {
1418        return true;
1419    }
1420    // env -S asks env to split one opaque argv item into a fresh command line.
1421    // Deny it like an interpreter command string rather than pretending the
1422    // split can be reproduced by this deliberately small tokenizer.
1423    if verb == "env" {
1424        return has_short_command_flag(args, 'S', &['a', 'C', 'S', 'u'])
1425            || args
1426                .iter()
1427                .any(|arg| arg == "--split-string" || arg.starts_with("--split-string="));
1428    }
1429    if matches!(
1430        verb,
1431        "bash" | "sh" | "zsh" | "dash" | "ksh" | "fish" | "ash"
1432    ) {
1433        return has_short_command_flag(args, 'c', &['O', 'o'])
1434            || (verb == "fish"
1435                && (has_short_command_flag(args, 'C', &['C'])
1436                    || args.iter().any(|arg| {
1437                        matches!(arg.as_str(), "--command" | "--init-command")
1438                            || arg.starts_with("--command=")
1439                            || arg.starts_with("--init-command=")
1440                    })))
1441            || !shell_has_script_operand(args);
1442    }
1443    if verb == "script" {
1444        return has_short_command_flag(args, 'c', &['B', 'F', 'I', 'O', 'T', 'c', 'm', 't'])
1445            || args
1446                .iter()
1447                .any(|arg| arg == "--command" || arg.starts_with("--command="));
1448    }
1449    if verb == "perl" {
1450        // Perl's -E is -e with optional features enabled. Stop at options such
1451        // as -M whose attached module name is an argument, not more flags.
1452        const PERL_VALUE_OPTIONS: &[char] =
1453            &['0', 'C', 'D', 'F', 'I', 'M', 'V', 'd', 'i', 'l', 'm', 'x'];
1454        return has_short_command_flag(args, 'e', PERL_VALUE_OPTIONS)
1455            || has_short_command_flag(args, 'E', PERL_VALUE_OPTIONS);
1456    }
1457    if matches!(verb, "ruby" | "node" | "osascript") {
1458        let value_options: &[char] = match verb {
1459            "ruby" => &['0', 'C', 'E', 'F', 'I', 'S', 'i', 'r', 'x'],
1460            "node" => &['C', 'r'],
1461            "osascript" => &['l'],
1462            _ => unreachable!("matched interpreter"),
1463        };
1464        return has_short_command_flag(args, 'e', value_options)
1465            || (verb == "node"
1466                && args
1467                    .iter()
1468                    .any(|arg| arg == "--eval" || arg.starts_with("--eval=")));
1469    }
1470    if verb == "python"
1471        || verb == "py"
1472        || verb == "python3"
1473        || verb
1474            .strip_prefix("python3.")
1475            .is_some_and(|minor| minor.chars().all(|ch| ch.is_ascii_digit()))
1476    {
1477        return has_short_command_flag(args, 'c', &['W', 'X', 'Q', 'm']);
1478    }
1479    false
1480}
1481
1482struct LeadingShellVariable<'a> {
1483    /// Parameter-expansion syntax between the variable name and `}`. Even for
1484    /// an allowlisted name, modifiers can transform the known value and are
1485    /// therefore not inspectable by this deliberately small parser.
1486    modifier: &'a str,
1487    /// Empty for a bare variable operand; otherwise begins with `/`.
1488    suffix: &'a str,
1489}
1490
1491fn shell_name_len(value: &str) -> usize {
1492    let mut chars = value.char_indices();
1493    let Some((_, first)) = chars.next() else {
1494        return 0;
1495    };
1496    if first != '_' && !first.is_ascii_alphabetic() {
1497        return 0;
1498    }
1499    chars
1500        .take_while(|(_, ch)| *ch == '_' || ch.is_ascii_alphanumeric())
1501        .last()
1502        .map_or(first.len_utf8(), |(index, ch)| index + ch.len_utf8())
1503}
1504
1505/// Recognize a shell variable that controls the beginning of a path operand.
1506/// Exact dollar-named repository entries are handled as a narrow literal
1507/// exception by [`variable_operand_denial`].
1508fn leading_shell_variable(candidate: &str) -> Option<LeadingShellVariable<'_>> {
1509    if let Some(rest) = candidate.strip_prefix("${") {
1510        let length = shell_name_len(rest);
1511        if length == 0 {
1512            return None;
1513        }
1514        let mut depth = 1usize;
1515        let mut close = None;
1516        let mut index = length;
1517        while index < rest.len() {
1518            if rest[index..].starts_with("${") {
1519                depth += 1;
1520                index += 2;
1521                continue;
1522            }
1523            let ch = rest[index..].chars().next().expect("index is in bounds");
1524            if ch == '}' {
1525                depth -= 1;
1526                if depth == 0 {
1527                    close = Some(index);
1528                    break;
1529                }
1530            }
1531            index += ch.len_utf8();
1532        }
1533        let close = close?;
1534        let suffix = &rest[close + 1..];
1535        if !suffix.is_empty() && !suffix.starts_with('/') {
1536            return None;
1537        }
1538        return Some(LeadingShellVariable {
1539            modifier: &rest[length..close],
1540            suffix,
1541        });
1542    }
1543
1544    let rest = candidate.strip_prefix('$')?;
1545    let length = shell_name_len(rest);
1546    if length == 0 {
1547        return None;
1548    }
1549    let suffix = &rest[length..];
1550    if !suffix.is_empty() && !suffix.starts_with('/') {
1551        return None;
1552    }
1553    Some(LeadingShellVariable {
1554        modifier: "",
1555        suffix,
1556    })
1557}
1558
1559#[derive(Clone, Copy)]
1560struct ShellVariableReference<'a> {
1561    name: &'a str,
1562    start: usize,
1563    end: usize,
1564}
1565
1566/// Parse every parameter expansion in a prospective path. Only plain `$NAME`
1567/// and `${NAME}` forms are inspectable. Positional/special parameters, command
1568/// substitution, malformed braces, and modifier expansions all fail closed.
1569fn shell_variable_references(candidate: &str) -> Result<Vec<ShellVariableReference<'_>>, ()> {
1570    let mut references = Vec::new();
1571    let mut cursor = 0;
1572    while let Some(relative) = candidate[cursor..].find('$') {
1573        let start = cursor + relative;
1574        let after_dollar = start + 1;
1575        let rest = &candidate[after_dollar..];
1576        if let Some(braced) = rest.strip_prefix('{') {
1577            let length = shell_name_len(braced);
1578            if length == 0 || !braced[length..].starts_with('}') {
1579                return Err(());
1580            }
1581            let end = after_dollar + 1 + length + 1;
1582            references.push(ShellVariableReference {
1583                name: &braced[..length],
1584                start,
1585                end,
1586            });
1587            cursor = end;
1588        } else {
1589            let length = shell_name_len(rest);
1590            if length == 0 {
1591                return Err(());
1592            }
1593            let end = after_dollar + length;
1594            references.push(ShellVariableReference {
1595                name: &rest[..length],
1596                start,
1597                end,
1598            });
1599            cursor = end;
1600        }
1601    }
1602    Ok(references)
1603}
1604
1605fn resolved_shell_variable<'a>(
1606    gate: &'a DenyGovernedShellPathEscape,
1607    name: &str,
1608) -> Option<&'a Path> {
1609    match name {
1610        "PWD" => Some(gate.worktree.as_path()),
1611        "OLDPWD" if gate.oldpwd_is_worktree => Some(gate.worktree.as_path()),
1612        "CARGO_TARGET_DIR" => gate.cargo_target_dir.as_deref(),
1613        _ => None,
1614    }
1615}
1616
1617/// Deny any uninspectable variable in a path operand, whether it is leading or
1618/// follows a fixed path segment. Known variables are expanded from values
1619/// pinned when the gate is constructed, then the resulting path is checked.
1620fn variable_operand_denial(
1621    gate: &DenyGovernedShellPathEscape,
1622    verb: &str,
1623    candidate: &str,
1624) -> Option<String> {
1625    if !candidate.contains('$') {
1626        return None;
1627    }
1628
1629    // The tokenizer intentionally forgets shell quoting. Preserve only the
1630    // existing exact-dollar-name exception; suffix variables are expansions.
1631    if let Some(operand) = leading_shell_variable(candidate) {
1632        if operand.suffix.is_empty()
1633            && operand.modifier.is_empty()
1634            && gate.worktree.join(candidate).exists()
1635            && stays_under(&gate.worktree, candidate)
1636        {
1637            return None;
1638        }
1639    }
1640
1641    let references = match shell_variable_references(candidate) {
1642        Ok(references) if !references.is_empty() => references,
1643        _ => {
1644            return Some(format!(
1645                "'{verb}' variable operand cannot be inspected: '{candidate}'"
1646            ));
1647        }
1648    };
1649    let mut expanded = String::with_capacity(candidate.len());
1650    let mut copied = 0;
1651    for reference in references {
1652        let Some(value) = resolved_shell_variable(gate, reference.name) else {
1653            return Some(format!(
1654                "'{verb}' variable operand cannot be inspected: '{candidate}'"
1655            ));
1656        };
1657        expanded.push_str(&candidate[copied..reference.start]);
1658        expanded.push_str(&value.to_string_lossy());
1659        copied = reference.end;
1660    }
1661    expanded.push_str(&candidate[copied..]);
1662
1663    let expanded_path = Path::new(&expanded);
1664    let stays_governed = if expanded_path.is_absolute() {
1665        stays_under(&gate.worktree, &expanded)
1666            || gate
1667                .cargo_target_dir
1668                .as_deref()
1669                .is_some_and(|root| stays_under(root, &expanded))
1670    } else {
1671        stays_under(&gate.worktree, &expanded)
1672    };
1673    if stays_governed {
1674        None
1675    } else {
1676        Some(format!(
1677            "'{verb}' variable path '{candidate}' resolves outside its governed root"
1678        ))
1679    }
1680}
1681
1682fn governed_operand_prefix_variables(segment: &[String]) -> BTreeSet<String> {
1683    let mut names = BTreeSet::new();
1684    let mut command = segment;
1685    while let Some(verb_index) = command.iter().position(|token| !shell_assignment(token)) {
1686        let verb = executable_name(&command[verb_index]);
1687        if READ_OR_CHDIR_VERBS.contains(&verb.as_str()) {
1688            if let Ok(paths) = governed_path_arguments(&verb, command) {
1689                for path in paths {
1690                    let candidate = path
1691                        .trim_matches(|c: char| matches!(c, '"' | '\'' | '(' | ')' | ',' | ';'));
1692                    if let Ok(references) = shell_variable_references(candidate) {
1693                        if let Some(reference) = references.first().filter(|item| item.start == 0) {
1694                            names.insert(reference.name.to_string());
1695                        }
1696                    }
1697                }
1698            }
1699        }
1700        let Some(invocation) = carrier_command(command, verb_index, &verb) else {
1701            break;
1702        };
1703        if invocation.command.is_empty() {
1704            break;
1705        }
1706        command = invocation.command;
1707    }
1708    names
1709}
1710
1711fn shell_root_escape(candidate: &str) -> bool {
1712    ["HOME", "TMPDIR"].iter().any(|name| {
1713        let unbraced = format!("${name}");
1714        let unbraced_match = candidate.strip_prefix(&unbraced).is_some_and(|suffix| {
1715            suffix
1716                .chars()
1717                .next()
1718                .is_none_or(|ch| ch != '_' && !ch.is_ascii_alphanumeric())
1719        });
1720        let braced = format!("${{{name}");
1721        let braced_match = candidate.strip_prefix(&braced).is_some_and(|suffix| {
1722            suffix
1723                .chars()
1724                .next()
1725                .is_some_and(|ch| ch != '_' && !ch.is_ascii_alphanumeric())
1726        });
1727        unbraced_match || braced_match
1728    })
1729}
1730
1731fn execution_redirecting_variable(name: &str) -> bool {
1732    matches!(
1733        name,
1734        "BASH_ENV"
1735            | "ENV"
1736            | "PATH"
1737            | "LD_PRELOAD"
1738            | "PYTHONPATH"
1739            | "PERL5LIB"
1740            | "RUBYLIB"
1741            | "NODE_OPTIONS"
1742            | "CARGO_HOME"
1743            | "RUSTUP_HOME"
1744            | "GIT_EXEC_PATH"
1745            | "GIT_SSH_COMMAND"
1746    ) || name.starts_with("DYLD_")
1747}
1748
1749fn command_opens_editor(verb: &str, args: &[String]) -> bool {
1750    (verb == "git"
1751        && args.iter().any(|arg| {
1752            matches!(
1753                arg.as_str(),
1754                "add" | "commit" | "config" | "merge" | "rebase" | "tag"
1755            )
1756        }))
1757        || matches!(verb, "crontab" | "vipw" | "vigr" | "visudo")
1758}
1759
1760fn assignment_value_names_path(value: &str) -> bool {
1761    value.contains('/')
1762        || value.contains('\\')
1763        || value.starts_with('~')
1764        || shell_root_escape(value)
1765        || value.contains("..")
1766        || (value.as_bytes().get(1) == Some(&b':')
1767            && value
1768                .as_bytes()
1769                .first()
1770                .is_some_and(u8::is_ascii_alphabetic))
1771}
1772
1773fn effective_command(mut command: &[String]) -> Option<(String, &[String])> {
1774    loop {
1775        let verb_index = command.iter().position(|token| !shell_assignment(token))?;
1776        let current_verb = executable_name(&command[verb_index]);
1777        let args = &command[verb_index + 1..];
1778        let Some(invocation) = carrier_command(command, verb_index, &current_verb) else {
1779            return Some((current_verb, args));
1780        };
1781        if invocation.command.is_empty() {
1782            return Some((current_verb, args));
1783        }
1784        command = invocation.command;
1785    }
1786}
1787
1788struct AssignmentBuiltinOperands<'a> {
1789    assignments: Vec<&'a str>,
1790    removed_names: Vec<&'a str>,
1791}
1792
1793/// Extract assignments handled by shell builtins rather than by the shell's
1794/// leading-assignment grammar. These builtins mutate the current shell, so a
1795/// later command segment observes the assigned value just as it would for a
1796/// bare assignment-only segment.
1797fn assignment_builtin_operands<'a>(
1798    command: &'a [String],
1799    verb_index: usize,
1800    verb: &str,
1801) -> AssignmentBuiltinOperands<'a> {
1802    let args = &command[verb_index + 1..];
1803    let assignment_builtin = matches!(
1804        verb,
1805        "export" | "readonly" | "declare" | "typeset" | "local"
1806    );
1807    let assignments = if assignment_builtin {
1808        args.iter()
1809            .map(String::as_str)
1810            .filter(|arg| shell_assignment(arg))
1811            .collect()
1812    } else {
1813        Vec::new()
1814    };
1815
1816    let removes_export_attribute = verb == "export"
1817        && args
1818            .iter()
1819            .take_while(|arg| arg.as_str() != "--")
1820            .filter_map(|arg| arg.strip_prefix('-'))
1821            .any(|options| !options.starts_with('-') && options.contains('n'));
1822    let removes_variables = verb == "unset" || removes_export_attribute;
1823    let removed_names = if removes_variables {
1824        args.iter()
1825            .skip_while(|arg| arg.starts_with('-') && arg.as_str() != "--")
1826            .filter(|arg| arg.as_str() != "--")
1827            .map(String::as_str)
1828            .filter(|arg| shell_name_len(arg) == arg.len())
1829            .collect()
1830    } else {
1831        Vec::new()
1832    };
1833
1834    AssignmentBuiltinOperands {
1835        assignments,
1836        removed_names,
1837    }
1838}
1839
1840fn assignment_name_denial(
1841    name: &str,
1842    opens_editor: bool,
1843    operand_prefix_variables: &BTreeSet<String>,
1844) -> Option<String> {
1845    if execution_redirecting_variable(name) || (matches!(name, "EDITOR" | "VISUAL") && opens_editor)
1846    {
1847        return Some(format!(
1848            "environment assignment '{name}' may redirect executable code in a governed session"
1849        ));
1850    }
1851    if matches!(
1852        name,
1853        "PWD" | "OLDPWD" | "CARGO_TARGET_DIR" | "HOME" | "TMPDIR" | "IFS" | "PATH"
1854    ) || operand_prefix_variables.contains(name)
1855    {
1856        return Some(format!(
1857            "environment assignment '{name}' may change a governed path operand"
1858        ));
1859    }
1860    None
1861}
1862
1863fn assignment_denial(
1864    worktree: &Path,
1865    command: &[String],
1866    operand_prefix_variables: &BTreeSet<String>,
1867) -> Option<String> {
1868    let opens_editor =
1869        effective_command(command).is_some_and(|(verb, args)| command_opens_editor(&verb, args));
1870    let mut current = command;
1871    loop {
1872        // Assignment-only segments have no verb, but their assignments still
1873        // affect later shell segments and must not disappear from inspection.
1874        let verb_index = current
1875            .iter()
1876            .position(|token| !shell_assignment(token))
1877            .unwrap_or(current.len());
1878        let invocation = if verb_index < current.len() {
1879            let current_verb = executable_name(&current[verb_index]);
1880            carrier_command(current, verb_index, &current_verb)
1881        } else {
1882            None
1883        };
1884        let carrier_assignments = invocation
1885            .as_ref()
1886            .into_iter()
1887            .flat_map(|invocation| invocation.assignments.iter().copied());
1888        let builtin_operands = if verb_index < current.len() {
1889            assignment_builtin_operands(current, verb_index, &executable_name(&current[verb_index]))
1890        } else {
1891            AssignmentBuiltinOperands {
1892                assignments: Vec::new(),
1893                removed_names: Vec::new(),
1894            }
1895        };
1896        let assignments = current[..verb_index]
1897            .iter()
1898            .map(String::as_str)
1899            .chain(carrier_assignments)
1900            .chain(builtin_operands.assignments);
1901        for assignment in assignments {
1902            let Some(parts) = shell_assignment_parts(assignment) else {
1903                continue;
1904            };
1905            if parts
1906                .subscript
1907                .is_some_and(shell_subscript_contains_command_substitution)
1908            {
1909                return Some(format!(
1910                    "environment assignment '{}' has a subscript that cannot be inspected",
1911                    parts.name
1912                ));
1913            }
1914            if let Some(reason) =
1915                assignment_name_denial(parts.name, opens_editor, operand_prefix_variables)
1916            {
1917                return Some(reason);
1918            }
1919            if assignment_value_names_path(parts.value) && !stays_under(worktree, parts.value) {
1920                return Some(format!(
1921                    "environment assignment '{}' names path '{}' outside the governed repository",
1922                    parts.name, parts.value
1923                ));
1924            }
1925        }
1926        for name in builtin_operands.removed_names {
1927            if let Some(reason) =
1928                assignment_name_denial(name, opens_editor, operand_prefix_variables)
1929            {
1930                return Some(reason);
1931            }
1932        }
1933        let Some(invocation) = invocation else {
1934            break;
1935        };
1936        if invocation.command.is_empty() {
1937            break;
1938        }
1939        current = invocation.command;
1940    }
1941    None
1942}
1943
1944/// Apply the assignment rules to an array-element assignment whose subscript
1945/// the tokenised path never sees. A command substitution is uninspectable
1946/// outright; otherwise the base name still gets the protected-name and
1947/// path-prefix checks it would have had if the subscript had survived
1948/// tokenisation, because bash assigns element 0 of a scalar to the scalar
1949/// itself whatever whitespace the subscript carries.
1950fn uninspectable_subscript_denial(
1951    command: &str,
1952    segments: &[Vec<String>],
1953    operand_prefix_variables: &BTreeSet<String>,
1954) -> Option<String> {
1955    let candidates = uninspectable_assignment_subscripts(command);
1956    if candidates.is_empty() {
1957        return None;
1958    }
1959    let opens_editor = segments.iter().any(|segment| {
1960        effective_command(segment).is_some_and(|(verb, args)| command_opens_editor(&verb, args))
1961    });
1962    for (name, flaw) in candidates {
1963        match flaw {
1964            SubscriptFlaw::CommandSubstitution => {
1965                return Some(format!(
1966                    "environment assignment '{name}' has a subscript that cannot be inspected"
1967                ));
1968            }
1969            SubscriptFlaw::BreaksTokenization => {
1970                if let Some(reason) =
1971                    assignment_name_denial(name, opens_editor, operand_prefix_variables)
1972                {
1973                    return Some(reason);
1974                }
1975            }
1976        }
1977    }
1978    None
1979}
1980
1981impl Inspector for DenyGovernedShellPathEscape {
1982    fn name(&self) -> &'static str {
1983        "governed_host.deny_shell_path_escape"
1984    }
1985
1986    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
1987        let Some(cmd) = shell_command(tool, params) else {
1988            return InspectionResult::Allow;
1989        };
1990        let segments = governed_shell_segments(&cmd);
1991        let mut future_operand_prefix_variables = vec![BTreeSet::new(); segments.len()];
1992        let mut suffix_variables = BTreeSet::new();
1993        for (index, segment) in segments.iter().enumerate().rev() {
1994            suffix_variables.extend(governed_operand_prefix_variables(segment));
1995            future_operand_prefix_variables[index] = suffix_variables.clone();
1996        }
1997        // `suffix_variables` now holds every variable used as a path prefix
1998        // anywhere in the command. A subscript that breaks tokenisation also
1999        // destroys the segment the assignment belonged to, so the whole-command
2000        // set is the only honest one to check it against.
2001        if let Some(reason) = uninspectable_subscript_denial(&cmd, &segments, &suffix_variables) {
2002            return InspectionResult::Deny(reason);
2003        }
2004        for (index, seg) in segments.into_iter().enumerate() {
2005            if let Some(reason) = assignment_denial(
2006                &self.worktree,
2007                &seg,
2008                &future_operand_prefix_variables[index],
2009            ) {
2010                return InspectionResult::Deny(reason);
2011            }
2012            let mut command = seg.as_slice();
2013            while let Some(verb_index) = command.iter().position(|token| !shell_assignment(token)) {
2014                let v = executable_name(&command[verb_index]);
2015                let args = &command[verb_index + 1..];
2016                if v == "busybox"
2017                    && args
2018                        .iter()
2019                        .any(|arg| arg == "--install" || arg.starts_with("--install="))
2020                {
2021                    return InspectionResult::Deny(
2022                        "busybox --install may write outside the governed repository".into(),
2023                    );
2024                }
2025                if nested_command_carrier(&v, args) {
2026                    return InspectionResult::Deny(format!(
2027                        "nested shell command through '{v}' cannot be inspected in a governed session"
2028                    ));
2029                }
2030                if READ_OR_CHDIR_VERBS.contains(&v.as_str()) {
2031                    let governed_args = match governed_path_arguments(&v, command) {
2032                        Ok(paths) => paths,
2033                        Err(reason) => return InspectionResult::Deny(reason),
2034                    };
2035                    for arg in governed_args {
2036                        let candidate = arg.trim_matches(|c: char| {
2037                            matches!(c, '"' | '\'' | '(' | ')' | ',' | ';')
2038                        });
2039                        if let Some(reason) = variable_operand_denial(self, &v, candidate) {
2040                            return InspectionResult::Deny(reason);
2041                        }
2042                        let names_path = candidate.starts_with('~')
2043                            || shell_root_escape(candidate)
2044                            || is_abs_or_traversal(candidate)
2045                            || self.worktree.join(candidate).exists();
2046                        if names_path && !stays_under(&self.worktree, candidate) {
2047                            return InspectionResult::Deny(format!(
2048                                "'{v}' path '{candidate}' resolves outside the governed repository"
2049                            ));
2050                        }
2051                    }
2052                }
2053
2054                let Some(invocation) = carrier_command(command, verb_index, &v) else {
2055                    break;
2056                };
2057                if invocation.command.is_empty() {
2058                    break;
2059                }
2060                command = invocation.command;
2061            }
2062        }
2063        InspectionResult::Allow
2064    }
2065}
2066
2067/// Unlike the general coder, governed host mode does not permit file-tool
2068/// reads outside the selected repository either.
2069struct DenyGovernedFilePathEscape {
2070    worktree: PathBuf,
2071}
2072
2073impl Inspector for DenyGovernedFilePathEscape {
2074    fn name(&self) -> &'static str {
2075        "governed_host.deny_file_path_escape"
2076    }
2077
2078    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2079        if !matches!(
2080            tool,
2081            "read_file" | "write_file" | "edit_file" | "grep_files"
2082        ) {
2083            return InspectionResult::Allow;
2084        }
2085        let Some(path) = params.get("path").and_then(Value::as_str) else {
2086            return InspectionResult::Allow;
2087        };
2088        if stays_under(&self.worktree, path) {
2089            InspectionResult::Allow
2090        } else {
2091            InspectionResult::Deny(format!(
2092                "file access to '{path}' resolves outside the governed repository"
2093            ))
2094        }
2095    }
2096}
2097
2098/// Host hardening for the governed supervised assistant. Unlike a coder
2099/// worktree, this workflow may perform one explicitly approved normal push;
2100/// remote reconfiguration, force pushes, history rewrite, credential access,
2101/// and path escape remain unconditional denials.
2102pub fn governed_host_inspector_chain(worktree: &Path) -> InspectorChain {
2103    InspectorChain::new()
2104        .with(Box::new(DenyGuiShellAutomation))
2105        .with(Box::new(DenyForcePushAndRemoteReconfiguration))
2106        .with(Box::new(DenyBroadGitStage))
2107        .with(Box::new(DenyHistoryRewrite))
2108        .with(Box::new(DenyPrivilegeEscalation))
2109        .with(Box::new(DenyCredentialAccess))
2110        .with(Box::new(DenyEnvironmentRepair))
2111        .with(Box::new(DenyDestructiveOutsideWorktree {
2112            worktree: worktree.to_path_buf(),
2113        }))
2114        .with(Box::new(DenyGovernedShellPathEscape::new(worktree)))
2115        .with(Box::new(DenyGovernedFilePathEscape {
2116            worktree: worktree.to_path_buf(),
2117        }))
2118}
2119
2120/// A governed engineering session has a first-class host shell. Driving a
2121/// terminal (or a PowerShell window) through desktop automation would bypass
2122/// repository scoping, action classification, gate checks, and receipts.
2123struct DenyGuiShellAutomation;
2124
2125impl Inspector for DenyGuiShellAutomation {
2126    fn name(&self) -> &'static str {
2127        "governed_host.deny_gui_shell_automation"
2128    }
2129
2130    fn inspect(&self, tool: &str, _params: &Value) -> InspectionResult {
2131        if matches!(tool, "run_applescript" | "run_powershell") {
2132            InspectionResult::Deny(
2133                "desktop-driven shell execution is not allowed; use the governed shell tool".into(),
2134            )
2135        } else {
2136            InspectionResult::Allow
2137        }
2138    }
2139}
2140
2141/// Preserve unrelated dirty-checkout changes by requiring explicit paths at
2142/// the staging boundary. Targeted `git add path` remains available.
2143struct DenyBroadGitStage;
2144
2145impl Inspector for DenyBroadGitStage {
2146    fn name(&self) -> &'static str {
2147        "governed_host.deny_broad_git_stage"
2148    }
2149
2150    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2151        let Some(cmd) = shell_command(tool, params) else {
2152            return InspectionResult::Allow;
2153        };
2154        for seg in segments(&cmd) {
2155            if verb(&seg) != Some("git") {
2156                continue;
2157            }
2158            let add = seg.iter().position(|token| token == "add");
2159            if let Some(index) = add {
2160                if seg
2161                    .iter()
2162                    .skip(index + 1)
2163                    .any(|token| matches!(token.as_str(), "." | "-A" | "--all" | "-u" | "--update"))
2164                {
2165                    return InspectionResult::Deny(
2166                        "broad git staging is not allowed; name only the files changed for this task"
2167                            .into(),
2168                    );
2169                }
2170            }
2171            if seg.iter().any(|token| token == "commit")
2172                && seg.iter().any(|token| {
2173                    token == "--all"
2174                        || token
2175                            .strip_prefix('-')
2176                            .filter(|short| !short.starts_with('-'))
2177                            .is_some_and(|short| short.contains('a'))
2178                })
2179            {
2180                return InspectionResult::Deny(
2181                    "git commit -a is not allowed; stage only explicit task files".into(),
2182                );
2183            }
2184        }
2185        InspectionResult::Allow
2186    }
2187}
2188
2189/// Lexically resolve `candidate` against `root` and decide whether it stays
2190/// under `root`. Purely lexical (`..` popping) — symlinks inside the worktree
2191/// are out of scope here, consistent with the hardening-not-sandbox stance.
2192pub(crate) fn stays_under(root: &Path, candidate: &str) -> bool {
2193    // Shells expand leading `~`/`~user` and HOME/TMPDIR references before
2194    // invoking the command. Do not reinterpret those spellings as literal
2195    // directories under the repo; direct file tools use this helper too and
2196    // must keep the same boundary.
2197    if candidate.starts_with('~') || shell_root_escape(candidate) {
2198        return false;
2199    }
2200    // A variable-shaped path is not safely repository-relative merely because
2201    // its dollar sign is a legal filename byte. Preserve only an exact,
2202    // already-existing literal entry such as `$HOME_fixture`; slash-prefixed
2203    // suffixes and absent bare names are shell expansion, not repo paths.
2204    if let Some(variable) = leading_shell_variable(candidate) {
2205        if !variable.suffix.is_empty()
2206            || !variable.modifier.is_empty()
2207            || !root.join(candidate).exists()
2208        {
2209            return false;
2210        }
2211    }
2212    let p = Path::new(candidate);
2213    let joined = if p.is_absolute() {
2214        p.to_path_buf()
2215    } else {
2216        root.join(p)
2217    };
2218    // Existing paths get a filesystem-authoritative check first. This closes
2219    // the symlink escape (`repo/link -> /outside`, then `read_file link/x`)
2220    // that a purely lexical `..` clamp cannot see. Prospective writes fall
2221    // through to the lexical check; their nearest existing ancestor is also
2222    // checked by the governed RepositoryScope before host binding.
2223    if joined.exists() {
2224        if let (Ok(real_root), Ok(real_candidate)) = (root.canonicalize(), joined.canonicalize()) {
2225            return path_starts_with(&real_candidate, &real_root);
2226        }
2227        return false;
2228    }
2229    if let Ok(real_root) = root.canonicalize() {
2230        let mut ancestor = joined.as_path();
2231        while !ancestor.exists() {
2232            let Some(parent) = ancestor.parent() else {
2233                return false;
2234            };
2235            ancestor = parent;
2236        }
2237        match ancestor.canonicalize() {
2238            Ok(real_ancestor) if path_starts_with(&real_ancestor, &real_root) => {}
2239            _ => return false,
2240        }
2241    }
2242    let mut stack: Vec<Component> = Vec::new();
2243    for c in joined.components() {
2244        match c {
2245            Component::CurDir => {}
2246            Component::ParentDir => {
2247                if stack.pop().is_none() {
2248                    return false;
2249                }
2250            }
2251            other => stack.push(other),
2252        }
2253    }
2254    let normalized: PathBuf = stack.iter().collect();
2255    path_starts_with(&normalized, root)
2256}
2257
2258/// Component-boundary prefix test. On Unix this is `Path::starts_with`. On
2259/// Windows it additionally strips the `\\?\` verbatim prefix (which
2260/// `Path::canonicalize` adds to the worktree root but a model-supplied absolute
2261/// path lacks) and folds case (NTFS is case-insensitive), so a legitimate
2262/// absolute write inside the worktree isn't spuriously denied.
2263#[cfg(not(windows))]
2264fn path_starts_with(path: &Path, base: &Path) -> bool {
2265    path.starts_with(base)
2266}
2267
2268#[cfg(windows)]
2269fn path_starts_with(path: &Path, base: &Path) -> bool {
2270    fn key(p: &Path) -> String {
2271        let s = p.to_string_lossy().into_owned();
2272        let s = if let Some(r) = s.strip_prefix(r"\\?\UNC\") {
2273            format!(r"\\{r}")
2274        } else if let Some(r) = s.strip_prefix(r"\\?\") {
2275            r.to_string()
2276        } else {
2277            s
2278        };
2279        s.replace('/', "\\").to_ascii_lowercase()
2280    }
2281    let base_key = key(base);
2282    let base_trim = base_key.trim_end_matches('\\');
2283    let path_key = key(path);
2284    path_key == base_trim || path_key.starts_with(&format!("{base_trim}\\"))
2285}
2286
2287/// True when a shell argument names an absolute path (POSIX `/…`, Windows
2288/// `C:\…` / `\\server\…`) or contains a `..` traversal — i.e. the argument may
2289/// point outside the worktree and must be checked against [`stays_under`].
2290/// The old code tested only `starts_with('/')`, which never matches a Windows
2291/// absolute path, so `del C:\…` slipped past the destructive-op guard.
2292fn is_abs_or_traversal(arg: &str) -> bool {
2293    arg.starts_with('/')
2294        || arg.starts_with('\\')
2295        || arg.contains("..")
2296        || Path::new(arg).is_absolute()
2297}
2298
2299/// True for a Windows `cmd` switch like `/q`, `/s`, `/f` — a leading `/`
2300/// followed by one or two alphanumerics and nothing else. Distinguished from a
2301/// POSIX absolute path (`/etc`, `/wt/...`), which is longer or contains another
2302/// separator. Only ever true on Windows, so Unix argument handling (where a
2303/// leading `/` is always a path) is unchanged.
2304fn is_windows_switch(arg: &str) -> bool {
2305    #[cfg(not(windows))]
2306    {
2307        let _ = arg;
2308        false
2309    }
2310    #[cfg(windows)]
2311    {
2312        arg.strip_prefix('/')
2313            .map(|rest| {
2314                (1..=2).contains(&rest.len()) && rest.chars().all(|c| c.is_ascii_alphanumeric())
2315            })
2316            .unwrap_or(false)
2317    }
2318}
2319
2320/// Split a shell command into segments at unquoted-ish separators and each
2321/// segment into whitespace tokens. Naive on purpose (no quote handling): a
2322/// quoted `";"` may split a segment too eagerly, which only ever makes the
2323/// chain MORE likely to deny — never less. Carrier invocations additionally
2324/// yield each recursively launched command, while retaining the outer command
2325/// so a rule that governs the carrier itself (notably `sudo`) still fires.
2326fn segments(command: &str) -> Vec<Vec<String>> {
2327    let outer: Vec<Vec<String>> = join_shell_line_continuations(command)
2328        .replace("&&", "\n")
2329        .replace("||", "\n")
2330        .replace([';', ';', '|'], "\n")
2331        .lines()
2332        .map(|seg| {
2333            seg.split_whitespace()
2334                .map(|t| t.trim_matches(|c| c == '"' || c == '\'').to_string())
2335                .filter(|t| !t.is_empty())
2336                .collect::<Vec<_>>()
2337        })
2338        .filter(|toks: &Vec<String>| !toks.is_empty())
2339        .collect();
2340
2341    let mut expanded = Vec::new();
2342    for segment in outer {
2343        expanded.push(segment.clone());
2344        let mut command = segment.as_slice();
2345        while let Some(verb_index) = command.iter().position(|token| !shell_assignment(token)) {
2346            let v = executable_name(&command[verb_index]);
2347            let Some(invocation) = carrier_command(command, verb_index, &v) else {
2348                break;
2349            };
2350            if invocation.command.is_empty() {
2351                break;
2352            }
2353            expanded.push(invocation.command.to_vec());
2354            command = invocation.command;
2355        }
2356    }
2357    expanded
2358}
2359
2360/// First non-env-assignment token of a segment (`FOO=bar cmd …` → `cmd`).
2361fn verb(tokens: &[String]) -> Option<&str> {
2362    tokens.iter().map(String::as_str).find(|t| !t.contains('='))
2363}
2364
2365fn shell_command(tool: &str, params: &Value) -> Option<String> {
2366    if tool != "shell" {
2367        return None;
2368    }
2369    params
2370        .get("command")
2371        .and_then(Value::as_str)
2372        .map(str::to_string)
2373}
2374
2375/// `git push`, `git remote add/set-url`, `git fetch --force` — the coder's
2376/// output leaves the machine only via the approved merge branch.
2377struct DenyGitRemoteMutation;
2378
2379impl Inspector for DenyGitRemoteMutation {
2380    fn name(&self) -> &'static str {
2381        "coder.deny_git_remote_mutation"
2382    }
2383
2384    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2385        let Some(cmd) = shell_command(tool, params) else {
2386            return InspectionResult::Allow;
2387        };
2388        for seg in segments(&cmd) {
2389            let is_git = verb(&seg) == Some("git");
2390            if !is_git {
2391                continue;
2392            }
2393            if seg.iter().any(|t| t == "push") {
2394                return InspectionResult::Deny(
2395                    "git push is not allowed from a coder session — results are delivered \
2396                     via the approved local branch"
2397                        .into(),
2398                );
2399            }
2400            if seg.iter().any(|t| t == "remote")
2401                && seg
2402                    .iter()
2403                    .any(|t| t == "add" || t == "set-url" || t == "remove")
2404            {
2405                return InspectionResult::Deny("mutating git remotes is not allowed".into());
2406            }
2407        }
2408        InspectionResult::Allow
2409    }
2410}
2411
2412/// Publication by any route other than the approved merge branch. `git push`
2413/// is denied above, but a forge CLI reaches the world without touching git:
2414/// `gh pr create` opens a pull request, `gh release create` ships a release,
2415/// `gh api --method DELETE` edits branch protection, and `gh auth token`
2416/// prints the credential that does all three. The property the coder's gates
2417/// claim is "work leaves the worktree only through `coder.approve_merge`", and
2418/// one extra binary was enough to break it (car#1074).
2419///
2420/// Shape, deliberately mixed. For the forge CLIs (`gh`, `glab`, `hub`) this is
2421/// an **allowlist**: the read-only subcommands are a small finite set while the
2422/// mutating ones are not, so a `gh` verb nobody has vetted arrives denied. For
2423/// package registries it is a short **blacklist** of publish subcommands,
2424/// because the surrounding verbs (`npm`, `cargo`, `docker`) are ordinary build
2425/// tools a task legitimately runs. An unparseable forge invocation falls to
2426/// Deny, which is the safe side — the model gets a reason, not a silent push.
2427///
2428/// Out of scope on purpose: `aws`, `kubectl`, `terraform`, `gcloud`. Those are
2429/// cloud/infra mutation rather than publishing *this repo's work*, the blast
2430/// radius of a false denial is larger, and the honest fix for them is an
2431/// allowlist over network-reaching verbs (car#1074 option 2) rather than one
2432/// more name on a blacklist. A shell alias or a script that wraps `gh` still
2433/// reaches the binary — hardening, not a sandbox. Prefix carriers are unwrapped
2434/// by [`segments`], but aliases and wrapper scripts remain outside this
2435/// matcher's reach.
2436struct DenyForgePublication;
2437
2438/// Forge CLIs: anything not on [`FORGE_READS`] is denied.
2439const FORGE_VERBS: &[&str] = &["gh", "glab", "hub"];
2440
2441/// (group, allowed subcommands) for a forge CLI. An empty subcommand list
2442/// allows the whole group.
2443const FORGE_READS: &[(&str, &[&str])] = &[
2444    ("pr", &["view", "list", "diff", "checks", "status"]),
2445    ("mr", &["view", "list", "diff", "checks", "status"]),
2446    ("issue", &["view", "list"]),
2447    ("repo", &["view"]),
2448    ("run", &["view", "list", "watch"]),
2449    ("release", &["view", "list"]),
2450    ("workflow", &["view", "list"]),
2451    ("label", &["list"]),
2452    ("cache", &["list"]),
2453    ("gist", &["view", "list"]),
2454    ("auth", &["status"]),
2455    ("search", &[]),
2456    ("status", &[]),
2457    ("version", &[]),
2458];
2459
2460/// Global flags that take a separate value, so the value isn't mistaken for
2461/// the subcommand group (`gh --repo o/r pr view` → group `pr`, not `o/r`).
2462const FORGE_VALUE_FLAGS: &[&str] = &["-r", "--repo", "--hostname"];
2463
2464/// Registry/artifact publication, matched as (verb, first operand).
2465const PUBLICATION_COMMANDS: &[(&str, &[&str])] = &[
2466    ("npm", &["publish"]),
2467    ("pnpm", &["publish"]),
2468    ("yarn", &["publish"]),
2469    ("cargo", &["publish"]),
2470    ("gem", &["push"]),
2471    ("twine", &["upload"]),
2472    ("docker", &["push", "login"]),
2473];
2474
2475impl Inspector for DenyForgePublication {
2476    fn name(&self) -> &'static str {
2477        "coder.deny_forge_publication"
2478    }
2479
2480    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2481        let Some(cmd) = shell_command(tool, params) else {
2482            return InspectionResult::Allow;
2483        };
2484        for seg in segments(&cmd) {
2485            let Some(v) = verb(&seg) else { continue };
2486            // Match on the program NAME, as `DenyEnvironmentRepair` does:
2487            // `/opt/homebrew/bin/gh` is the same action as the bare verb.
2488            let v = Path::new(&v.to_ascii_lowercase())
2489                .file_name()
2490                .map(|f| f.to_string_lossy().into_owned())
2491                .unwrap_or_default();
2492            let v = v.strip_suffix(".exe").unwrap_or(&v).to_string();
2493            let args: Vec<String> = seg.iter().skip(1).map(|a| a.to_ascii_lowercase()).collect();
2494
2495            if FORGE_VERBS.contains(&v.as_str()) {
2496                if let Some(reason) = forge_denial(&v, &args) {
2497                    return InspectionResult::Deny(reason);
2498                }
2499                continue;
2500            }
2501
2502            for (mgr, subs) in PUBLICATION_COMMANDS {
2503                if v != *mgr {
2504                    continue;
2505                }
2506                if leading_operands(&args).iter().any(|sub| subs.contains(sub)) {
2507                    return InspectionResult::Deny(format!(
2508                        "'{mgr}' publication is not allowed from a coder session — results \
2509                         leave the worktree only through the approved merge branch"
2510                    ));
2511                }
2512            }
2513        }
2514        InspectionResult::Allow
2515    }
2516}
2517
2518/// Positional operands of a forge invocation, in order, with the values of the
2519/// known value-taking global flags skipped.
2520fn forge_operands(args: &[String]) -> Vec<&str> {
2521    let mut operands = Vec::new();
2522    let mut skip_value = false;
2523    for arg in args {
2524        if std::mem::take(&mut skip_value) {
2525            continue;
2526        }
2527        if FORGE_VALUE_FLAGS.contains(&arg.as_str()) {
2528            skip_value = true;
2529            continue;
2530        }
2531        if arg.starts_with('-') {
2532            continue;
2533        }
2534        operands.push(arg.as_str());
2535    }
2536    operands
2537}
2538
2539/// The leading positional operands of a command, enough to find a subcommand
2540/// that ordinary leading tokens have pushed out of first place.
2541///
2542/// Reading only the FIRST operand missed three everyday spellings, each of
2543/// which reaches a registry:
2544///
2545/// - `cargo +stable publish` — the rustup toolchain selector is an operand
2546/// - `docker image push img` — the canonical modern form, subcommand in a group
2547/// - `npm --workspace x publish` — the flag's VALUE lands in first place
2548///
2549/// A `+toolchain` selector is dropped outright, and the next two operands are
2550/// returned so a group + subcommand pair is visible.
2551///
2552/// The trade-off is deliberate and worth stating: scanning two operands can
2553/// over-match a flag value (`cargo build --features publish` would be denied).
2554/// This half of the chain is a deny-list over registries, so it is defence in
2555/// depth rather than the boundary — a false positive is a red check with an
2556/// explicit reason, while a false negative publishes a package.
2557fn leading_operands(args: &[String]) -> Vec<&str> {
2558    args.iter()
2559        .map(String::as_str)
2560        .filter(|a| !a.starts_with('-') && !a.starts_with('+'))
2561        .take(2)
2562        .collect()
2563}
2564
2565/// `Some(reason)` when this forge invocation is not on the read-only
2566/// allowlist. Everything unrecognised — a new subcommand, a group with no
2567/// subcommand, an argument shape this matcher cannot read — comes back denied.
2568fn forge_denial(verb: &str, args: &[String]) -> Option<String> {
2569    const BLOCKED: &str = "publishing from a coder session is not allowed — the runtime opens \
2570                           the pull request after `coder.approve_merge`";
2571
2572    // `--version`/`--help` are flags, not operands, so read them off the raw
2573    // argument list before operand filtering drops them.
2574    //
2575    // `-h` is NOT in this set, and must not be. It was, and it made the whole
2576    // inspector an allow-all: pflag consumes the next token as a string flag's
2577    // value even when it starts with a dash, so `gh release create v9 --notes -h`
2578    // and `gh pr create --title -h --body b --head x --base main` both reached
2579    // this and returned None before the group was ever read. The premise was
2580    // wrong on its own terms too — in `gh auth status`, `-h` is `--hostname`.
2581    //
2582    // Matching only the long forms costs a coder nothing: `gh --help` still
2583    // works, and a denied `-h` is one retry away from the spelling that does.
2584    if args
2585        .iter()
2586        .any(|a| matches!(a.as_str(), "--version" | "--help"))
2587    {
2588        return None;
2589    }
2590    let operands = forge_operands(args);
2591    let Some(group) = operands.first().copied() else {
2592        return None; // bare `gh` prints usage
2593    };
2594
2595    // `gh api` defaults to GET; an explicit non-GET method, or a field/input
2596    // flag (which implicitly switches it to POST), makes it a write.
2597    if group == "api" {
2598        // Every one of these must match the ATTACHED forms too. pflag accepts
2599        // `-XPOST` and `--field=k=v` exactly as it accepts the separated
2600        // spellings, so a matcher that only reads two-token pairs and exact
2601        // flag names lets `gh api -XPOST repos/O/R/pulls --input=-` straight
2602        // through — a pull request opened past the gate.
2603        let is_write_method = |v: &str| !v.is_empty() && v != "get";
2604        let explicit_method = args
2605            .windows(2)
2606            .any(|pair| matches!(pair[0].as_str(), "--method" | "-x") && is_write_method(&pair[1]))
2607            || args.iter().any(|a| {
2608                a.strip_prefix("--method=")
2609                    .or_else(|| a.strip_prefix("-x"))
2610                    .is_some_and(is_write_method)
2611            });
2612        // NB: args are lowercased, so `-f` covers `gh api -F` too.
2613        let field_flag = |a: &String| {
2614            matches!(a.as_str(), "-f" | "--field" | "--raw-field" | "--input")
2615                || a.starts_with("--field=")
2616                || a.starts_with("--raw-field=")
2617                || a.starts_with("--input=")
2618                || a.starts_with("-f")
2619        };
2620        // A field alone is not a write: read-only GraphQL REQUIRES `-f query=`,
2621        // and denying that while allowing `--field=query=mutation{…}` had the
2622        // detector inverted on the single endpoint where it matters most. What
2623        // makes a GraphQL call a write is the operation, not the flag shape.
2624        let graphql = operands.get(1).is_some_and(|o| *o == "graphql");
2625        let mutating_graphql = graphql
2626            && args
2627                .iter()
2628                .any(|a| a.contains("mutation") || a.contains("deletion"));
2629        let implicit_post = !graphql && args.iter().any(field_flag);
2630        return (explicit_method || implicit_post || mutating_graphql)
2631            .then(|| format!("'{verb} api' with a write method is not allowed — {BLOCKED}"));
2632    }
2633
2634    // `gh auth status` is a read — except with `-t`/`--show-token`, which
2635    // PRINTS THE TOKEN. #1074 named `gh auth token` as the credential leak and
2636    // this allowlist quietly kept the other spelling of it. With the token in
2637    // hand the whole forge matcher is moot: `curl -X POST -H "Authorization:
2638    // bearer $T" .../pulls` has verb `curl` and is inspected by nothing.
2639    if group == "auth"
2640        && args
2641            .iter()
2642            .any(|a| a == "-t" || a == "--show-token" || a.starts_with("--show-token="))
2643    {
2644        return Some(format!(
2645            "'{verb} auth status --show-token' prints the forge credential — {BLOCKED}"
2646        ));
2647    }
2648
2649    let Some((_, subs)) = FORGE_READS.iter().find(|(g, _)| *g == group) else {
2650        return Some(format!("'{verb} {group}' is not allowed — {BLOCKED}"));
2651    };
2652    if subs.is_empty() {
2653        return None;
2654    }
2655    match operands.get(1).copied() {
2656        Some(sub) if subs.contains(&sub) => None,
2657        Some(sub) => Some(format!("'{verb} {group} {sub}' is not allowed — {BLOCKED}")),
2658        // `gh pr` alone only prints usage, but a missing subcommand is exactly
2659        // the parse ambiguity to fail closed on.
2660        None => Some(format!(
2661            "'{verb} {group}' without a read-only subcommand is not allowed — {BLOCKED}"
2662        )),
2663    }
2664}
2665
2666/// The governed assistant may perform an approved ordinary push, but never a
2667/// force push or remote-configuration mutation.
2668struct DenyForcePushAndRemoteReconfiguration;
2669
2670impl Inspector for DenyForcePushAndRemoteReconfiguration {
2671    fn name(&self) -> &'static str {
2672        "governed_host.deny_force_push_and_remote_reconfiguration"
2673    }
2674
2675    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2676        let Some(cmd) = shell_command(tool, params) else {
2677            return InspectionResult::Allow;
2678        };
2679        for seg in segments(&cmd) {
2680            if verb(&seg) != Some("git") {
2681                continue;
2682            }
2683            let push = seg.iter().any(|token| token == "push");
2684            let forced = seg.iter().any(|token| {
2685                token == "--force"
2686                    || token == "-f"
2687                    || token.starts_with("--force-with-lease")
2688                    || token.starts_with('+')
2689            });
2690            if push && forced {
2691                return InspectionResult::Deny("force-push is never allowed".into());
2692            }
2693            if seg.iter().any(|token| token == "remote")
2694                && seg.iter().any(|token| {
2695                    token == "add" || token == "set-url" || token == "remove" || token == "rename"
2696                })
2697            {
2698                return InspectionResult::Deny(
2699                    "mutating git remote configuration is not allowed".into(),
2700                );
2701            }
2702        }
2703        InspectionResult::Allow
2704    }
2705}
2706
2707/// `git rebase/reset --hard/filter-branch` — the worktree HEAD is detached;
2708/// history rewrite is never needed and only ever destroys evidence.
2709struct DenyHistoryRewrite;
2710
2711impl Inspector for DenyHistoryRewrite {
2712    fn name(&self) -> &'static str {
2713        "coder.deny_history_rewrite"
2714    }
2715
2716    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2717        let Some(cmd) = shell_command(tool, params) else {
2718            return InspectionResult::Allow;
2719        };
2720        for seg in segments(&cmd) {
2721            if verb(&seg) != Some("git") {
2722                continue;
2723            }
2724            if seg.iter().any(|t| t == "rebase" || t == "filter-branch") {
2725                return InspectionResult::Deny("git history rewrite is not allowed".into());
2726            }
2727            if seg.iter().any(|t| t == "reset") && seg.iter().any(|t| t == "--hard") {
2728                return InspectionResult::Deny("git reset --hard is not allowed".into());
2729            }
2730            if seg.iter().any(|t| t == "worktree") && seg.iter().any(|t| t == "remove") {
2731                return InspectionResult::Deny(
2732                    "removing worktrees is the runtime's job, not the agent's".into(),
2733                );
2734            }
2735        }
2736        InspectionResult::Allow
2737    }
2738}
2739
2740/// `sudo`/`doas`/service managers — the coder runs with user privileges, full
2741/// stop.
2742struct DenyPrivilegeEscalation;
2743
2744const PRIVILEGE_VERBS: &[&str] = &[
2745    // POSIX
2746    "sudo",
2747    "doas",
2748    "su",
2749    "launchctl",
2750    "systemctl", //
2751    // Windows privilege elevation / service control.
2752    "runas",
2753    "sc",
2754    "psexec",
2755];
2756
2757impl Inspector for DenyPrivilegeEscalation {
2758    fn name(&self) -> &'static str {
2759        "coder.deny_privilege_escalation"
2760    }
2761
2762    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2763        let Some(cmd) = shell_command(tool, params) else {
2764            return InspectionResult::Allow;
2765        };
2766        for seg in segments(&cmd) {
2767            if let Some(v) = verb(&seg) {
2768                if PRIVILEGE_VERBS.contains(&v.to_ascii_lowercase().as_str()) {
2769                    return InspectionResult::Deny(format!(
2770                        "'{v}' is not allowed in a coder session"
2771                    ));
2772                }
2773            }
2774        }
2775        InspectionResult::Allow
2776    }
2777}
2778
2779/// Reads of key stores and credential directories, via shell or file tools.
2780struct DenyCredentialAccess;
2781
2782const CREDENTIAL_PATH_MARKERS: [&str; 6] = [
2783    "/.ssh",
2784    "/.aws",
2785    "/.gnupg",
2786    "/.kube",
2787    "/.car/secrets",
2788    "/.netrc",
2789];
2790
2791impl Inspector for DenyCredentialAccess {
2792    fn name(&self) -> &'static str {
2793        "coder.deny_credential_access"
2794    }
2795
2796    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2797        let haystacks: Vec<String> = if let Some(cmd) = shell_command(tool, params) {
2798            if cmd.contains("find-generic-password") || cmd.contains("find-internet-password") {
2799                return InspectionResult::Deny("keychain access is not allowed".into());
2800            }
2801            // Windows Credential Manager / DPAPI vault tooling.
2802            let cmd_lower = cmd.to_ascii_lowercase();
2803            if cmd_lower.contains("cmdkey") || cmd_lower.contains("vaultcmd") {
2804                return InspectionResult::Deny(
2805                    "Windows Credential Manager access is not allowed".into(),
2806                );
2807            }
2808            let sensitive_env = [
2809                "_key",
2810                "_token",
2811                "_secret",
2812                "_password",
2813                "openai_",
2814                "anthropic_",
2815                "azure_client_",
2816                "github_token",
2817                "connection_string",
2818            ];
2819            if sensitive_env
2820                .iter()
2821                .any(|marker| cmd_lower.contains(marker))
2822            {
2823                return InspectionResult::Deny(
2824                    "reading or expanding credential environment variables is not allowed".into(),
2825                );
2826            }
2827            for seg in segments(&cmd) {
2828                let Some(command) = verb(&seg).map(|value| value.to_ascii_lowercase()) else {
2829                    continue;
2830                };
2831                if command == "env" && seg.len() == 1
2832                    || command == "printenv"
2833                    || command == "set" && seg.len() == 1
2834                {
2835                    return InspectionResult::Deny(
2836                        "dumping the process environment is not allowed".into(),
2837                    );
2838                }
2839            }
2840            vec![cmd]
2841        } else if matches!(
2842            tool,
2843            "read_file" | "write_file" | "edit_file" | "grep_files"
2844        ) {
2845            params
2846                .get("path")
2847                .and_then(Value::as_str)
2848                .map(|p| vec![p.to_string()])
2849                .unwrap_or_default()
2850        } else {
2851            return InspectionResult::Allow;
2852        };
2853        for hay in &haystacks {
2854            // Normalize Windows separators and the various home spellings
2855            // ("~/.ssh", "$HOME/.ssh", "%USERPROFILE%\.ssh") into the same
2856            // forward-slash marker space as POSIX absolute paths.
2857            let hay = hay.replace('\\', "/");
2858            let hay = hay
2859                .replace("~/", "/HOME/.")
2860                .replace("$HOME/", "/HOME/.")
2861                .replace("%USERPROFILE%/", "/HOME/.")
2862                .replace("%HOMEPATH%/", "/HOME/.");
2863            let hay = hay.replace("/HOME/..", "/."); // "~/.ssh" → "/.ssh"
2864            for marker in CREDENTIAL_PATH_MARKERS {
2865                if hay.contains(marker) {
2866                    return InspectionResult::Deny(format!(
2867                        "access to credential path matching '{marker}' is not allowed"
2868                    ));
2869                }
2870            }
2871        }
2872        InspectionResult::Allow
2873    }
2874}
2875
2876/// Destructive shell verbs aimed outside the worktree (absolute paths, `..`
2877/// escapes, `~`).
2878struct DenyDestructiveOutsideWorktree {
2879    worktree: PathBuf,
2880}
2881
2882const DESTRUCTIVE_VERBS: &[&str] = &[
2883    // POSIX
2884    "rm", "rmdir", "mv", "cp", "chmod", "chown", "truncate", "dd", //
2885    // Windows `cmd.exe` (the coder shell runs `cmd /C` there) — without these
2886    // the destructive-outside-worktree guard did nothing on Windows.
2887    "del", "erase", "rd", "move", "copy", "format", "ren", "rename",
2888];
2889
2890impl Inspector for DenyDestructiveOutsideWorktree {
2891    fn name(&self) -> &'static str {
2892        "coder.deny_destructive_outside_worktree"
2893    }
2894
2895    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2896        let Some(cmd) = shell_command(tool, params) else {
2897            return InspectionResult::Allow;
2898        };
2899        for seg in segments(&cmd) {
2900            let Some(v) = verb(&seg) else { continue };
2901            // Case-insensitive: `cmd.exe` verbs are case-insensitive (DEL/del).
2902            let v_lower = v.to_ascii_lowercase();
2903            if !DESTRUCTIVE_VERBS.contains(&v_lower.as_str()) {
2904                continue;
2905            }
2906            // Skip flag-like args: POSIX `-x` and Windows `/x` (e.g. `del /q`).
2907            for arg in seg
2908                .iter()
2909                .skip(1)
2910                .filter(|a| !a.starts_with('-') && !is_windows_switch(a))
2911            {
2912                if arg.starts_with('~') {
2913                    return InspectionResult::Deny(format!(
2914                        "'{v}' on a home-relative path ('{arg}') is not allowed"
2915                    ));
2916                }
2917                if is_abs_or_traversal(arg) && !stays_under(&self.worktree, arg) {
2918                    return InspectionResult::Deny(format!(
2919                        "'{v}' outside the worktree ('{arg}') is not allowed"
2920                    ));
2921                }
2922            }
2923        }
2924        InspectionResult::Allow
2925    }
2926}
2927
2928/// Environment repair — installing packages, creating interpreters, or dropping
2929/// an interpreter shim. The coder's job is the code; the runtime re-runs the
2930/// outcome contract in the correct environment to decide done, so a session that
2931/// "fixes" its interpreter is burning turns on a verdict it cannot change.
2932///
2933/// This used to live as ~140 words of prose in the coder system prompt — an
2934/// enumerated blacklist a model could reason its way around. As an inspector it
2935/// is enforced, and the model gets a denial *with a reason* instead, which the
2936/// loop already knows how to handle.
2937///
2938/// Deliberately scoped to what is mechanically decidable. `conftest.py`,
2939/// `pyproject.toml`, `tox.ini`, and `setup.cfg` are NOT denied: editing them is
2940/// often the actual task, and no matcher can separate "add a fixture" from
2941/// "change how tests run". Those stay a matter of judgment in the prompt.
2942/// `sitecustomize.py` has no legitimate task purpose and is denied.
2943struct DenyEnvironmentRepair;
2944
2945/// Package-manager invocations that MUTATE the environment. Matched as
2946/// (verb, subcommand); a read-only subcommand (`pip list`, `pip show`) passes,
2947/// so a coder can still inspect what is installed.
2948const PACKAGE_MUTATIONS: &[(&str, &[&str])] = &[
2949    ("pip", &["install", "uninstall"]),
2950    ("pip3", &["install", "uninstall"]),
2951    ("conda", &["install", "remove", "uninstall", "update"]),
2952    ("poetry", &["add", "remove", "install", "update"]),
2953    ("uv", &["add", "remove", "sync"]),
2954    ("easy_install", &[]),
2955];
2956
2957/// Interpreter/test-runner shims a coder has no task reason to author.
2958const SHIM_FILES: &[&str] = &["sitecustomize.py", "usercustomize.py"];
2959
2960impl Inspector for DenyEnvironmentRepair {
2961    fn name(&self) -> &'static str {
2962        "coder.deny_environment_repair"
2963    }
2964
2965    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
2966        if matches!(tool, "write_file" | "edit_file") {
2967            let path = params.get("path").and_then(Value::as_str).unwrap_or("");
2968            let base = Path::new(path)
2969                .file_name()
2970                .map(|f| f.to_string_lossy().to_ascii_lowercase())
2971                .unwrap_or_default();
2972            if SHIM_FILES.contains(&base.as_str()) {
2973                return InspectionResult::Deny(format!(
2974                    "writing '{base}' changes how the interpreter loads, not what your code \
2975                     does — the runtime re-runs the contract in the correct environment"
2976                ));
2977            }
2978            return InspectionResult::Allow;
2979        }
2980
2981        let Some(cmd) = shell_command(tool, params) else {
2982            return InspectionResult::Allow;
2983        };
2984        for seg in segments(&cmd) {
2985            let Some(v) = verb(&seg) else { continue };
2986            // Match on the program NAME, not the path it was invoked by:
2987            // `/usr/bin/python3.11 -m pip install` and `.venv/bin/pip install`
2988            // are the same action as the bare verb.
2989            let v = Path::new(&v.to_ascii_lowercase())
2990                .file_name()
2991                .map(|f| f.to_string_lossy().into_owned())
2992                .unwrap_or_default();
2993            let v = v.strip_suffix(".exe").unwrap_or(&v).to_string();
2994            let args: Vec<String> = seg.iter().skip(1).map(|a| a.to_ascii_lowercase()).collect();
2995
2996            // `python -m pip install …` / `python -m venv …` — the verb is the
2997            // interpreter, so look past `-m` for the real module.
2998            let module = args
2999                .iter()
3000                .position(|a| a == "-m")
3001                .and_then(|i| args.get(i + 1))
3002                .cloned();
3003            let (effective, effective_args): (String, Vec<String>) = match module {
3004                Some(m) if v.starts_with("python") || v.starts_with("py") => {
3005                    let rest = args
3006                        .iter()
3007                        .skip_while(|a| **a != m)
3008                        .skip(1)
3009                        .cloned()
3010                        .collect();
3011                    (m, rest)
3012                }
3013                _ => (v.clone(), args.clone()),
3014            };
3015
3016            if effective == "venv" || effective == "virtualenv" {
3017                return InspectionResult::Deny(
3018                    "creating an interpreter is environment repair, not part of the task — \
3019                     the runtime re-runs the contract in the correct environment"
3020                        .into(),
3021                );
3022            }
3023            for (mgr, subs) in PACKAGE_MUTATIONS {
3024                if effective != *mgr {
3025                    continue;
3026                }
3027                let mutates = subs.is_empty()
3028                    || effective_args.iter().any(|a| subs.contains(&a.as_str()))
3029                    // `uv pip install …` nests one level deeper.
3030                    || (effective == "uv" && effective_args.iter().any(|a| a == "install"));
3031                if mutates {
3032                    return InspectionResult::Deny(format!(
3033                        "'{mgr}' package mutation is environment repair, not part of the task \
3034                         — the runtime re-runs the contract in the correct environment"
3035                    ));
3036                }
3037            }
3038        }
3039        InspectionResult::Allow
3040    }
3041}
3042
3043/// File-tool writes whose path resolves outside the worktree. (The executor
3044/// also clamps; defense in depth so a future executor change can't silently
3045/// drop the rule.)
3046struct DenyPathEscape {
3047    worktree: PathBuf,
3048}
3049
3050impl Inspector for DenyPathEscape {
3051    fn name(&self) -> &'static str {
3052        "coder.deny_path_escape"
3053    }
3054
3055    fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
3056        if !matches!(tool, "write_file" | "edit_file") {
3057            return InspectionResult::Allow;
3058        }
3059        let Some(path) = params.get("path").and_then(Value::as_str) else {
3060            return InspectionResult::Allow; // missing param fails in the tool itself
3061        };
3062        if stays_under(&self.worktree, path) {
3063            InspectionResult::Allow
3064        } else {
3065            InspectionResult::Deny(format!("write to '{path}' resolves outside the worktree"))
3066        }
3067    }
3068}
3069
3070#[cfg(test)]
3071mod tests {
3072    use super::*;
3073    use serde_json::json;
3074
3075    fn chain() -> InspectorChain {
3076        coder_inspector_chain(Path::new("/wt"))
3077    }
3078
3079    fn denied(tool: &str, params: Value) -> bool {
3080        chain().check(tool, &params).is_some()
3081    }
3082
3083    fn sh(cmd: &str) -> Value {
3084        json!({ "command": cmd })
3085    }
3086
3087    fn write_policy(dir: &Path, body: &str) {
3088        std::fs::create_dir_all(dir).unwrap();
3089        std::fs::write(dir.join("rules.toml"), body).unwrap();
3090    }
3091
3092    #[test]
3093    fn coder_chain_merges_machine_and_project_deny_rules() {
3094        let root = tempfile::tempdir().unwrap();
3095        let repo = root.path().join("repo");
3096        let machine = root.path().join("machine-policies");
3097        let project = repo.join(".car").join("policies");
3098        std::fs::create_dir_all(&repo).unwrap();
3099        write_policy(&machine, "deny_tool = [\"write_file\"]\n");
3100        write_policy(&project, "deny_keyword = [\"DO NOT RUN\"]\n");
3101
3102        let policy =
3103            coder_inspector_chain_from_policy_dirs(&repo, &[machine.clone(), project.clone()])
3104                .unwrap();
3105        // The blanket deny is also readable back for tool-list assembly; the
3106        // keyword rule is argument-dependent and correctly is not.
3107        assert_eq!(
3108            policy.denied_tools.iter().cloned().collect::<Vec<_>>(),
3109            vec!["write_file".to_string()]
3110        );
3111        let chain = policy.chain;
3112        assert!(chain
3113            .check("write_file", &json!({"path": "x", "content": "ok"}))
3114            .is_some());
3115        assert!(chain
3116            .check("shell", &json!({"command": "echo DO NOT RUN"}))
3117            .is_some());
3118        assert!(chain.check("read_file", &json!({"path": "x"})).is_none());
3119    }
3120
3121    #[test]
3122    fn built_in_denial_reason_wins_before_project_policy() {
3123        let root = tempfile::tempdir().unwrap();
3124        let policies = root.path().join("policies");
3125        write_policy(&policies, "deny_tool = [\"shell\"]\n");
3126        let chain = coder_inspector_chain_from_policy_dirs(root.path(), &[policies])
3127            .unwrap()
3128            .chain;
3129
3130        let reason = chain
3131            .check("shell", &sh("git push origin main"))
3132            .expect("both rules deny");
3133        assert!(
3134            reason.contains("git push"),
3135            "built-in reason must win: {reason}"
3136        );
3137        assert!(
3138            !reason.contains("operator policy"),
3139            "wrong precedence: {reason}"
3140        );
3141    }
3142
3143    #[test]
3144    fn malformed_or_unenforced_policy_refuses_chain_construction() {
3145        let root = tempfile::tempdir().unwrap();
3146        let malformed = root.path().join("malformed");
3147        write_policy(&malformed, "deny_tool = [not valid TOML\n");
3148        assert!(coder_inspector_chain_from_policy_dirs(root.path(), &[malformed]).is_err());
3149
3150        let trace = root.path().join("trace");
3151        write_policy(
3152            &trace,
3153            "[[trace_rule]]\nkind = \"never\"\ntool = \"deploy\"\n",
3154        );
3155        let err = coder_inspector_chain_from_policy_dirs(root.path(), &[trace])
3156            .err()
3157            .expect("trace rules are deliberately unenforced");
3158        assert!(err.to_string().contains("not enforced"), "{err}");
3159    }
3160
3161    #[test]
3162    fn denies_package_mutation_and_interpreter_creation() {
3163        for cmd in [
3164            "pip install requests",
3165            "pip3 uninstall -y six",
3166            "python -m pip install --upgrade pip",
3167            "/usr/bin/python3.11 -m pip install pytest",
3168            "conda install numpy",
3169            "poetry add httpx",
3170            "uv pip install ruff",
3171            "python -m venv .venv",
3172            "virtualenv env",
3173            "easy_install foo",
3174            "cd /wt && pip install -e .",
3175        ] {
3176            assert!(denied("shell", sh(cmd)), "should be denied: {cmd}");
3177        }
3178    }
3179
3180    #[test]
3181    fn allows_read_only_package_queries_and_real_test_runs() {
3182        // Inspecting the environment is fine; only mutation is env repair. And
3183        // the contract's own verify command must never be caught by this rule.
3184        for cmd in [
3185            "pip list",
3186            "pip show pytest",
3187            "python -m pytest -q tests/test_x.py",
3188            "/wt/.venv/bin/python -m pytest -q tests/test_x.py",
3189            "cargo test -p car-engine",
3190            "npm test",
3191        ] {
3192            assert!(!denied("shell", sh(cmd)), "should be allowed: {cmd}");
3193        }
3194    }
3195
3196    #[test]
3197    fn denies_interpreter_shims_but_not_ordinary_test_config() {
3198        assert!(denied(
3199            "write_file",
3200            json!({ "path": "sitecustomize.py", "content": "x" })
3201        ));
3202        assert!(denied(
3203            "write_file",
3204            json!({ "path": "src/usercustomize.py", "content": "x" })
3205        ));
3206        // Editing test config is often the actual task — no matcher can tell
3207        // "add a fixture" from "change how tests run", so it stays judgment.
3208        for path in ["conftest.py", "pyproject.toml", "tox.ini", "setup.cfg"] {
3209            assert!(
3210                !denied("write_file", json!({ "path": path, "content": "x" })),
3211                "must stay allowed: {path}"
3212            );
3213        }
3214    }
3215
3216    #[test]
3217    fn git_push_and_remote_mutation_denied() {
3218        assert!(denied("shell", sh("git push origin main")));
3219        assert!(denied("shell", sh("cargo test && git push --force")));
3220        assert!(denied("shell", sh("git remote add evil https://x")));
3221        assert!(denied("shell", sh("git remote set-url origin https://x")));
3222        // Reading remotes and committing are fine.
3223        assert!(!denied("shell", sh("git remote -v")));
3224        assert!(!denied("shell", sh("git commit -m 'x'")));
3225        assert!(!denied("shell", sh("git status && git diff")));
3226        // "push" in a non-git segment is fine.
3227        assert!(!denied("shell", sh("echo push")));
3228    }
3229
3230    /// car#1074: `git push` was denied and `gh pr create` was not, so a coder
3231    /// session could publish its work without passing `coder.approve_merge`.
3232    #[test]
3233    fn forge_publication_denied_but_reads_allowed() {
3234        for cmd in [
3235            "gh pr create --fill",
3236            "gh pr merge --admin",
3237            "gh api --method DELETE /repos/o/r/branches/main/protection",
3238            "gh api -X POST /repos/o/r/issues",
3239            "gh api repos/o/r/issues -f title=x",
3240            "gh release create v9.9.9 ./x",
3241            "gh auth token",
3242            "gh repo fork",
3243            "glab mr create",
3244            "npm publish",
3245            "cargo publish",
3246            "docker push img",
3247            "docker login ghcr.io",
3248            "cargo test && gh pr create",
3249            "/opt/homebrew/bin/gh pr create --fill",
3250            // --- Adversarial shapes (review of #1076). Every one of these was
3251            // a working bypass; without them here they regress silently. ---
3252            //
3253            // `-h` was in the help allow-all, so ANY command carrying it was
3254            // waved through before its group was read. pflag takes the next
3255            // token as a string flag's value even when it starts with a dash.
3256            "gh release create v9.9.9 --notes -h",
3257            "gh pr create --title -h --body b --head mybranch --base main",
3258            // `-t`/`--show-token` PRINTS the credential. #1074 named
3259            // `gh auth token`; this is the same leak by another spelling, and
3260            // it sat on the read allowlist.
3261            "gh auth status -t",
3262            "gh auth status --show-token",
3263            // Attached-value forms. pflag parses these exactly like the
3264            // separated spellings the matcher already knew.
3265            "gh api -XPOST repos/o/r/pulls --input=-",
3266            "gh api -XPOST repos/o/r/pulls --field=title=x",
3267            "gh api --method=post repos/o/r/pulls",
3268            "gh api repos/o/r/issues --raw-field=title=x",
3269            // A GraphQL mutation, whatever flag shape carries it.
3270            "gh api graphql --field=query=mutation{createpullrequest}",
3271            // Leading operands that shifted the subcommand out of first place.
3272            "cargo +stable publish",
3273            "docker image push img",
3274            "npm --workspace x publish",
3275        ] {
3276            assert!(denied("shell", sh(cmd)), "should be denied: {cmd}");
3277        }
3278
3279        // Reading the forge is how a coder checks CI on its own branch.
3280        for cmd in [
3281            "gh pr view 12",
3282            "gh pr checks",
3283            "gh pr diff 12",
3284            "gh issue list",
3285            "gh run view 5",
3286            "gh run watch 5",
3287            "gh api repos/o/r",
3288            "gh api --method GET /repos/o/r",
3289            "gh --repo o/r pr view 12",
3290            "gh auth status",
3291            "gh --version",
3292            "/opt/homebrew/bin/gh pr list",
3293            // Verb position only, as elsewhere in this chain.
3294            "echo gh pr create",
3295            // Ordinary build verbs keep their non-publish subcommands.
3296            "cargo test -p car-engine",
3297            "npm run build",
3298            "docker build -t img .",
3299            // Read-only GraphQL REQUIRES `-f query=`. Denying it while the
3300            // attached mutation form passed had the detector inverted on the
3301            // one endpoint where it matters most.
3302            "gh api graphql -f query=query{viewer{login}}",
3303        ] {
3304            assert!(!denied("shell", sh(cmd)), "should be allowed: {cmd}");
3305        }
3306    }
3307
3308    /// The governed assistant is designed to reach production: `governance.rs`
3309    /// scores `gh run` / `az pipelines` as CI evidence and an approved
3310    /// `git push` as remote-main evidence. The forge guard is coder-only, and
3311    /// that carve-out is pinned here rather than by a comment.
3312    #[test]
3313    fn governed_host_still_allows_ci_reads_and_approved_push() {
3314        let chain = governed_host_inspector_chain(Path::new("/wt"));
3315        for command in [
3316            "gh run list",
3317            "az pipelines runs list",
3318            "git push origin HEAD:main",
3319        ] {
3320            assert!(
3321                chain.check("shell", &sh(command)).is_none(),
3322                "governed host must still allow {command}"
3323            );
3324        }
3325    }
3326
3327    #[test]
3328    fn governed_host_allows_only_normal_push_shape() {
3329        let chain = governed_host_inspector_chain(Path::new("/wt"));
3330        assert!(chain
3331            .check("shell", &sh("git push origin HEAD:main"))
3332            .is_none());
3333        for command in [
3334            "git push --force origin main",
3335            "git push --force-with-lease origin main",
3336            "git push origin +HEAD:main",
3337            "git remote set-url origin https://evil",
3338            "git rebase -i HEAD~2",
3339            "git add .",
3340            "git add -A",
3341            "git commit -am fix",
3342        ] {
3343            assert!(
3344                chain.check("shell", &sh(command)).is_some(),
3345                "must deny {command}"
3346            );
3347        }
3348    }
3349
3350    #[test]
3351    fn governed_host_denies_direct_reads_outside_repository() {
3352        let temp = tempfile::tempdir().unwrap();
3353        let repo = temp.path().join("repo");
3354        let outside = temp.path().join("outside.txt");
3355        std::fs::create_dir(&repo).unwrap();
3356        std::fs::write(&outside, "secret").unwrap();
3357        let chain = governed_host_inspector_chain(&repo);
3358
3359        assert!(chain
3360            .check("read_file", &json!({"path": outside}))
3361            .is_some());
3362        assert!(chain
3363            .check("shell", &sh(&format!("cat {}", outside.display())))
3364            .is_some());
3365        assert!(chain.check("shell", &sh("cd ..")).is_some());
3366        assert!(chain.check("shell", &sh(r"cat \../outside.txt")).is_some());
3367        for path in [
3368            "~/notes.txt",
3369            "$HOME/notes.txt",
3370            "${HOME}/notes.txt",
3371            "${HOME:-/tmp}/notes.txt",
3372            "${HOME:=/tmp}/notes.txt",
3373            "$TMPDIR/notes.txt",
3374            "${TMPDIR}/notes.txt",
3375            "${TMPDIR:-/tmp}/notes.txt",
3376        ] {
3377            assert!(
3378                chain.check("read_file", &json!({"path": path})).is_some(),
3379                "expanded shell root must be denied by read_file: {path}"
3380            );
3381        }
3382        for command in [
3383            "cat ~/notes.txt",
3384            "cat ~someone/notes.txt",
3385            "cat $HOME/notes.txt",
3386            "cat ${HOME}/notes.txt",
3387            "cat ${HOME:-/tmp}/notes.txt",
3388            "cat ${HOME:=/tmp}/notes.txt",
3389            "cat $TMPDIR/notes.txt",
3390            "cat ${TMPDIR}/notes.txt",
3391            "cat ${TMPDIR:=/tmp}/notes.txt",
3392            "sed -f ~/evil.sed file",
3393        ] {
3394            assert!(
3395                chain.check("shell", &sh(command)).is_some(),
3396                "home-relative path must be denied: {command}"
3397            );
3398        }
3399        assert!(chain.check("shell", &sh("cat src/lib.rs")).is_none());
3400
3401        #[cfg(unix)]
3402        {
3403            std::os::unix::fs::symlink(&outside, repo.join("escape")).unwrap();
3404            assert!(chain
3405                .check("read_file", &json!({"path": "escape"}))
3406                .is_some());
3407            assert!(chain.check("shell", &sh("cat escape")).is_some());
3408        }
3409    }
3410
3411    #[test]
3412    fn governed_host_denies_uninspectable_variable_operands() {
3413        let temp = tempfile::tempdir().unwrap();
3414        let repo = temp.path().join("repo");
3415        let cargo_target = temp.path().join("cargo-target");
3416        std::fs::create_dir_all(repo.join("src")).unwrap();
3417        std::fs::create_dir_all(cargo_target.join("debug")).unwrap();
3418        std::fs::write(repo.join("src/x"), "x\n").unwrap();
3419        std::fs::write(repo.join("src/lib.rs"), "pub fn control() {}\n").unwrap();
3420        std::fs::write(repo.join("$HOME_fixture"), "literal\n").unwrap();
3421        let chain = governed_host_inspector_chain(&repo);
3422
3423        for command in [
3424            "cat $NOPE/etc/hosts",
3425            "cat ${NOPE}/x",
3426            "cat ${NOPE:-${PWD}}/x",
3427            "cat $NOPE",
3428            "cat $MISSING_fixture",
3429            "cat $HOME_fixture/inside",
3430            "cat ${HOME_fixture}/inside",
3431            "cat ${PWD%repo}/x",
3432            "cat $1/etc/hosts",
3433            "cat ${1}",
3434            "cat $@",
3435            "cat $*",
3436            "cat $?",
3437            "cat $-",
3438            "cat $$",
3439            "cat $!",
3440            "cat src/$x",
3441            "cat src/${x}/file",
3442            "cat src/deeper/$x",
3443            "cat src/file$x",
3444            "cat src/$1",
3445            "cat $PWD/src/$x",
3446        ] {
3447            let reason = chain
3448                .check("shell", &sh(command))
3449                .unwrap_or_else(|| panic!("variable operand must be denied: {command}"));
3450            assert!(
3451                reason.contains("variable operand cannot be inspected"),
3452                "unexpected variable denial for {command}: {reason}"
3453            );
3454        }
3455
3456        for command in [
3457            "cat $PWD/src/x",
3458            "cat ${PWD}/src/x",
3459            "cat src/$PWD",
3460            "cat src/${PWD}/x",
3461            "cat src/lib.rs",
3462            "cat $HOME_fixture",
3463            "env RUST_LOG=debug cargo test",
3464        ] {
3465            assert!(
3466                chain.check("shell", &sh(command)).is_none(),
3467                "resolvable or repo-prefixed control must stay allowed: {command}"
3468            );
3469        }
3470
3471        // Pin the two conditional allowlist entries without mutating the
3472        // process-wide environment that parallel tests share.
3473        let path_gate = DenyGovernedShellPathEscape {
3474            worktree: repo.clone(),
3475            oldpwd_is_worktree: true,
3476            cargo_target_dir: Some(cargo_target.clone()),
3477        };
3478        for command in [
3479            "cat $OLDPWD/src/x",
3480            "cat $CARGO_TARGET_DIR/debug/output",
3481            "cat src/$OLDPWD/x",
3482            "cat src/$CARGO_TARGET_DIR/output",
3483        ] {
3484            assert!(
3485                matches!(
3486                    path_gate.inspect("shell", &sh(command)),
3487                    InspectionResult::Allow
3488                ),
3489                "pinned variable root must stay allowed: {command}"
3490            );
3491        }
3492        assert!(matches!(
3493            path_gate.inspect("shell", &sh("cat $CARGO_TARGET_DIR/../outside")),
3494            InspectionResult::Deny(reason)
3495                if reason.contains("resolves outside its governed root")
3496        ));
3497    }
3498
3499    #[test]
3500    fn governed_host_gates_every_shell_command_segment() {
3501        let temp = tempfile::tempdir().unwrap();
3502        let repo = temp.path().join("repo");
3503        let outside = temp.path().join("outside.txt");
3504        std::fs::create_dir(&repo).unwrap();
3505        std::fs::write(&outside, "secret").unwrap();
3506        let chain = governed_host_inspector_chain(&repo);
3507
3508        for separator in ["\n", "\r\n", ";", "&&", "||"] {
3509            let command = format!("printf harmless{separator}cat {}", outside.display());
3510            assert!(
3511                chain.check("shell", &sh(&command)).is_some(),
3512                "the command after separator {separator:?} must be path-gated"
3513            );
3514        }
3515    }
3516
3517    #[test]
3518    fn governed_host_joins_continued_lines_before_path_gating() {
3519        let temp = tempfile::tempdir().unwrap();
3520        let repo = temp.path().join("repo");
3521        let outside = temp.path().join("outside.txt");
3522        std::fs::create_dir_all(repo.join("src")).unwrap();
3523        std::fs::write(repo.join("src/lib.rs"), "pub fn example() {}\n").unwrap();
3524        std::fs::write(&outside, "secret").unwrap();
3525        let chain = governed_host_inspector_chain(&repo);
3526
3527        for verb in ["cat", "rm"] {
3528            let command = format!("{verb} \\\n{}", outside.display());
3529            assert!(
3530                chain.check("shell", &sh(&command)).is_some(),
3531                "a continued {verb} path must remain governed: {command:?}"
3532            );
3533        }
3534        assert!(
3535            chain
3536                .check("shell", &sh("sed -n '/pub fn/p' \\\n  src/lib.rs"))
3537                .is_none(),
3538            "a continued repository-local sed operand must remain allowed"
3539        );
3540    }
3541
3542    #[test]
3543    fn governed_host_gates_paths_embedded_in_sed_and_awk_programs() {
3544        let temp = tempfile::tempdir().unwrap();
3545        let repo = temp.path().join("repo");
3546        let outside = temp.path().join("outside.txt");
3547        std::fs::create_dir(&repo).unwrap();
3548        std::fs::write(repo.join("file"), "x\n").unwrap();
3549        std::fs::write(repo.join("script.sed"), "p\n").unwrap();
3550        std::fs::write(&outside, "secret").unwrap();
3551        let chain = governed_host_inspector_chain(&repo);
3552        let outside = outside.display();
3553
3554        for command in [
3555            format!("sed 'r {outside}' file"),
3556            format!("sed -n 'R {outside}' file"),
3557            format!("sed -e '1w {outside}' file"),
3558            format!("sed '/x/W {outside}' file"),
3559            format!("awk '{{ getline < \"{outside}\" }}' file"),
3560            format!("awk '{{ getline line < \"{outside}\" }}' file"),
3561            format!("awk '{{ print $0 > \"{outside}\" }}' file"),
3562            format!("awk '{{ print $0 > (\"{outside}\") }}' file"),
3563            format!("awk '{{ printf \"%s\", $0 >> \"{outside}\" }}' file"),
3564            // Substitution w flags write a file, including after another flag
3565            // or an address and a second substitution command.
3566            format!("sed -n 's/x/y/w {outside}' file"),
3567            format!("sed -n '/x/s//y/gw {outside}' file"),
3568            format!("sed -n 's/x/;/w {outside}' file"),
3569            format!("sed -n 's;x;y;w {outside}' file"),
3570            format!("sed -n 's/x/{{/w {outside}' file"),
3571            // r/R/w/W accept the filename immediately after the command.
3572            format!("sed -n 'r{outside}' file"),
3573            format!("sed -n 'R{outside}' file"),
3574            format!("sed -n 'w{outside}' file"),
3575            format!("sed -n 'W{outside}' file"),
3576            // Bundled options must preserve the operand-taking final option.
3577            format!("sed -nf {outside} file"),
3578            format!("sed -ne 'r {outside}' file"),
3579            format!("sed -i.bak 's/x/y/' {outside}"),
3580            // Inline command execution is denied rather than treated as an
3581            // opaque program that bypasses the governed inspector chain.
3582            format!("awk 'BEGIN {{ system(\"cat {outside}\") }}'"),
3583            format!("awk 'BEGIN {{ \"cat {outside}\" | getline line }}'"),
3584            format!("awk 'BEGIN {{ \"cat {outside}\" |& getline line }}'"),
3585            format!("awk '{{ print $0 | \"cat > {outside}\" }}' file"),
3586            format!("awk '{{ printf \"%s\", $0 | \"cat > {outside}\" }}' file"),
3587            format!("sed -n '1e cat {outside}' file"),
3588            "sed -n 's/x/y/e' file".to_string(),
3589        ] {
3590            assert!(
3591                chain.check("shell", &sh(&command)).is_some(),
3592                "dangerous embedded operand or command execution must be denied: {command}"
3593            );
3594        }
3595
3596        for separator in ["\n", "\r\n"] {
3597            let command = format!("printf harmless{separator}sed -n 's/x/y/w {outside}' file");
3598            assert!(
3599                chain.check("shell", &sh(&command)).is_some(),
3600                "a sed write after {separator:?} must remain governed"
3601            );
3602        }
3603        for continuation in ["\\\n", "\\\r\n"] {
3604            let command = format!("sed -n 's/x/y/w {continuation}{outside}' file");
3605            assert!(
3606                chain.check("shell", &sh(&command)).is_some(),
3607                "a continued sed write must remain governed: {command:?}"
3608            );
3609        }
3610
3611        for command in [
3612            "sed 'r file' file",
3613            "sed -e '1w generated.txt' file",
3614            "awk '{ getline < \"file\" }' file",
3615            "awk '{ print $0 > \"generated.txt\" }' file",
3616            "sed -nf script.sed file",
3617            "sed -ne '/x/p' file",
3618            "sed -i.bak 's/x/y/' file",
3619            "sed 'y/x/;/' file",
3620            "sed ':example; /x/p' file",
3621        ] {
3622            assert!(
3623                chain.check("shell", &sh(command)).is_none(),
3624                "repository-relative embedded file operand must remain allowed: {command}"
3625            );
3626        }
3627    }
3628
3629    #[test]
3630    fn governed_host_denies_shell_fed_sed_and_awk_programs() {
3631        let temp = tempfile::tempdir().unwrap();
3632        let repo = temp.path().join("repo");
3633        let outside = temp.path().join("outside.txt");
3634        std::fs::create_dir(&repo).unwrap();
3635        std::fs::write(repo.join("file"), "x\n").unwrap();
3636        std::fs::write(&outside, "secret").unwrap();
3637        let chain = governed_host_inspector_chain(&repo);
3638        let outside = outside.display();
3639
3640        for command in [
3641            // Heredoc-fed stdin, including the /dev/stdin spelling.
3642            format!("sed -f - file <<'EOF'\nw {outside}\nEOF"),
3643            format!("awk -f /dev/stdin file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
3644            // Here-string and pipe-fed stdin.
3645            format!("sed -f - file <<< 'w {outside}'"),
3646            format!("printf 'w {outside}\\n' | sed -f - file"),
3647            // A shell expands process substitution to /dev/fd/N; direct fd
3648            // spellings must be refused for the same reason.
3649            format!("sed -f <(printf 'w {outside}\\n') file"),
3650            format!("awk -f /dev/fd/0 file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
3651            "awk -f /proc/self/fd/0 file".to_string(),
3652            // Empty inline-program padding must not make the parser consume
3653            // the following -f option as the -e program.
3654            format!("sed -e '' -f - file <<'EOF'\nw {outside}\nEOF"),
3655            format!("awk -e '' -f - file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
3656            // Empty and shell-computed -f sources cannot be inspected as
3657            // literal repository paths.
3658            "sed -f '' file".to_string(),
3659            "sed -f\"\" file".to_string(),
3660            "sed -f $(printf -) file".to_string(),
3661            "awk -f `printf -` file".to_string(),
3662            "sed -f >(printf p) file".to_string(),
3663            // Backslash quote removal happens before argv reaches sed/awk.
3664            r"sed -f \- file".to_string(),
3665            r"awk -f \- file".to_string(),
3666            "busybox awk -f - file".to_string(),
3667            r"sed --file=\- file".to_string(),
3668            // Brace and parameter expansion can turn a source that looks
3669            // repository-relative here into stdin when the shell executes it.
3670            "sed -f {-,} file".to_string(),
3671            "x=-; sed -f $x file".to_string(),
3672            "x=-; sed -f \"$x\" file".to_string(),
3673        ] {
3674            let reason = chain
3675                .check("shell", &sh(&command))
3676                .unwrap_or_else(|| panic!("shell-fed program must be denied: {command}"));
3677            assert!(
3678                reason.contains("script source") && reason.contains("cannot be inspected"),
3679                "program channels must not be misclassified as governed paths: {reason}"
3680            );
3681        }
3682    }
3683
3684    #[test]
3685    fn governed_host_denies_nested_shell_command_carriers() {
3686        let chain = governed_host_inspector_chain(Path::new("/wt"));
3687        for command in [
3688            "eval 'sed -f - file'",
3689            "bash -c 'sed -f - file'",
3690            "sh -c 'cat /etc/passwd'",
3691            "zsh -c 'awk -f - file'",
3692            "dash -c 'cat /etc/passwd'",
3693            "ksh -c 'cat /etc/passwd'",
3694            "fish -c 'cat /etc/passwd'",
3695            "busybox sh -c 'cat /etc/passwd'",
3696            "busybox ash -c 'cat /etc/passwd'",
3697            "perl -e 'open F, q(/etc/passwd)'",
3698            "perl -E 'say qx(cat /etc/passwd)'",
3699            "perl -wE 'say qx(cat /etc/passwd)'",
3700            "python3 -c 'open(\"/etc/passwd\").read()'",
3701            "python3.14 -c 'open(\"/etc/passwd\").read()'",
3702            "ruby -e 'puts File.read(\"/etc/passwd\")'",
3703            "node -e 'require(\"fs\").readFileSync(\"/etc/passwd\")'",
3704            "osascript -e 'do shell script \"cat /etc/passwd\"'",
3705            "script -c 'cat /etc/passwd'",
3706            "bash -lc 'cat /etc/passwd'",
3707            "MODE=check /bin/bash --noprofile -c 'cat /etc/passwd'",
3708            "env -S 'cat /etc/passwd'",
3709            "bash",
3710            "nice bash",
3711            "nohup bash",
3712        ] {
3713            let reason = chain
3714                .check("shell", &sh(command))
3715                .unwrap_or_else(|| panic!("nested shell carrier must be denied: {command}"));
3716            assert!(
3717                reason.contains("nested shell command"),
3718                "unexpected nested-shell denial reason: {reason}"
3719            );
3720        }
3721        assert!(chain.check("shell", &sh("echo eval")).is_none());
3722        assert!(chain.check("shell", &sh("bash script.sh")).is_none());
3723        assert!(chain
3724            .check("shell", &sh("nice bash scripts/check.sh"))
3725            .is_none());
3726        assert!(chain.check("shell", &sh("python3 script.py")).is_none());
3727        assert!(chain.check("shell", &sh("python3 -E script.py")).is_none());
3728        assert!(chain
3729            .check("shell", &sh("ruby -E UTF-8 script.rb"))
3730            .is_none());
3731        assert!(chain.check("shell", &sh("node script.js")).is_none());
3732        assert!(chain
3733            .check("shell", &sh("perl -MExtUtils::MakeMaker scripts/build.pl"))
3734            .is_none());
3735        assert!(chain.check("shell", &sh("command -v sudo")).is_none());
3736    }
3737
3738    #[test]
3739    fn governed_host_inspects_command_environment_assignments() {
3740        let temp = tempfile::tempdir().unwrap();
3741        let repo = temp.path().join("repo");
3742        let outside = temp.path().join("outside");
3743        std::fs::create_dir_all(repo.join("target")).unwrap();
3744        std::fs::create_dir_all(&outside).unwrap();
3745        let chain = governed_host_inspector_chain(&repo);
3746
3747        for name in [
3748            "BASH_ENV",
3749            "ENV",
3750            "PATH",
3751            "LD_PRELOAD",
3752            "DYLD_INSERT_LIBRARIES",
3753            "PYTHONPATH",
3754            "PERL5LIB",
3755            "RUBYLIB",
3756            "NODE_OPTIONS",
3757            "CARGO_HOME",
3758            "RUSTUP_HOME",
3759            "GIT_EXEC_PATH",
3760            "GIT_SSH_COMMAND",
3761        ] {
3762            let command = format!("env {name}=repo-local cargo test");
3763            let reason = chain
3764                .check("shell", &sh(&command))
3765                .unwrap_or_else(|| panic!("redirecting assignment must be denied: {command}"));
3766            assert!(reason.contains("redirect executable code"), "{reason}");
3767        }
3768
3769        for command in [
3770            "BASH_ENV=repo-local bash script.sh".to_string(),
3771            format!("env CACHE_DIR={} cargo test", outside.display()),
3772            "env EDITOR=repo-editor git commit".to_string(),
3773            "VISUAL=repo-editor git rebase --interactive main".to_string(),
3774            "env CARGO_TARGET_DIR=target cargo test".to_string(),
3775        ] {
3776            assert!(
3777                chain.check("shell", &sh(&command)).is_some(),
3778                "unsafe assignment must be denied: {command}"
3779            );
3780        }
3781
3782        for command in [
3783            "env RUST_LOG=debug cargo test".to_string(),
3784            "RUST_LOG=debug".to_string(),
3785            "RUST_LOG[0]=debug; cargo test".to_string(),
3786            "RUST_LOG[$index]=debug; cargo test".to_string(),
3787            "RUST_LOG[$((1+1))]=debug; cargo test".to_string(),
3788            // A whitespace subscript is uninspectable, not forbidden: an
3789            // unprotected name stays allowed through every spelling.
3790            "RUST_LOG[ 0 ]=debug; cargo test".to_string(),
3791            "RUST_LOG[0 ]=debug; cargo test".to_string(),
3792            "RUST_LOG[ 0]=debug; cargo test".to_string(),
3793            "RUST_LOG[\t0]=debug; cargo test".to_string(),
3794            "RUST_LOG[ 0 ]+=,debug; cargo test".to_string(),
3795            "cargo test 'a[0]'".to_string(),
3796            "CACHE_KIND=local".to_string(),
3797            "env EDITOR=vim cargo test".to_string(),
3798        ] {
3799            assert!(
3800                chain.check("shell", &sh(&command)).is_none(),
3801                "plain assignment must stay allowed: {command}"
3802            );
3803        }
3804
3805        for command in [
3806            "PWD=repo-local",
3807            "OLDPWD=repo-local",
3808            "CARGO_TARGET_DIR=target",
3809            "HOME=repo-local",
3810            "TMPDIR=tmp",
3811            "IFS=: ",
3812            "PATH=bin",
3813            "PWD=/etc; cat $PWD/passwd",
3814            "PWD[0]=/etc; cat $PWD/passwd",
3815            "PWD[0]+=/x",
3816            // bash accepts whitespace inside a subscript and still assigns the
3817            // scalar, so every spelling the tokenizer splits apart must be
3818            // denied on the base name.
3819            "PWD[ 0 ]=/etc; cat $PWD/passwd",
3820            "PWD[0 ]=/etc; cat $PWD/passwd",
3821            "PWD[ 0]=/etc; cat $PWD/passwd",
3822            "PWD[\t0]=/etc; cat $PWD/passwd",
3823            "PWD[\t0]+=/x",
3824            "env PWD[0]=/etc cat $PWD/passwd",
3825            "source=../../outside; cat $source/file",
3826            "source[0]=repo-local; cat $source/file",
3827            "env input=../../outside cat $input/file",
3828        ] {
3829            let reason = chain
3830                .check("shell", &sh(command))
3831                .unwrap_or_else(|| panic!("path-changing assignment must be denied: {command}"));
3832            assert!(
3833                reason.contains("environment assignment"),
3834                "unexpected assignment denial for {command}: {reason}"
3835            );
3836        }
3837    }
3838
3839    #[test]
3840    fn governed_host_inspects_assignment_builtins() {
3841        let chain = governed_host_inspector_chain(Path::new("/wt"));
3842
3843        for command in [
3844            "export PWD=/etc; cat $PWD/passwd",
3845            "readonly PWD=/etc; cat $PWD/passwd",
3846            "declare PWD=/etc; cat $PWD/passwd",
3847            "typeset PWD=/etc; cat $PWD/passwd",
3848            "local PWD=/etc; cat $PWD/passwd",
3849            "export PWD[1]=/etc; cat $PWD/passwd",
3850            "export PWD[ 1 ]=/etc; cat $PWD/passwd",
3851            "declare -a PWD[\t0]=/etc; cat $PWD/passwd",
3852            "readonly PWD[1]=/etc; cat $PWD/passwd",
3853            "declare PATH[0]=/tmp; cargo test",
3854            "declare -a arr[0]=/etc",
3855            "typeset HOME[0]=/tmp; cargo test",
3856            "local PWD[1]=/etc; cat $PWD/passwd",
3857            "unset PWD",
3858            "export -n PWD",
3859        ] {
3860            let reason = chain
3861                .check("shell", &sh(command))
3862                .unwrap_or_else(|| panic!("assignment builtin must be denied: {command}"));
3863            assert!(
3864                reason.contains("environment assignment"),
3865                "unexpected assignment-builtin denial for {command}: {reason}"
3866            );
3867        }
3868
3869        for builtin in ["export", "readonly", "declare", "typeset", "local"] {
3870            for assignment in ["source=repo-local", "source[0]=repo-local"] {
3871                let command = format!("{builtin} {assignment}; cat $source/file");
3872                let reason = chain.check("shell", &sh(&command)).unwrap_or_else(|| {
3873                    panic!("later operand-prefix assignment must be denied: {command}")
3874                });
3875                assert!(reason.contains("environment assignment"), "{reason}");
3876            }
3877        }
3878
3879        for command in [
3880            "PWD+=/../../../etc; cat $PWD/passwd",
3881            "env PWD+=/../../../etc cat $PWD/passwd",
3882            "export PWD+=/../../../etc; cat $PWD/passwd",
3883            "readonly PWD+=/../../../etc; cat $PWD/passwd",
3884            "declare PATH+=/../../../bin; cargo test",
3885            "typeset PWD+=/../../../etc; cat $PWD/passwd",
3886            "local PWD+=/../../../etc; cat $PWD/passwd",
3887        ] {
3888            let reason = chain
3889                .check("shell", &sh(command))
3890                .unwrap_or_else(|| panic!("append assignment must be denied: {command}"));
3891            assert!(
3892                reason.contains("environment assignment"),
3893                "unexpected append-assignment denial for {command}: {reason}"
3894            );
3895        }
3896
3897        for command in [
3898            "RUST_LOG[$(id)]=debug; cargo test",
3899            "RUST_LOG[$(printf 0)]=debug; cargo test",
3900            "env RUST_LOG[$(id)]=debug cargo test",
3901            "export RUST_LOG[$(id)]=debug; cargo test",
3902            "readonly RUST_LOG[`printf 0`]=debug; cargo test",
3903            "declare RUST_LOG[$(id)]=debug; cargo test",
3904            "typeset RUST_LOG[$(id)]=debug; cargo test",
3905            "local RUST_LOG[$(id)]=debug; cargo test",
3906        ] {
3907            let reason = chain.check("shell", &sh(command)).unwrap_or_else(|| {
3908                panic!("command-substitution subscript must be denied: {command}")
3909            });
3910            assert!(reason.contains("cannot be inspected"), "{reason}");
3911        }
3912
3913        for command in [
3914            "export RUST_LOG=debug; cargo test",
3915            "export RUST_LOG+=,debug; cargo test",
3916            "export RUST_LOG[0]=debug; cargo test",
3917            "export RUST_LOG[ 0 ]=debug; cargo test",
3918            "declare RUST_LOG[\t0]=debug; cargo test",
3919            "declare RUST_LOG[$index]=debug; cargo test",
3920            "typeset RUST_LOG[$((1+1))]=debug; cargo test",
3921            "echo 'RUST_LOG[$(id)]=debug'",
3922            "local x=1",
3923            "unset RUST_LOG",
3924            "export -n RUST_LOG",
3925        ] {
3926            assert!(
3927                chain.check("shell", &sh(command)).is_none(),
3928                "harmless assignment builtin must stay allowed: {command}"
3929            );
3930        }
3931    }
3932
3933    #[test]
3934    fn governed_host_denies_busybox_install_mode() {
3935        let chain = governed_host_inspector_chain(Path::new("/wt"));
3936        for command in [
3937            "busybox --install",
3938            "busybox --install -s",
3939            "busybox --install=/tmp/bin",
3940        ] {
3941            let reason = chain
3942                .check("shell", &sh(command))
3943                .unwrap_or_else(|| panic!("busybox install must be denied: {command}"));
3944            assert!(reason.contains("busybox --install"), "{reason}");
3945        }
3946        assert!(chain
3947            .check("shell", &sh("busybox grep foo src/input.txt"))
3948            .is_none());
3949    }
3950
3951    #[test]
3952    fn governed_host_unwraps_prefix_command_carriers() {
3953        let temp = tempfile::tempdir().unwrap();
3954        let repo = temp.path().join("repo");
3955        let outside = temp.path().join("outside.txt");
3956        std::fs::create_dir_all(repo.join("src")).unwrap();
3957        std::fs::write(repo.join("src/input.txt"), "foo\n").unwrap();
3958        std::fs::write(&outside, "secret\n").unwrap();
3959        let chain = governed_host_inspector_chain(&repo);
3960        let path_gate = DenyGovernedShellPathEscape::new(&repo);
3961        let outside = outside.display();
3962
3963        for command in [
3964            format!("env FOO=1 bash -c 'cat {outside}'"),
3965            format!("env -i -u FOO FOO=1 cat {outside}"),
3966            format!("command -p cat {outside}"),
3967            format!("exec -a reader cat {outside}"),
3968            format!("nohup -- cat {outside}"),
3969            format!("time -f '%E' cat {outside}"),
3970            format!("nice --adjustment 5 cat {outside}"),
3971            format!("caffeinate -t 1 cat {outside}"),
3972            format!("script -q transcript cat {outside}"),
3973            format!("xargs -0P 2 cat {outside}"),
3974            "builtin eval 'cat /etc/passwd'".to_string(),
3975            format!("sudo -u root cat {outside}"),
3976            format!("ionice -c 2 cat {outside}"),
3977            format!("timeout --signal TERM 5 cat {outside}"),
3978            format!("env -i time nice -n 1 cat {outside}"),
3979        ] {
3980            assert!(
3981                chain.check("shell", &sh(&command)).is_some(),
3982                "carrier must not hide the governed command: {command}"
3983            );
3984        }
3985
3986        // A carrier with no command adds no denial in the path gate. The full
3987        // chain separately (and intentionally) denies bare `env` because it
3988        // dumps all environment variables.
3989        assert!(matches!(
3990            path_gate.inspect("shell", &sh("env")),
3991            InspectionResult::Allow
3992        ));
3993        for command in [
3994            "time cargo build",
3995            "xargs -0 grep foo src/input.txt",
3996            "busybox grep foo src/input.txt",
3997        ] {
3998            assert!(
3999                chain.check("shell", &sh(command)).is_none(),
4000                "safe carrier control must stay allowed: {command}"
4001            );
4002        }
4003    }
4004
4005    #[test]
4006    fn prefix_carriers_do_not_bypass_other_shell_inspectors() {
4007        for command in [
4008            "env FOO=1 git push origin main",
4009            "command gh pr create --fill",
4010            "time cargo publish",
4011            "nice -n 1 pip install requests",
4012        ] {
4013            assert!(
4014                denied("shell", sh(command)),
4015                "must deny carried command: {command}"
4016            );
4017        }
4018    }
4019
4020    #[test]
4021    fn governed_host_distinguishes_sed_and_awk_programs_from_paths() {
4022        let temp = tempfile::tempdir().unwrap();
4023        let repo = temp.path().join("repo");
4024        std::fs::create_dir_all(repo.join("src")).unwrap();
4025        std::fs::write(repo.join("src/lib.rs"), "pub fn example() {}\n").unwrap();
4026        std::fs::write(repo.join("file.rs"), "pub fn example() {}\n").unwrap();
4027        std::fs::write(repo.join("file"), "x\n").unwrap();
4028        std::fs::write(repo.join("script.sed"), "p\n").unwrap();
4029        std::fs::write(repo.join("-"), "p\n").unwrap();
4030        #[cfg(unix)]
4031        {
4032            // The name `\-` is deliberate: a backslash is an ordinary filename
4033            // byte on unix, and the policy must resolve the shell-escaped
4034            // operand `\-` to this file. `Path::join` reads a leading backslash
4035            // as a path separator, so build the path by pushing instead.
4036            let mut escaped_dash = repo.clone();
4037            escaped_dash.push(r"\-");
4038            std::fs::write(escaped_dash, "p\n").unwrap();
4039        }
4040        let chain = governed_host_inspector_chain(&repo);
4041
4042        assert!(
4043            chain
4044                .check("shell", &sh("sed -n /pub fn/p file.rs"))
4045                .is_none(),
4046            "a sed address is a program, not the absolute path /pub"
4047        );
4048        assert!(
4049            chain
4050                .check("shell", &sh("sed -n '/pub fn/p' src/lib.rs"))
4051                .is_none(),
4052            "a quoted sed address is a program, not the absolute path /pub"
4053        );
4054        assert!(
4055            chain
4056                .check("shell", &sh("sed -e '/pub fn/p' src/lib.rs"))
4057                .is_none(),
4058            "a sed -e operand is an inline program, not a path"
4059        );
4060        assert!(
4061            chain
4062                .check("shell", &sh("sed -n '/pub \\/etc/p' src/lib.rs"))
4063                .is_none(),
4064            "quoted whitespace must not split one sed program into path operands"
4065        );
4066        assert!(
4067            chain.check("shell", &sh("awk /pub/ file.rs")).is_none(),
4068            "an awk pattern is a program, not the absolute path /pub"
4069        );
4070        assert!(
4071            chain.check("shell", &sh("awk '/x/{print}' file")).is_none(),
4072            "an awk pattern-action is a program, not a path"
4073        );
4074        assert!(
4075            chain
4076                .check("shell", &sh("awk '/x/ { print \"/etc\" }' file"))
4077                .is_none(),
4078            "a quoted awk program remains one non-path argument"
4079        );
4080        assert!(
4081            chain.check("shell", &sh("sed -n p /etc/passwd")).is_some(),
4082            "sed input files remain governed"
4083        );
4084        assert!(
4085            chain.check("shell", &sh("sed -f /etc/evil")).is_some(),
4086            "sed -f names a script file and must remain governed"
4087        );
4088        assert!(
4089            chain.check("shell", &sh("awk -E/path file")).is_some(),
4090            "an attached awk -E script path must remain governed"
4091        );
4092        assert!(
4093            chain
4094                .check("shell", &sh("sed -f script.sed file"))
4095                .is_none(),
4096            "a literal repository script must remain allowed"
4097        );
4098        assert!(
4099            chain
4100                .check("shell", &sh("sed -e '' -f script.sed file"))
4101                .is_none(),
4102            "empty -e padding must not deny a literal repository script"
4103        );
4104        assert!(
4105            chain.check("shell", &sh("sed -f ./- file")).is_none(),
4106            "./- is a literal repository path, not sed's stdin marker"
4107        );
4108        #[cfg(unix)]
4109        {
4110            for command in [r"sed -f '\-' file", r#"sed -f "\-" file"#] {
4111                assert!(
4112                    chain.check("shell", &sh(command)).is_none(),
4113                    "a backslash preserved by shell quotes remains a repository filename: {command}"
4114                );
4115            }
4116        }
4117    }
4118
4119    #[test]
4120    fn governed_host_denies_gui_shell_automation() {
4121        let chain = governed_host_inspector_chain(Path::new("/wt"));
4122        assert!(chain
4123            .check(
4124                "run_applescript",
4125                &json!({"script": "tell application \"Terminal\" to do script \"az deploy\""})
4126            )
4127            .is_some());
4128        assert!(chain
4129            .check("run_powershell", &json!({"script": "az deploy"}))
4130            .is_some());
4131    }
4132
4133    #[test]
4134    fn history_rewrite_denied() {
4135        assert!(denied("shell", sh("git rebase -i HEAD~3")));
4136        assert!(denied("shell", sh("git reset --hard HEAD~1")));
4137        assert!(denied("shell", sh("git filter-branch --all")));
4138        assert!(denied("shell", sh("git worktree remove /wt")));
4139        assert!(!denied("shell", sh("git reset HEAD file.txt"))); // soft reset ok
4140    }
4141
4142    #[test]
4143    fn privilege_escalation_denied() {
4144        assert!(denied("shell", sh("sudo rm -rf /tmp/x")));
4145        assert!(denied("shell", sh("doas pkg_add x")));
4146        assert!(denied("shell", sh("FOO=1 sudo make install")));
4147        assert!(denied("shell", sh("launchctl unload foo")));
4148        assert!(!denied("shell", sh("echo sudo"))); // verb position only
4149    }
4150
4151    #[test]
4152    fn credential_access_denied_for_shell_and_file_tools() {
4153        assert!(denied("shell", sh("cat ~/.ssh/id_rsa")));
4154        assert!(denied("shell", sh("cat $HOME/.aws/credentials")));
4155        assert!(denied("shell", sh("security find-generic-password -s x")));
4156        assert!(denied("read_file", json!({"path": "/Users/u/.ssh/id_rsa"})));
4157        assert!(denied("read_file", json!({"path": "~/.netrc"})));
4158        assert!(!denied("read_file", json!({"path": "src/main.rs"})));
4159        // ".ssh" as a repo-relative dir name is unfortunate but stays denied —
4160        // conservative beats clever here.
4161    }
4162
4163    #[test]
4164    fn destructive_ops_scoped_to_worktree() {
4165        assert!(denied("shell", sh("rm -rf /etc")));
4166        assert!(denied("shell", sh("rm -rf ../other-checkout")));
4167        assert!(denied("shell", sh("mv target ~/elsewhere")));
4168        assert!(denied("shell", sh("chmod 777 /usr/local/bin/x")));
4169        // Inside the worktree: fine, relative or absolute.
4170        assert!(!denied("shell", sh("rm -rf target/debug")));
4171        assert!(!denied("shell", sh("rm /wt/scratch.txt")));
4172        assert!(!denied("shell", sh("cp a.txt b.txt")));
4173    }
4174
4175    #[test]
4176    fn write_path_escape_denied_but_reads_allowed() {
4177        assert!(denied(
4178            "write_file",
4179            json!({"path": "/etc/hosts", "content": "x"})
4180        ));
4181        assert!(denied("edit_file", json!({"path": "../outside.txt"})));
4182        assert!(!denied(
4183            "write_file",
4184            json!({"path": "src/new.rs", "content": "x"})
4185        ));
4186        assert!(!denied(
4187            "write_file",
4188            json!({"path": "/wt/src/new.rs", "content": "x"})
4189        ));
4190        // Reads outside the worktree are allowed (context gathering) unless
4191        // they hit credential markers.
4192        assert!(!denied(
4193            "read_file",
4194            json!({"path": "/usr/include/stdio.h"})
4195        ));
4196    }
4197
4198    #[test]
4199    fn stays_under_is_lexical_and_strict() {
4200        let root = Path::new("/wt");
4201        assert!(stays_under(root, "src/x.rs"));
4202        assert!(stays_under(root, "a/../b.txt"));
4203        assert!(stays_under(root, "/wt/deep/file"));
4204        assert!(!stays_under(root, "../escape"));
4205        assert!(!stays_under(root, "a/../../escape"));
4206        assert!(!stays_under(root, "/etc/passwd"));
4207        assert!(!stays_under(root, "/wtevil/file")); // prefix, not component, match
4208        assert!(!stays_under(root, "~"));
4209        assert!(!stays_under(root, "~/outside"));
4210        assert!(!stays_under(root, "~someone/outside"));
4211        assert!(!stays_under(root, "$HOME"));
4212        assert!(!stays_under(root, "$HOME/outside"));
4213        assert!(!stays_under(root, "${HOME}/outside"));
4214        assert!(!stays_under(root, "${HOME:-/tmp}/outside"));
4215        assert!(!stays_under(root, "${HOME:=/tmp}/outside"));
4216        assert!(!stays_under(root, "$TMPDIR/outside"));
4217        assert!(!stays_under(root, "${TMPDIR}/outside"));
4218        assert!(!stays_under(root, "${TMPDIR:-/tmp}/outside"));
4219        assert!(!stays_under(root, "${TMPDIR:=/tmp}/outside"));
4220        assert!(!stays_under(root, "$HOME_fixture"));
4221        assert!(!stays_under(root, "${HOME_fixture}/inside"));
4222        assert!(stays_under(root, "src/$x"));
4223        assert!(stays_under(root, "src/~fixture"));
4224
4225        let temp = tempfile::tempdir().unwrap();
4226        std::fs::write(temp.path().join("$HOME_fixture"), "literal").unwrap();
4227        assert!(stays_under(temp.path(), "$HOME_fixture"));
4228    }
4229
4230    #[cfg(windows)]
4231    #[test]
4232    fn windows_destructive_and_privilege_denied() {
4233        let chain = coder_inspector_chain(Path::new(r"C:\wt"));
4234        let denied = |cmd: &str| chain.check("shell", &sh(cmd)).is_some();
4235        // `cmd.exe` destructive verbs aimed outside the worktree.
4236        assert!(denied(r"del C:\Windows\System32\drivers\etc\hosts"));
4237        assert!(denied(r"rd /s /q C:\Windows"));
4238        assert!(denied(r"del /q C:\Users\victim\file")); // `/q` switch is skipped
4239        assert!(denied(r"move C:\wt\keep.txt C:\Users\public\stolen.txt"));
4240        // Windows privilege elevation.
4241        assert!(denied("runas /user:Administrator cmd"));
4242        assert!(denied("sc stop windefend"));
4243        // Inside the worktree: allowed (absolute or relative).
4244        assert!(!denied(r"del C:\wt\target\debug\app.exe"));
4245        assert!(!denied(r"del build\out.txt"));
4246        assert!(!denied("dir")); // non-destructive verb untouched
4247    }
4248
4249    #[cfg(windows)]
4250    #[test]
4251    fn windows_credential_access_denied() {
4252        let chain = coder_inspector_chain(Path::new(r"C:\wt"));
4253        assert!(chain
4254            .check("shell", &sh(r"type %USERPROFILE%\.ssh\id_rsa"))
4255            .is_some());
4256        assert!(chain.check("shell", &sh("cmdkey /list")).is_some());
4257        assert!(chain
4258            .check(
4259                "read_file",
4260                &json!({"path": r"C:\Users\u\.aws\credentials"})
4261            )
4262            .is_some());
4263        // A normal source read is fine.
4264        assert!(chain
4265            .check("read_file", &json!({"path": r"C:\wt\src\main.rs"}))
4266            .is_none());
4267    }
4268
4269    #[cfg(windows)]
4270    #[test]
4271    fn stays_under_handles_verbatim_prefix_and_case() {
4272        // A canonicalized worktree carries the `\\?\` verbatim prefix; a plain
4273        // absolute candidate inside it (any case) must still count as inside,
4274        // and NTFS case-insensitivity is honoured.
4275        let root = Path::new(r"\\?\C:\wt");
4276        assert!(stays_under(root, r"C:\WT\src\main.rs"));
4277        assert!(stays_under(root, r"c:\wt\src\main.rs"));
4278        assert!(!stays_under(root, r"C:\other\x"));
4279        assert!(!stays_under(root, r"C:\wtevil\x")); // prefix, not component
4280    }
4281}