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