use std::collections::{BTreeSet, HashMap};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use car_ir::Action;
use car_policy::{InspectionResult, Inspector, InspectorChain, PolicyEngine, PolicyRules};
use car_state::StateStore;
use serde_json::Value;
fn coder_inspector_chain_for(worktree: &Path, deny_credentials: bool) -> InspectorChain {
let mut chain = InspectorChain::new()
.with(Box::new(DenyShellFunctionDeclaration))
.with(Box::new(DenyGitRemoteMutation))
.with(Box::new(DenyForgePublication))
.with(Box::new(DenyHistoryRewrite))
.with(Box::new(DenyPrivilegeEscalation));
if deny_credentials {
chain = chain.with(Box::new(DenyCredentialAccess));
}
chain
.with(Box::new(DenyEnvironmentRepair))
.with(Box::new(DenyDestructiveOutsideWorktree {
worktree: worktree.to_path_buf(),
}))
.with(Box::new(DenyPathEscape {
worktree: worktree.to_path_buf(),
}))
}
pub fn coder_inspector_chain(worktree: &Path) -> InspectorChain {
coder_inspector_chain_for(worktree, true)
}
pub(super) fn credentialed_contract_inspector_chain(worktree: &Path) -> InspectorChain {
coder_inspector_chain_for(worktree, false)
}
pub struct CoderPolicy {
pub chain: InspectorChain,
pub credentialed_contract_chain: InspectorChain,
pub denied_tools: BTreeSet<String>,
}
pub fn coder_inspector_chain_with_project_policies(
worktree: &Path,
) -> Result<CoderPolicy, car_policy::PolicyLoadError> {
let dirs = [
car_home::root_or_relative().join("policies"),
worktree.join(".car").join("policies"),
];
coder_inspector_chain_from_policy_dirs(worktree, &dirs)
}
fn coder_inspector_chain_from_policy_dirs(
worktree: &Path,
dirs: &[PathBuf],
) -> Result<CoderPolicy, car_policy::PolicyLoadError> {
let mut rules = PolicyRules::default();
for dir in dirs {
rules.merge(car_policy::load_policy_dir(dir)?);
}
let mut engine = PolicyEngine::new();
rules.apply(&mut engine);
let denied_tools = engine.blanket_denied_tools();
let engine = Arc::new(engine);
let state = Arc::new(StateStore::new());
let project_policy = || {
Box::new(ProjectPolicyInspector {
engine: Arc::clone(&engine),
state: Arc::clone(&state),
}) as Box<dyn Inspector>
};
Ok(CoderPolicy {
chain: coder_inspector_chain(worktree).with(project_policy()),
credentialed_contract_chain: credentialed_contract_inspector_chain(worktree)
.with(project_policy()),
denied_tools,
})
}
struct ProjectPolicyInspector {
engine: Arc<PolicyEngine>,
state: Arc<StateStore>,
}
impl Inspector for ProjectPolicyInspector {
fn name(&self) -> &'static str {
"project_policy"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let mut action = Action::tool_call(tool);
action.id = "coder-policy-check".to_string();
action.parameters = params
.as_object()
.map(|m| {
m.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<HashMap<_, _>>()
})
.unwrap_or_default();
match self.engine.check(&action, &self.state).into_iter().next() {
Some(violation) => InspectionResult::Deny(format!(
"operator policy '{}': {}",
violation.policy_name, violation.reason
)),
None => InspectionResult::Allow,
}
}
}
struct DenyGovernedShellPathEscape {
worktree: PathBuf,
oldpwd_is_worktree: bool,
cargo_target_dir: Option<PathBuf>,
}
impl DenyGovernedShellPathEscape {
fn new(worktree: &Path) -> Self {
let inherited_path = |name: &str| {
std::env::var_os(name).and_then(|value| {
let path = PathBuf::from(value);
(!path.as_os_str().is_empty()).then(|| {
if path.is_absolute() {
path
} else {
worktree.join(path)
}
})
})
};
let oldpwd_is_worktree = inherited_path("OLDPWD").is_some_and(|oldpwd| {
match (oldpwd.canonicalize(), worktree.canonicalize()) {
(Ok(oldpwd), Ok(worktree)) => oldpwd == worktree,
_ => false,
}
});
Self {
worktree: worktree.to_path_buf(),
oldpwd_is_worktree,
cargo_target_dir: inherited_path("CARGO_TARGET_DIR"),
}
}
}
const READ_OR_CHDIR_VERBS: &[&str] = &[
"cat", "head", "tail", "less", "more", "grep", "egrep", "fgrep", "rg", "sed", "awk", "find",
"ls", "stat", "wc", "strings", "readlink", "realpath", "cd", "type",
];
fn skip_program_whitespace(program: &str, index: &mut usize) {
while let Some(ch) = program[*index..].chars().next() {
if !ch.is_whitespace() {
break;
}
*index += ch.len_utf8();
}
}
fn consume_sed_address(program: &str, index: &mut usize) -> bool {
let Some(first) = program[*index..].chars().next() else {
return false;
};
match first {
'0'..='9' => {
while let Some(ch) = program[*index..].chars().next() {
if !ch.is_ascii_digit() {
break;
}
*index += ch.len_utf8();
}
true
}
'$' => {
*index += 1;
true
}
'/' => {
*index += 1;
let mut escaped = false;
while let Some(ch) = program[*index..].chars().next() {
*index += ch.len_utf8();
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '/' {
return true;
}
}
true
}
'\\' => {
*index += 1;
let Some(delimiter) = program[*index..].chars().next() else {
return true;
};
*index += delimiter.len_utf8();
let mut escaped = false;
while let Some(ch) = program[*index..].chars().next() {
*index += ch.len_utf8();
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
return true;
}
}
true
}
_ => false,
}
}
fn consume_sed_delimited_field(statement: &str, index: &mut usize, delimiter: char) -> bool {
let mut escaped = false;
while let Some(ch) = statement[*index..].chars().next() {
*index += ch.len_utf8();
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == delimiter {
return true;
}
}
false
}
fn sed_line_end(program: &str, index: usize) -> usize {
program[index..]
.find('\n')
.map_or(program.len(), |offset| index + offset)
}
fn skip_to_sed_separator(program: &str, index: &mut usize) {
while let Some(ch) = program[*index..].chars().next() {
if matches!(ch, ';' | '\n' | '{' | '}') {
break;
}
*index += ch.len_utf8();
}
}
fn sed_program_paths(program: &str) -> Result<Vec<String>, String> {
let mut paths = Vec::new();
let mut index = 0;
while index < program.len() {
skip_program_whitespace(program, &mut index);
while let Some(separator) = program[index..].chars().next() {
if !matches!(separator, ';' | '{' | '}') {
break;
}
index += separator.len_utf8();
skip_program_whitespace(program, &mut index);
}
if index == program.len() {
break;
}
loop {
if !consume_sed_address(program, &mut index) {
break;
}
skip_program_whitespace(program, &mut index);
if program[index..].starts_with(',') || program[index..].starts_with('~') {
index += 1;
skip_program_whitespace(program, &mut index);
continue;
}
break;
}
skip_program_whitespace(program, &mut index);
if program[index..].starts_with('!') {
index += 1;
skip_program_whitespace(program, &mut index);
}
let Some(command) = program[index..].chars().next() else {
break;
};
index += command.len_utf8();
match command {
'r' | 'R' | 'w' | 'W' => {
let end = sed_line_end(program, index);
let operand = program[index..end].trim();
if !operand.is_empty() {
paths.push(operand.to_string());
}
index = end;
}
's' => {
let Some(delimiter) = program[index..].chars().next() else {
break;
};
index += delimiter.len_utf8();
if !consume_sed_delimited_field(program, &mut index, delimiter)
|| !consume_sed_delimited_field(program, &mut index, delimiter)
{
continue;
}
while let Some(flag) = program[index..].chars().next() {
match flag {
'e' => {
return Err(
"sed shell execution through the e flag is not allowed in a governed session"
.to_string(),
)
}
'w' => {
index += flag.len_utf8();
let end = sed_line_end(program, index);
let operand = program[index..end].trim();
if !operand.is_empty() {
paths.push(operand.to_string());
}
index = end;
break;
}
';' | '\n' | '{' | '}' => break,
ch if ch.is_whitespace()
|| ch.is_ascii_digit()
|| matches!(ch, 'g' | 'i' | 'I' | 'm' | 'M' | 'p') =>
{
index += ch.len_utf8();
}
_ => {
skip_to_sed_separator(program, &mut index);
break;
}
}
}
}
'y' => {
let Some(delimiter) = program[index..].chars().next() else {
break;
};
index += delimiter.len_utf8();
if !consume_sed_delimited_field(program, &mut index, delimiter)
|| !consume_sed_delimited_field(program, &mut index, delimiter)
{
continue;
}
}
'e' => return Err(
"sed shell execution through the e command is not allowed in a governed session"
.to_string(),
),
'a' | 'c' | 'i' | '#' => index = sed_line_end(program, index),
':' | 'b' | 'l' | 'q' | 'Q' | 't' | 'T' | 'v' => {
skip_to_sed_separator(program, &mut index)
}
_ => {}
}
}
Ok(paths)
}
fn awk_program_tokens(program: &str) -> Vec<String> {
fn finish(token: &mut String, result: &mut Vec<String>) {
if !token.is_empty() {
result.push(std::mem::take(token));
}
}
let mut result = Vec::new();
let mut token = String::new();
let mut quote = None;
let mut escaped = false;
let mut chars = program.chars().peekable();
while let Some(ch) = chars.next() {
if let Some(delimiter) = quote {
if escaped {
token.push(ch);
escaped = false;
} else if ch == '\\' {
#[cfg(windows)]
{
if chars
.peek()
.is_some_and(|next| *next != delimiter && *next != '\\')
{
token.push(ch);
} else {
escaped = true;
}
}
#[cfg(not(windows))]
{
escaped = true;
}
} else if ch == delimiter {
quote = None;
finish(&mut token, &mut result);
} else {
token.push(ch);
}
continue;
}
match ch {
'"' | '\'' => {
finish(&mut token, &mut result);
quote = Some(ch);
}
ch if ch.is_whitespace() => finish(&mut token, &mut result),
'<' | '>' | '|' => {
finish(&mut token, &mut result);
let mut operator = ch.to_string();
if (ch == '>' && chars.peek() == Some(&'>'))
|| (ch == '|' && chars.peek() == Some(&'&'))
{
let joined = chars.next().expect("peeked operator suffix");
operator.push(joined);
}
result.push(operator);
}
';' | '{' | '}' | '(' | ')' | ',' => {
finish(&mut token, &mut result);
result.push(ch.to_string());
}
_ => token.push(ch),
}
}
finish(&mut token, &mut result);
result
}
fn awk_program_paths(program: &str) -> Result<Vec<String>, String> {
let tokens = awk_program_tokens(program);
if tokens
.windows(2)
.any(|pair| pair[0] == "system" && pair[1] == "(")
{
return Err("awk system() is not allowed in a governed session".to_string());
}
for (index, token) in tokens.iter().enumerate() {
if !matches!(token.as_str(), "|" | "|&") {
continue;
}
let start = tokens[..index]
.iter()
.rposition(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
.map_or(0, |position| position + 1);
let end = tokens[index + 1..]
.iter()
.position(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
.map_or(tokens.len(), |offset| index + 1 + offset);
if tokens[start..index]
.iter()
.any(|candidate| matches!(candidate.as_str(), "print" | "printf"))
|| tokens[index + 1..end]
.iter()
.any(|candidate| candidate == "getline")
{
return Err("awk command pipes are not allowed in a governed session".to_string());
}
}
let mut paths = Vec::new();
for (index, token) in tokens.iter().enumerate() {
let wanted = match token.as_str() {
"getline" => &["<"][..],
"print" | "printf" => &[">", ">>"][..],
_ => continue,
};
let end = tokens[index + 1..]
.iter()
.position(|candidate| matches!(candidate.as_str(), ";" | "{" | "}"))
.map_or(tokens.len(), |offset| index + 1 + offset);
let Some(operator) = tokens[index + 1..end]
.iter()
.position(|candidate| wanted.contains(&candidate.as_str()))
.map(|offset| index + 1 + offset)
else {
continue;
};
if let Some(path) = tokens[operator + 1..end]
.iter()
.find(|candidate| candidate.as_str() != "(")
{
paths.push(path.clone());
}
}
Ok(paths)
}
fn inline_program_paths(verb: &str, program: &str) -> Result<Vec<String>, String> {
match verb {
"sed" => sed_program_paths(program),
"awk" => awk_program_paths(program),
_ => Ok(Vec::new()),
}
}
fn append_inline_program_paths(
verb: &str,
program: &str,
paths: &mut Vec<String>,
) -> Result<(), String> {
paths.extend(inline_program_paths(verb, program)?);
Ok(())
}
fn has_brace_expansion_comma(source: &str) -> bool {
let mut braces = Vec::new();
let mut escaped = false;
for ch in source.chars() {
if escaped {
escaped = false;
continue;
}
match ch {
'\\' => escaped = true,
'{' => braces.push(false),
',' => {
if let Some(brace) = braces.last_mut() {
*brace = true;
}
}
'}' if braces.pop().is_some_and(|has_comma| has_comma) => return true,
_ => {}
}
}
false
}
fn reject_shell_fed_program_source(verb: &str, source: &str) -> Result<(), String> {
let is_program_channel = source.is_empty()
|| source == "-"
|| source == "/dev/stdin"
|| source
.strip_prefix("/dev/fd/")
.is_some_and(|fd| !fd.is_empty())
|| source
.strip_prefix("/proc/self/fd/")
.is_some_and(|fd| !fd.is_empty())
|| source.starts_with("<(")
|| source.starts_with(">(")
|| source.contains('$')
|| source.contains('`')
|| has_brace_expansion_comma(source);
if is_program_channel {
return Err(format!(
"{verb} script source '{source}' cannot be inspected in a governed session"
));
}
Ok(())
}
fn append_program_source_path(
verb: &str,
source: &str,
paths: &mut Vec<String>,
) -> Result<(), String> {
reject_shell_fed_program_source(verb, source)?;
paths.push(source.to_string());
Ok(())
}
fn reject_shell_fed_program_file_operands(verb: &str, args: &[String]) -> Result<(), String> {
for (index, arg) in args.iter().enumerate() {
if arg == "--" {
break;
}
let is_file_option = matches!(
(verb, arg.as_str()),
("sed", "-f" | "--file") | ("awk", "-f" | "-E" | "--file")
);
if is_file_option {
let source = args.get(index + 1).map_or("", String::as_str);
reject_shell_fed_program_source(verb, source)?;
}
}
Ok(())
}
fn governed_path_arguments(verb: &str, tokens: &[String]) -> Result<Vec<String>, String> {
let Some(verb_index) = shell_verb_index(tokens) else {
return Ok(Vec::new());
};
let args = &tokens[verb_index + 1..];
if !matches!(verb, "sed" | "awk") {
return Ok(args
.iter()
.filter(|arg| !arg.starts_with('-'))
.cloned()
.collect());
}
reject_shell_fed_program_file_operands(verb, args)?;
let mut paths = Vec::new();
let mut explicit_program = false;
let mut positional_program_seen = false;
let mut options = true;
let mut index = 0;
while index < args.len() {
let arg = args[index].as_str();
if options && arg == "--" {
options = false;
index += 1;
continue;
}
if options && arg.starts_with('-') && arg != "-" {
match (verb, arg) {
("sed", "-e" | "--expression") | ("awk", "-e" | "--source") => {
explicit_program = true;
if let Some(program) = args.get(index + 1) {
append_inline_program_paths(verb, program, &mut paths)?;
}
index += 2;
continue;
}
("sed", "-f" | "--file") | ("awk", "-f" | "-E" | "--file") => {
explicit_program = true;
if let Some(source) = args.get(index + 1) {
append_program_source_path(verb, source, &mut paths)?;
}
index += 2;
continue;
}
("awk", "-F" | "-v") => {
index += 2; continue;
}
_ => {}
}
if let Some(source) = arg.strip_prefix("--file=") {
explicit_program = true;
append_program_source_path(verb, source, &mut paths)?;
index += 1;
continue;
}
if let Some(program) = arg
.strip_prefix("--expression=")
.or_else(|| arg.strip_prefix("--source="))
{
explicit_program = true;
append_inline_program_paths(verb, program, &mut paths)?;
index += 1;
continue;
}
if verb == "sed" && !arg.starts_with("--") {
let cluster = &arg[1..];
let mut cursor = 0;
let mut handled = true;
while let Some(option) = cluster[cursor..].chars().next() {
cursor += option.len_utf8();
match option {
'n' | 'E' | 'r' | 's' | 'u' | 'z' => {}
'e' | 'f' => {
explicit_program = true;
let attached = &cluster[cursor..];
if option == 'e' {
if attached.is_empty() {
if let Some(program) = args.get(index + 1) {
append_inline_program_paths("sed", program, &mut paths)?;
}
index += 2;
} else {
append_inline_program_paths("sed", attached, &mut paths)?;
index += 1;
}
} else if attached.is_empty() {
if let Some(source) = args.get(index + 1) {
append_program_source_path("sed", source, &mut paths)?;
}
index += 2;
} else {
append_program_source_path("sed", attached, &mut paths)?;
index += 1;
}
break;
}
'i' => {
index += 1;
break;
}
_ => {
handled = false;
break;
}
}
}
if handled {
if cursor == cluster.len()
&& !matches!(cluster.chars().last(), Some('e' | 'f' | 'i'))
{
index += 1;
}
continue;
}
}
let attached_file_source = arg
.strip_prefix("-f")
.filter(|source| !source.is_empty())
.or_else(|| {
if verb == "awk" {
arg.strip_prefix("-E").filter(|source| !source.is_empty())
} else {
None
}
});
if let Some(source) = attached_file_source {
explicit_program = true;
append_program_source_path(verb, source, &mut paths)?;
} else if let Some(program) = arg.strip_prefix("-e").filter(|p| !p.is_empty()) {
explicit_program = true;
append_inline_program_paths(verb, program, &mut paths)?;
}
index += 1;
continue;
}
if !explicit_program && !positional_program_seen {
positional_program_seen = true;
append_inline_program_paths(verb, arg, &mut paths)?;
} else {
paths.push(arg.to_string());
}
index += 1;
}
Ok(paths)
}
fn join_shell_line_continuations(command: &str) -> String {
let mut logical = String::with_capacity(command.len());
let mut chars = command.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
if chars.peek() == Some(&'\n') {
chars.next();
continue;
}
if chars.peek() == Some(&'\r') {
let mut lookahead = chars.clone();
if lookahead.next() == Some('\r') && lookahead.next() == Some('\n') {
chars.next();
chars.next();
continue;
}
}
}
logical.push(ch);
}
logical
}
#[derive(Debug, PartialEq, Eq)]
enum ShellDeclarationToken {
Word { text: String, quoted: bool },
OpenParen,
CloseParen,
OpenBrace,
CloseBrace,
CommandBoundary,
}
fn shell_declaration_tokens(command: &str) -> Vec<ShellDeclarationToken> {
fn finish_word(tokens: &mut Vec<ShellDeclarationToken>, word: &mut String, quoted: &mut bool) {
if !word.is_empty() || *quoted {
tokens.push(ShellDeclarationToken::Word {
text: std::mem::take(word),
quoted: std::mem::take(quoted),
});
}
}
let mut tokens = Vec::new();
let mut word = String::new();
let mut word_quoted = false;
let logical_command = join_shell_line_continuations(command);
let segmented_command = split_shell_case_arm_bodies(&logical_command);
let mut chars = segmented_command.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
word_quoted = true;
if let Some(escaped) = chars.next() {
word.push(escaped);
}
}
'\'' | '"' => {
word_quoted = true;
let delimiter = ch;
while let Some(quoted) = chars.next() {
if quoted == delimiter {
break;
}
if delimiter == '"' && quoted == '\\' {
if let Some(escaped) = chars.next() {
word.push(escaped);
}
} else {
word.push(quoted);
}
}
}
'#' if word.is_empty() => {
finish_word(&mut tokens, &mut word, &mut word_quoted);
for comment in chars.by_ref() {
if comment == '\n' {
tokens.push(ShellDeclarationToken::CommandBoundary);
break;
}
}
}
'\n' | '\r' => {
finish_word(&mut tokens, &mut word, &mut word_quoted);
tokens.push(ShellDeclarationToken::CommandBoundary);
}
ch if ch.is_whitespace() => {
finish_word(&mut tokens, &mut word, &mut word_quoted);
}
';' | 'ï¼›' | '|' | '&' => {
finish_word(&mut tokens, &mut word, &mut word_quoted);
tokens.push(ShellDeclarationToken::CommandBoundary);
if chars.peek() == Some(&ch) {
chars.next();
}
}
'(' => {
finish_word(&mut tokens, &mut word, &mut word_quoted);
tokens.push(ShellDeclarationToken::OpenParen);
}
')' => {
finish_word(&mut tokens, &mut word, &mut word_quoted);
tokens.push(ShellDeclarationToken::CloseParen);
}
'{' => {
finish_word(&mut tokens, &mut word, &mut word_quoted);
tokens.push(ShellDeclarationToken::OpenBrace);
}
'}' => {
finish_word(&mut tokens, &mut word, &mut word_quoted);
tokens.push(ShellDeclarationToken::CloseBrace);
}
_ => word.push(ch),
}
}
finish_word(&mut tokens, &mut word, &mut word_quoted);
tokens
}
pub(crate) fn contains_shell_function_declaration(command: &str) -> bool {
use ShellDeclarationToken::*;
let tokens = shell_declaration_tokens(command);
let mut command_position = true;
let mut index = 0;
while index < tokens.len() {
if command_position {
let posix_declaration = matches!(tokens.get(index), Some(Word { text, .. }) if !text.is_empty())
&& matches!(tokens.get(index + 1), Some(OpenParen))
&& matches!(tokens.get(index + 2), Some(CloseParen))
&& matches!(tokens.get(index + 3), Some(OpenBrace));
let keyword_declaration = matches!(
tokens.get(index),
Some(Word { text, quoted: false }) if text == "function"
) && matches!(tokens.get(index + 1), Some(Word { text, .. }) if !text.is_empty())
&& (matches!(tokens.get(index + 2), Some(OpenBrace))
|| (matches!(tokens.get(index + 2), Some(OpenParen))
&& matches!(tokens.get(index + 3), Some(CloseParen))
&& matches!(tokens.get(index + 4), Some(OpenBrace))));
if posix_declaration || keyword_declaration {
return true;
}
}
match &tokens[index] {
CommandBoundary | OpenBrace | OpenParen => command_position = true,
Word {
text,
quoted: false,
} if matches!(
text.as_str(),
"if" | "then" | "elif" | "else" | "while" | "until" | "do" | "time" | "!"
) =>
{
command_position = true
}
_ => command_position = false,
}
index += 1;
}
false
}
struct DenyShellFunctionDeclaration;
impl Inspector for DenyShellFunctionDeclaration {
fn name(&self) -> &'static str {
"coder.deny_shell_function_declaration"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(command) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
if contains_shell_function_declaration(&command) {
InspectionResult::Deny(
"shell function declarations require explicit human approval and cannot be run by a coding model"
.into(),
)
} else {
InspectionResult::Allow
}
}
}
#[derive(Clone, Copy)]
enum CaseScanPhase {
Header { subject_seen: bool },
Pattern,
Body,
}
fn shell_word_keeps_command_position(word: &str) -> bool {
matches!(
word,
"{" | "}"
| "if"
| "then"
| "elif"
| "else"
| "fi"
| "for"
| "do"
| "done"
| "while"
| "until"
| "function"
| "time"
| "!"
)
}
fn finish_case_scan_word(
token: &mut String,
token_started: &mut bool,
token_quoted: &mut bool,
cases: &mut Vec<CaseScanPhase>,
command_position: &mut bool,
) {
if !std::mem::take(token_started) {
return;
}
let word = std::mem::take(token);
let quoted = std::mem::take(token_quoted);
match cases.last().copied() {
Some(CaseScanPhase::Header { subject_seen }) => {
if subject_seen && !quoted && word == "in" {
*cases.last_mut().expect("case header exists") = CaseScanPhase::Pattern;
*command_position = true;
} else if !subject_seen {
*cases.last_mut().expect("case header exists") =
CaseScanPhase::Header { subject_seen: true };
}
}
Some(CaseScanPhase::Pattern) => {
if !quoted && word == "esac" && *command_position {
cases.pop();
*command_position = false;
}
}
Some(CaseScanPhase::Body) | None => {
if !quoted && word == "esac" && *command_position && !cases.is_empty() {
cases.pop();
*command_position = false;
} else if !quoted && word == "case" && *command_position {
cases.push(CaseScanPhase::Header {
subject_seen: false,
});
*command_position = false;
} else {
*command_position =
!quoted && *command_position && shell_word_keeps_command_position(&word);
}
}
}
}
fn split_shell_case_arm_bodies(command: &str) -> String {
let mut output = String::with_capacity(command.len());
let mut token = String::new();
let mut token_started = false;
let mut token_quoted = false;
let mut quote = None;
let mut nested_parentheses = 0usize;
let mut cases = Vec::new();
let mut command_position = true;
let mut chars = command.chars().peekable();
while let Some(ch) = chars.next() {
output.push(ch);
if let Some(delimiter) = quote {
if ch == delimiter {
quote = None;
} else if delimiter == '"' && ch == '\\' {
if let Some(escaped) = chars.next() {
output.push(escaped);
token.push(escaped);
token_started = true;
}
} else {
token.push(ch);
token_started = true;
}
continue;
}
if nested_parentheses > 0 {
token.push(ch);
token_started = true;
match ch {
'\\' => {
if let Some(escaped) = chars.next() {
output.push(escaped);
token.push(escaped);
}
}
'\'' | '"' => {
quote = Some(ch);
token_quoted = true;
}
'(' => nested_parentheses += 1,
')' => nested_parentheses -= 1,
_ => {}
}
continue;
}
match ch {
'\\' => {
if let Some(escaped) = chars.next() {
output.push(escaped);
token.push(escaped);
token_started = true;
token_quoted = true;
}
}
'\'' | '"' => {
quote = Some(ch);
token_started = true;
token_quoted = true;
}
ch if ch.is_whitespace() => {
finish_case_scan_word(
&mut token,
&mut token_started,
&mut token_quoted,
&mut cases,
&mut command_position,
);
if matches!(ch, '\n' | '\r') {
command_position = true;
}
}
')' if matches!(cases.last(), Some(CaseScanPhase::Pattern)) => {
let case_depth = cases.len();
finish_case_scan_word(
&mut token,
&mut token_started,
&mut token_quoted,
&mut cases,
&mut command_position,
);
if cases.len() == case_depth {
*cases.last_mut().expect("case pattern remains") = CaseScanPhase::Body;
command_position = true;
output.push('\n');
}
}
';' => {
finish_case_scan_word(
&mut token,
&mut token_started,
&mut token_quoted,
&mut cases,
&mut command_position,
);
let mut arm_terminator = false;
if chars.peek() == Some(&';') {
output.push(chars.next().expect("peeked second semicolon"));
arm_terminator = true;
if chars.peek() == Some(&'&') {
output.push(chars.next().expect("peeked case terminator ampersand"));
}
} else if chars.peek() == Some(&'&') {
output.push(chars.next().expect("peeked case terminator ampersand"));
arm_terminator = true;
}
if arm_terminator && matches!(cases.last(), Some(CaseScanPhase::Body)) {
*cases.last_mut().expect("case body exists") = CaseScanPhase::Pattern;
}
command_position = true;
}
'|' | '&' => {
finish_case_scan_word(
&mut token,
&mut token_started,
&mut token_quoted,
&mut cases,
&mut command_position,
);
if chars.peek() == Some(&ch) {
output.push(chars.next().expect("peeked shell operator"));
}
if !matches!(cases.last(), Some(CaseScanPhase::Pattern)) {
command_position = true;
}
}
'(' if token
.chars()
.last()
.is_some_and(|prefix| matches!(prefix, '$' | '<' | '>'))
|| (matches!(cases.last(), Some(CaseScanPhase::Pattern))
&& token
.chars()
.last()
.is_some_and(|prefix| matches!(prefix, '@' | '+' | '?' | '*' | '!'))) =>
{
token.push(ch);
token_started = true;
nested_parentheses = 1;
}
'(' => {
finish_case_scan_word(
&mut token,
&mut token_started,
&mut token_quoted,
&mut cases,
&mut command_position,
);
if !matches!(cases.last(), Some(CaseScanPhase::Pattern)) {
command_position = true;
}
}
')' => {
finish_case_scan_word(
&mut token,
&mut token_started,
&mut token_quoted,
&mut cases,
&mut command_position,
);
command_position = false;
}
'{' | '}'
if !token_started
&& chars.peek().is_none_or(|next| {
next.is_whitespace() || matches!(next, ';' | 'ï¼›' | '|' | '&')
}) =>
{
command_position = true;
}
_ => {
token.push(ch);
token_started = true;
}
}
}
finish_case_scan_word(
&mut token,
&mut token_started,
&mut token_quoted,
&mut cases,
&mut command_position,
);
output
}
fn governed_shell_segments(command: &str) -> Vec<Vec<String>> {
fn finish_token(
token: &mut String,
started: &mut bool,
attached_empty: &mut bool,
tokens: &mut Vec<String>,
) {
if std::mem::take(started) {
tokens.push(std::mem::take(token));
if std::mem::take(attached_empty) {
tokens.push(String::new());
}
}
}
fn finish_segment(
token: &mut String,
started: &mut bool,
attached_empty: &mut bool,
tokens: &mut Vec<String>,
result: &mut Vec<Vec<String>>,
) {
finish_token(token, started, attached_empty, tokens);
if !tokens.is_empty() {
result.push(std::mem::take(tokens));
}
}
let mut result = Vec::new();
let mut tokens = Vec::new();
let mut token = String::new();
let mut token_started = false;
let mut attached_empty = false;
let mut quote = None;
let mut arithmetic_depth = 0usize;
let logical_command = split_shell_case_arm_bodies(&join_shell_line_continuations(command));
let mut chars = logical_command.chars().peekable();
while let Some(ch) = chars.next() {
if arithmetic_depth > 0 {
token.push(ch);
token_started = true;
attached_empty = false;
match ch {
'(' => arithmetic_depth += 1,
')' => arithmetic_depth -= 1,
_ => {}
}
continue;
}
if let Some((delimiter, start_len, was_attached)) = quote {
if ch == delimiter {
quote = None;
if was_attached && token.len() == start_len {
attached_empty = true;
}
} else if delimiter == '"' && ch == '\\' {
if chars
.peek()
.is_some_and(|next| matches!(next, '$' | '`' | '"' | '\\'))
{
token.push(chars.next().expect("peeked escaped character"));
} else {
token.push(ch);
}
attached_empty = false;
} else {
token.push(ch);
attached_empty = false;
}
continue;
}
match ch {
'\\' => {
#[cfg(windows)]
{
token.push(ch);
}
#[cfg(not(windows))]
{
if let Some(escaped) = chars.next() {
token.push(escaped);
} else {
token.push(ch);
}
}
token_started = true;
attached_empty = false;
}
'\'' | '"' => {
quote = Some((ch, token.len(), token_started));
token_started = true;
}
'\n' | '\r' => finish_segment(
&mut token,
&mut token_started,
&mut attached_empty,
&mut tokens,
&mut result,
),
'{' | '}'
if !token_started
&& chars.peek().is_none_or(|next| {
next.is_whitespace() || matches!(next, ';' | 'ï¼›' | '|' | '&')
}) =>
{
finish_segment(
&mut token,
&mut token_started,
&mut attached_empty,
&mut tokens,
&mut result,
)
}
ch if ch.is_whitespace() => finish_token(
&mut token,
&mut token_started,
&mut attached_empty,
&mut tokens,
),
';' | 'ï¼›' | '|' | '&' => {
finish_segment(
&mut token,
&mut token_started,
&mut attached_empty,
&mut tokens,
&mut result,
);
if matches!(ch, '|' | '&') && chars.peek() == Some(&ch) {
chars.next();
}
}
'(' if matches!(token.chars().last(), Some('<' | '>')) => {
token.push(ch);
token_started = true;
attached_empty = false;
}
'(' if chars.peek() == Some(&'(') => {
token.push(ch);
token.push(chars.next().expect("peeked arithmetic parenthesis"));
token_started = true;
attached_empty = false;
arithmetic_depth = 2;
}
'(' | ')' => finish_segment(
&mut token,
&mut token_started,
&mut attached_empty,
&mut tokens,
&mut result,
),
_ => {
token.push(ch);
token_started = true;
attached_empty = false;
}
}
}
finish_segment(
&mut token,
&mut token_started,
&mut attached_empty,
&mut tokens,
&mut result,
);
result
}
#[derive(Clone, Copy)]
struct CommandCarrier {
name: &'static str,
short_value_options: &'static [char],
long_value_options: &'static [&'static str],
leading_operands: usize,
assignments: bool,
}
const COMMAND_CARRIERS: &[CommandCarrier] = &[
CommandCarrier {
name: "env",
short_value_options: &['a', 'C', 'S', 'u'],
long_value_options: &["--argv0", "--chdir", "--split-string", "--unset"],
leading_operands: 0,
assignments: true,
},
CommandCarrier {
name: "command",
short_value_options: &[],
long_value_options: &[],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "exec",
short_value_options: &['a'],
long_value_options: &[],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "nohup",
short_value_options: &[],
long_value_options: &[],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "time",
short_value_options: &['f', 'o'],
long_value_options: &["--format", "--output"],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "nice",
short_value_options: &['n'],
long_value_options: &["--adjustment"],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "caffeinate",
short_value_options: &['t', 'w'],
long_value_options: &[],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "script",
short_value_options: &['B', 'F', 'I', 'O', 'T', 'c', 'm', 't'],
long_value_options: &[
"--command",
"--log-in",
"--log-io",
"--log-out",
"--log-timing",
"--logging-format",
],
leading_operands: 1,
assignments: false,
},
CommandCarrier {
name: "xargs",
short_value_options: &['E', 'I', 'J', 'L', 'P', 'R', 'S', 'a', 'd', 'n', 's'],
long_value_options: &[
"--arg-file",
"--delimiter",
"--eof",
"--max-args",
"--max-chars",
"--max-lines",
"--max-procs",
"--replace",
],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "builtin",
short_value_options: &[],
long_value_options: &[],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "busybox",
short_value_options: &[],
long_value_options: &[],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "sudo",
short_value_options: &['C', 'D', 'R', 'T', 'a', 'g', 'h', 'p', 'r', 't', 'u'],
long_value_options: &[
"--auth-type",
"--chdir",
"--chroot",
"--close-from",
"--command-timeout",
"--group",
"--host",
"--prompt",
"--role",
"--type",
"--user",
],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "ionice",
short_value_options: &['P', 'c', 'n', 'p', 'u'],
long_value_options: &["--class", "--classdata", "--pgid", "--pid", "--uid"],
leading_operands: 0,
assignments: false,
},
CommandCarrier {
name: "timeout",
short_value_options: &['k', 's'],
long_value_options: &["--kill-after", "--signal"],
leading_operands: 1,
assignments: false,
},
];
fn executable_name(raw: &str) -> String {
let lower = raw.to_ascii_lowercase();
let name = Path::new(&lower)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&lower);
name.strip_suffix(".exe").unwrap_or(name).to_string()
}
#[derive(Clone, Copy)]
struct ShellAssignmentParts<'a> {
name: &'a str,
subscript: Option<&'a str>,
value: &'a str,
}
fn shell_assignment_parts(token: &str) -> Option<ShellAssignmentParts<'_>> {
let name_length = shell_name_len(token);
if name_length == 0 {
return None;
}
let name = &token[..name_length];
let remainder = &token[name_length..];
let (subscript, operator) = if remainder.starts_with('[') {
let mut depth = 0usize;
let mut close = None;
for (index, ch) in remainder.char_indices() {
match ch {
'[' => depth += 1,
']' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
close = Some(index);
break;
}
}
_ => {}
}
}
let close = close?;
(Some(&remainder[1..close]), &remainder[close + 1..])
} else {
(None, remainder)
};
let value = operator
.strip_prefix("+=")
.or_else(|| operator.strip_prefix('='))?;
Some(ShellAssignmentParts {
name,
subscript,
value,
})
}
fn shell_subscript_contains_command_substitution(subscript: &str) -> bool {
if subscript.contains('`') {
return true;
}
let mut cursor = 0usize;
while let Some(relative) = subscript[cursor..].find("$(") {
let start = cursor + relative;
if subscript[start + 2..].starts_with('(') {
cursor = start + 3;
} else {
return true;
}
}
false
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SubscriptFlaw {
CommandSubstitution,
BreaksTokenization,
}
fn subscript_flaw(subscript: &str) -> Option<SubscriptFlaw> {
if shell_subscript_contains_command_substitution(subscript) {
return Some(SubscriptFlaw::CommandSubstitution);
}
subscript
.chars()
.any(|ch| ch.is_whitespace() || matches!(ch, ';' | 'ï¼›' | '|' | '&' | '\'' | '"' | '\\'))
.then_some(SubscriptFlaw::BreaksTokenization)
}
fn uninspectable_assignment_subscripts(command: &str) -> Vec<(&str, SubscriptFlaw)> {
let bytes = command.as_bytes();
let mut found = Vec::new();
let mut index = 0usize;
let mut quote = None;
let mut word_boundary = true;
while index < bytes.len() {
let byte = bytes[index];
if let Some(delimiter) = quote {
if byte == delimiter {
quote = None;
} else if delimiter == b'"' && byte == b'\\' {
index += usize::from(index + 1 < bytes.len());
}
index += 1;
continue;
}
match byte {
b'\\' => {
index += 1 + usize::from(index + 1 < bytes.len());
word_boundary = false;
}
b'\'' | b'"' => {
quote = Some(byte);
word_boundary = false;
index += 1;
}
b' ' | b'\t' | b'\r' | b'\n' | b';' | b'|' | b'&' | b'(' => {
word_boundary = true;
index += 1;
}
b'_' | b'a'..=b'z' | b'A'..=b'Z' if word_boundary => {
let name_start = index;
index += 1;
while index < bytes.len()
&& matches!(bytes[index], b'_' | b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9')
{
index += 1;
}
if bytes.get(index) == Some(&b'[') {
let subscript_start = index + 1;
let mut close_search = subscript_start;
while let Some(relative) = command[close_search..].find(']') {
let close = close_search + relative;
let after = &command[close + 1..];
if after.starts_with('=') || after.starts_with("+=") {
let subscript = &command[subscript_start..close];
if let Some(flaw) = subscript_flaw(subscript) {
found.push((&command[name_start..index], flaw));
}
break;
}
close_search = close + 1;
}
}
word_boundary = false;
}
_ => {
word_boundary = false;
index += 1;
}
}
}
found
}
fn shell_assignment(token: &str) -> bool {
shell_assignment_parts(token).is_some()
}
fn compound_command_keyword(token: &str) -> bool {
matches!(
token,
"{" | "}"
| "if"
| "then"
| "elif"
| "else"
| "fi"
| "for"
| "do"
| "done"
| "while"
| "until"
| "case"
| "esac"
| "function"
| "time"
| "!"
)
}
fn shell_verb_index(tokens: &[String]) -> Option<usize> {
let mut index = 0;
while let Some(token) = tokens.get(index) {
if token == "function" {
index += 2;
continue;
}
if let Some(span) = posix_function_head(&tokens[index..]) {
index += span;
continue;
}
if shell_assignment(token) {
index += 1;
continue;
}
if compound_command_keyword(token) && token != "time" {
index += 1;
continue;
}
return Some(index);
}
None
}
fn posix_function_head(tokens: &[String]) -> Option<usize> {
for span in 1..=3.min(tokens.len()) {
let joined: String = tokens[..span].concat();
let Some(name) = joined
.strip_suffix("(){")
.or_else(|| joined.strip_suffix("()"))
else {
continue;
};
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
{
continue;
}
if joined.ends_with('{') || tokens.get(span).is_some_and(|next| next == "{") {
return Some(span);
}
}
None
}
fn leading_shell_assignments(command: &[String]) -> Vec<&str> {
fn collect<'a>(command: &'a [String], start: usize, found: &mut Vec<&'a str>) {
for token in &command[start..] {
if compound_command_keyword(token) {
continue;
}
if shell_assignment(token) {
found.push(token);
continue;
}
break;
}
}
let mut found = Vec::new();
collect(command, 0, &mut found);
let case_header = command.first().is_some_and(|token| token == "case");
let case_arm = command.first().is_some_and(|token| token.ends_with(')'));
if case_header || case_arm {
for (index, token) in command.iter().enumerate() {
if token.ends_with(')') && !token.contains("$(") {
collect(command, index + 1, &mut found);
}
}
}
found
}
fn carrier_option_consumes_next(spec: CommandCarrier, option: &str) -> bool {
if spec.long_value_options.contains(&option) {
return true;
}
if option.starts_with("--") {
return false;
}
let mut options = option.strip_prefix('-').unwrap_or("").chars().peekable();
while let Some(option) = options.next() {
if spec.short_value_options.contains(&option) {
return options.peek().is_none();
}
}
false
}
struct CarrierInvocation<'a> {
command: &'a [String],
assignments: Vec<&'a str>,
}
fn carrier_command<'a>(
tokens: &'a [String],
verb_index: usize,
verb: &str,
) -> Option<CarrierInvocation<'a>> {
let spec = *COMMAND_CARRIERS.iter().find(|spec| spec.name == verb)?;
let args = &tokens[verb_index + 1..];
if verb == "command"
&& args
.iter()
.take_while(|arg| arg.as_str() != "--")
.any(|arg| {
arg.strip_prefix('-')
.filter(|short| !short.starts_with('-'))
.is_some_and(|short| short.contains('v') || short.contains('V'))
})
{
return Some(CarrierInvocation {
command: &[],
assignments: Vec::new(),
});
}
let mut index = 0;
let mut options = true;
let mut assignments = Vec::new();
while index < args.len() {
let arg = args[index].as_str();
if options && arg == "--" {
options = false;
index += 1;
continue;
}
if options && arg.starts_with('-') && arg != "-" {
let consumes_next = !arg.contains('=') && carrier_option_consumes_next(spec, arg);
index += 1 + usize::from(consumes_next && index + 1 < args.len());
continue;
}
if spec.assignments && shell_assignment(arg) {
assignments.push(arg);
index += 1;
continue;
}
break;
}
index = (index + spec.leading_operands).min(args.len());
Some(CarrierInvocation {
command: &args[index..],
assignments,
})
}
fn has_short_command_flag(args: &[String], wanted: char, value_options: &[char]) -> bool {
let mut index = 0;
while index < args.len() {
let arg = args[index].as_str();
if arg == "--" || !arg.starts_with('-') || arg == "-" {
break;
}
let Some(short) = arg
.strip_prefix('-')
.filter(|short| !short.starts_with('-'))
else {
index += 1;
continue;
};
let mut options = short.chars().peekable();
while let Some(option) = options.next() {
if option == wanted {
return true;
}
if value_options.contains(&option) {
if options.peek().is_none() {
index += 1;
}
break;
}
}
index += 1;
}
false
}
fn shell_has_script_operand(args: &[String]) -> bool {
let mut index = 0;
while index < args.len() {
let arg = args[index].as_str();
if arg == "--" {
return index + 1 < args.len();
}
if !arg.starts_with('-') || arg == "-" {
return true;
}
let consumes_next = matches!(arg, "-O" | "-o" | "--init-file" | "--rcfile");
index += 1 + usize::from(consumes_next && index + 1 < args.len());
}
false
}
fn nested_command_carrier(verb: &str, args: &[String]) -> bool {
if verb == "eval" {
return true;
}
if verb == "env" {
return has_short_command_flag(args, 'S', &['a', 'C', 'S', 'u'])
|| args
.iter()
.any(|arg| arg == "--split-string" || arg.starts_with("--split-string="));
}
if matches!(
verb,
"bash" | "sh" | "zsh" | "dash" | "ksh" | "fish" | "ash"
) {
return has_short_command_flag(args, 'c', &['O', 'o'])
|| (verb == "fish"
&& (has_short_command_flag(args, 'C', &['C'])
|| args.iter().any(|arg| {
matches!(arg.as_str(), "--command" | "--init-command")
|| arg.starts_with("--command=")
|| arg.starts_with("--init-command=")
})))
|| !shell_has_script_operand(args);
}
if verb == "script" {
return has_short_command_flag(args, 'c', &['B', 'F', 'I', 'O', 'T', 'c', 'm', 't'])
|| args
.iter()
.any(|arg| arg == "--command" || arg.starts_with("--command="));
}
if verb == "perl" {
const PERL_VALUE_OPTIONS: &[char] =
&['0', 'C', 'D', 'F', 'I', 'M', 'V', 'd', 'i', 'l', 'm', 'x'];
return has_short_command_flag(args, 'e', PERL_VALUE_OPTIONS)
|| has_short_command_flag(args, 'E', PERL_VALUE_OPTIONS);
}
if matches!(verb, "ruby" | "node" | "osascript") {
let value_options: &[char] = match verb {
"ruby" => &['0', 'C', 'E', 'F', 'I', 'S', 'i', 'r', 'x'],
"node" => &['C', 'r'],
"osascript" => &['l'],
_ => unreachable!("matched interpreter"),
};
return has_short_command_flag(args, 'e', value_options)
|| (verb == "node"
&& args
.iter()
.any(|arg| arg == "--eval" || arg.starts_with("--eval=")));
}
if verb == "python"
|| verb == "py"
|| verb == "python3"
|| verb
.strip_prefix("python3.")
.is_some_and(|minor| minor.chars().all(|ch| ch.is_ascii_digit()))
{
return has_short_command_flag(args, 'c', &['W', 'X', 'Q', 'm']);
}
false
}
struct LeadingShellVariable<'a> {
modifier: &'a str,
suffix: &'a str,
}
fn shell_name_len(value: &str) -> usize {
let mut chars = value.char_indices();
let Some((_, first)) = chars.next() else {
return 0;
};
if first != '_' && !first.is_ascii_alphabetic() {
return 0;
}
chars
.take_while(|(_, ch)| *ch == '_' || ch.is_ascii_alphanumeric())
.last()
.map_or(first.len_utf8(), |(index, ch)| index + ch.len_utf8())
}
fn leading_shell_variable(candidate: &str) -> Option<LeadingShellVariable<'_>> {
if let Some(rest) = candidate.strip_prefix("${") {
let length = shell_name_len(rest);
if length == 0 {
return None;
}
let mut depth = 1usize;
let mut close = None;
let mut index = length;
while index < rest.len() {
if rest[index..].starts_with("${") {
depth += 1;
index += 2;
continue;
}
let ch = rest[index..].chars().next().expect("index is in bounds");
if ch == '}' {
depth -= 1;
if depth == 0 {
close = Some(index);
break;
}
}
index += ch.len_utf8();
}
let close = close?;
let suffix = &rest[close + 1..];
if !suffix.is_empty() && !suffix.starts_with('/') {
return None;
}
return Some(LeadingShellVariable {
modifier: &rest[length..close],
suffix,
});
}
let rest = candidate.strip_prefix('$')?;
let length = shell_name_len(rest);
if length == 0 {
return None;
}
let suffix = &rest[length..];
if !suffix.is_empty() && !suffix.starts_with('/') {
return None;
}
Some(LeadingShellVariable {
modifier: "",
suffix,
})
}
#[derive(Clone, Copy)]
struct ShellVariableReference<'a> {
name: &'a str,
start: usize,
end: usize,
}
fn shell_variable_references(candidate: &str) -> Result<Vec<ShellVariableReference<'_>>, ()> {
let mut references = Vec::new();
let mut cursor = 0;
while let Some(relative) = candidate[cursor..].find('$') {
let start = cursor + relative;
let after_dollar = start + 1;
let rest = &candidate[after_dollar..];
if let Some(braced) = rest.strip_prefix('{') {
let length = shell_name_len(braced);
if length == 0 || !braced[length..].starts_with('}') {
return Err(());
}
let end = after_dollar + 1 + length + 1;
references.push(ShellVariableReference {
name: &braced[..length],
start,
end,
});
cursor = end;
} else {
let length = shell_name_len(rest);
if length == 0 {
return Err(());
}
let end = after_dollar + length;
references.push(ShellVariableReference {
name: &rest[..length],
start,
end,
});
cursor = end;
}
}
Ok(references)
}
fn resolved_shell_variable<'a>(
gate: &'a DenyGovernedShellPathEscape,
name: &str,
) -> Option<&'a Path> {
match name {
"PWD" => Some(gate.worktree.as_path()),
"OLDPWD" if gate.oldpwd_is_worktree => Some(gate.worktree.as_path()),
"CARGO_TARGET_DIR" => gate.cargo_target_dir.as_deref(),
_ => None,
}
}
fn variable_operand_denial(
gate: &DenyGovernedShellPathEscape,
verb: &str,
candidate: &str,
) -> Option<String> {
if !candidate.contains('$') {
return None;
}
if let Some(operand) = leading_shell_variable(candidate) {
if operand.suffix.is_empty()
&& operand.modifier.is_empty()
&& gate.worktree.join(candidate).exists()
&& stays_under(&gate.worktree, candidate)
{
return None;
}
}
let references = match shell_variable_references(candidate) {
Ok(references) if !references.is_empty() => references,
_ => {
return Some(format!(
"'{verb}' variable operand cannot be inspected: '{candidate}'"
));
}
};
#[cfg(windows)]
let safe_relative_prefix = references.first().is_some_and(|reference| {
reference.start > 0
&& !Path::new(&candidate[..reference.start]).is_absolute()
&& !candidate[..reference.start].contains("..")
&& !candidate.contains("..")
});
let mut expanded = String::with_capacity(candidate.len());
let mut copied = 0;
for reference in references {
let Some(value) = resolved_shell_variable(gate, reference.name) else {
return Some(format!(
"'{verb}' variable operand cannot be inspected: '{candidate}'"
));
};
expanded.push_str(&candidate[copied..reference.start]);
expanded.push_str(&value.to_string_lossy());
copied = reference.end;
}
expanded.push_str(&candidate[copied..]);
#[cfg(windows)]
if safe_relative_prefix {
return None;
}
let expanded_path = Path::new(&expanded);
let stays_governed = if expanded_path.is_absolute() {
stays_under(&gate.worktree, &expanded)
|| gate
.cargo_target_dir
.as_deref()
.is_some_and(|root| stays_under(root, &expanded))
} else {
stays_under(&gate.worktree, &expanded)
};
if stays_governed {
None
} else {
Some(format!(
"'{verb}' variable path '{candidate}' resolves outside its governed root"
))
}
}
fn governed_operand_prefix_variables(segment: &[String]) -> BTreeSet<String> {
let mut names = BTreeSet::new();
let mut command = segment;
while let Some(verb_index) = shell_verb_index(command) {
let verb = executable_name(&command[verb_index]);
if READ_OR_CHDIR_VERBS.contains(&verb.as_str()) {
if let Ok(paths) = governed_path_arguments(&verb, command) {
for path in paths {
let candidate = path
.trim_matches(|c: char| matches!(c, '"' | '\'' | '(' | ')' | ',' | ';'));
if let Ok(references) = shell_variable_references(candidate) {
if let Some(reference) = references.first().filter(|item| item.start == 0) {
names.insert(reference.name.to_string());
}
}
}
}
}
let Some(invocation) = carrier_command(command, verb_index, &verb) else {
break;
};
if invocation.command.is_empty() {
break;
}
command = invocation.command;
}
names
}
fn shell_root_escape(candidate: &str) -> bool {
["HOME", "TMPDIR"].iter().any(|name| {
let unbraced = format!("${name}");
let unbraced_match = candidate.strip_prefix(&unbraced).is_some_and(|suffix| {
suffix
.chars()
.next()
.is_none_or(|ch| ch != '_' && !ch.is_ascii_alphanumeric())
});
let braced = format!("${{{name}");
let braced_match = candidate.strip_prefix(&braced).is_some_and(|suffix| {
suffix
.chars()
.next()
.is_some_and(|ch| ch != '_' && !ch.is_ascii_alphanumeric())
});
unbraced_match || braced_match
})
}
fn execution_redirecting_variable(name: &str) -> bool {
matches!(
name,
"BASH_ENV"
| "ENV"
| "PATH"
| "LD_PRELOAD"
| "PYTHONPATH"
| "PERL5LIB"
| "RUBYLIB"
| "NODE_OPTIONS"
| "CARGO_HOME"
| "RUSTUP_HOME"
| "GIT_EXEC_PATH"
| "GIT_SSH_COMMAND"
) || name.starts_with("DYLD_")
}
fn command_opens_editor(verb: &str, args: &[String]) -> bool {
(verb == "git"
&& args.iter().any(|arg| {
matches!(
arg.as_str(),
"add" | "commit" | "config" | "merge" | "rebase" | "tag"
)
}))
|| matches!(verb, "crontab" | "vipw" | "vigr" | "visudo")
}
fn assignment_value_names_path(value: &str) -> bool {
value.contains('/')
|| value.contains('\\')
|| value.starts_with('~')
|| shell_root_escape(value)
|| value.contains("..")
|| (value.as_bytes().get(1) == Some(&b':')
&& value
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphabetic))
}
fn effective_command(mut command: &[String]) -> Option<(String, &[String])> {
loop {
let verb_index = shell_verb_index(command)?;
let current_verb = executable_name(&command[verb_index]);
let args = &command[verb_index + 1..];
let Some(invocation) = carrier_command(command, verb_index, ¤t_verb) else {
return Some((current_verb, args));
};
if invocation.command.is_empty() {
return Some((current_verb, args));
}
command = invocation.command;
}
}
struct AssignmentBuiltinOperands<'a> {
assignments: Vec<&'a str>,
removed_names: Vec<&'a str>,
}
fn assignment_builtin_operands<'a>(
command: &'a [String],
verb_index: usize,
verb: &str,
) -> AssignmentBuiltinOperands<'a> {
let args = &command[verb_index + 1..];
let assignment_builtin = matches!(
verb,
"export" | "readonly" | "declare" | "typeset" | "local"
);
let assignments = if assignment_builtin {
args.iter()
.map(String::as_str)
.filter(|arg| shell_assignment(arg))
.collect()
} else {
Vec::new()
};
let removes_export_attribute = verb == "export"
&& args
.iter()
.take_while(|arg| arg.as_str() != "--")
.filter_map(|arg| arg.strip_prefix('-'))
.any(|options| !options.starts_with('-') && options.contains('n'));
let removes_variables = verb == "unset" || removes_export_attribute;
let removed_names = if removes_variables {
args.iter()
.skip_while(|arg| arg.starts_with('-') && arg.as_str() != "--")
.filter(|arg| arg.as_str() != "--")
.map(String::as_str)
.filter(|arg| shell_name_len(arg) == arg.len())
.collect()
} else {
Vec::new()
};
AssignmentBuiltinOperands {
assignments,
removed_names,
}
}
fn assignment_name_denial(
name: &str,
opens_editor: bool,
operand_prefix_variables: &BTreeSet<String>,
) -> Option<String> {
if execution_redirecting_variable(name) || (matches!(name, "EDITOR" | "VISUAL") && opens_editor)
{
return Some(format!(
"environment assignment '{name}' may redirect executable code in a governed session"
));
}
if matches!(
name,
"PWD" | "OLDPWD" | "CARGO_TARGET_DIR" | "HOME" | "TMPDIR" | "IFS" | "PATH"
) || operand_prefix_variables.contains(name)
{
return Some(format!(
"environment assignment '{name}' may change a governed path operand"
));
}
None
}
fn assignment_denial(
worktree: &Path,
command: &[String],
operand_prefix_variables: &BTreeSet<String>,
) -> Option<String> {
let opens_editor =
effective_command(command).is_some_and(|(verb, args)| command_opens_editor(&verb, args));
let mut current = command;
loop {
let verb_index = shell_verb_index(current).unwrap_or(current.len());
let invocation = if verb_index < current.len() {
let current_verb = executable_name(¤t[verb_index]);
carrier_command(current, verb_index, ¤t_verb)
} else {
None
};
let carrier_assignments = invocation
.as_ref()
.into_iter()
.flat_map(|invocation| invocation.assignments.iter().copied());
let builtin_operands = if verb_index < current.len() {
assignment_builtin_operands(current, verb_index, &executable_name(¤t[verb_index]))
} else {
AssignmentBuiltinOperands {
assignments: Vec::new(),
removed_names: Vec::new(),
}
};
let assignments = leading_shell_assignments(current)
.into_iter()
.chain(carrier_assignments)
.chain(builtin_operands.assignments);
for assignment in assignments {
let Some(parts) = shell_assignment_parts(assignment) else {
continue;
};
if parts
.subscript
.is_some_and(shell_subscript_contains_command_substitution)
{
return Some(format!(
"environment assignment '{}' has a subscript that cannot be inspected",
parts.name
));
}
if let Some(reason) =
assignment_name_denial(parts.name, opens_editor, operand_prefix_variables)
{
return Some(reason);
}
if assignment_value_names_path(parts.value) && !stays_under(worktree, parts.value) {
return Some(format!(
"environment assignment '{}' names path '{}' outside the governed repository",
parts.name, parts.value
));
}
}
for name in builtin_operands.removed_names {
if let Some(reason) =
assignment_name_denial(name, opens_editor, operand_prefix_variables)
{
return Some(reason);
}
}
let Some(invocation) = invocation else {
break;
};
if invocation.command.is_empty() {
break;
}
current = invocation.command;
}
None
}
fn uninspectable_subscript_denial(
command: &str,
segments: &[Vec<String>],
operand_prefix_variables: &BTreeSet<String>,
) -> Option<String> {
let candidates = uninspectable_assignment_subscripts(command);
if candidates.is_empty() {
return None;
}
let opens_editor = segments.iter().any(|segment| {
effective_command(segment).is_some_and(|(verb, args)| command_opens_editor(&verb, args))
});
for (name, flaw) in candidates {
match flaw {
SubscriptFlaw::CommandSubstitution => {
return Some(format!(
"environment assignment '{name}' has a subscript that cannot be inspected"
));
}
SubscriptFlaw::BreaksTokenization => {
if let Some(reason) =
assignment_name_denial(name, opens_editor, operand_prefix_variables)
{
return Some(reason);
}
}
}
}
None
}
impl Inspector for DenyGovernedShellPathEscape {
fn name(&self) -> &'static str {
"governed_host.deny_shell_path_escape"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
let segments = governed_shell_segments(&cmd);
let mut future_operand_prefix_variables = vec![BTreeSet::new(); segments.len()];
let mut suffix_variables = BTreeSet::new();
for (index, segment) in segments.iter().enumerate().rev() {
suffix_variables.extend(governed_operand_prefix_variables(segment));
future_operand_prefix_variables[index] = suffix_variables.clone();
}
if let Some(reason) = uninspectable_subscript_denial(&cmd, &segments, &suffix_variables) {
return InspectionResult::Deny(reason);
}
for (index, seg) in segments.into_iter().enumerate() {
if let Some(reason) = assignment_denial(
&self.worktree,
&seg,
&future_operand_prefix_variables[index],
) {
return InspectionResult::Deny(reason);
}
let mut command = seg.as_slice();
while let Some(verb_index) = shell_verb_index(command) {
let v = executable_name(&command[verb_index]);
let args = &command[verb_index + 1..];
if v == "busybox"
&& args
.iter()
.any(|arg| arg == "--install" || arg.starts_with("--install="))
{
return InspectionResult::Deny(
"busybox --install may write outside the governed repository".into(),
);
}
if nested_command_carrier(&v, args) {
return InspectionResult::Deny(format!(
"nested shell command through '{v}' cannot be inspected in a governed session"
));
}
if READ_OR_CHDIR_VERBS.contains(&v.as_str()) {
let governed_args = match governed_path_arguments(&v, command) {
Ok(paths) => paths,
Err(reason) => return InspectionResult::Deny(reason),
};
for arg in governed_args {
let candidate = arg.trim_matches(|c: char| {
matches!(c, '"' | '\'' | '(' | ')' | ',' | ';')
});
if let Some(reason) = variable_operand_denial(self, &v, candidate) {
return InspectionResult::Deny(reason);
}
let names_path = candidate.starts_with('~')
|| shell_root_escape(candidate)
|| is_abs_or_traversal(candidate)
|| self.worktree.join(candidate).exists();
if names_path && !stays_under(&self.worktree, candidate) {
return InspectionResult::Deny(format!(
"'{v}' path '{candidate}' resolves outside the governed repository"
));
}
}
}
let Some(invocation) = carrier_command(command, verb_index, &v) else {
break;
};
if invocation.command.is_empty() {
break;
}
command = invocation.command;
}
}
InspectionResult::Allow
}
}
struct DenyGovernedFilePathEscape {
worktree: PathBuf,
}
impl Inspector for DenyGovernedFilePathEscape {
fn name(&self) -> &'static str {
"governed_host.deny_file_path_escape"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
if !matches!(
tool,
"read_file" | "write_file" | "edit_file" | "grep_files"
) {
return InspectionResult::Allow;
}
let Some(path) = params.get("path").and_then(Value::as_str) else {
return InspectionResult::Allow;
};
if stays_under(&self.worktree, path) {
InspectionResult::Allow
} else {
InspectionResult::Deny(format!(
"file access to '{path}' resolves outside the governed repository"
))
}
}
}
pub fn governed_host_inspector_chain(worktree: &Path) -> InspectorChain {
InspectorChain::new()
.with(Box::new(DenyShellFunctionDeclaration))
.with(Box::new(DenyGuiShellAutomation))
.with(Box::new(DenyForcePushAndRemoteReconfiguration))
.with(Box::new(DenyBroadGitStage))
.with(Box::new(DenyHistoryRewrite))
.with(Box::new(DenyPrivilegeEscalation))
.with(Box::new(DenyCredentialAccess))
.with(Box::new(DenyEnvironmentRepair))
.with(Box::new(DenyDestructiveOutsideWorktree {
worktree: worktree.to_path_buf(),
}))
.with(Box::new(DenyGovernedShellPathEscape::new(worktree)))
.with(Box::new(DenyGovernedFilePathEscape {
worktree: worktree.to_path_buf(),
}))
}
struct DenyGuiShellAutomation;
impl Inspector for DenyGuiShellAutomation {
fn name(&self) -> &'static str {
"governed_host.deny_gui_shell_automation"
}
fn inspect(&self, tool: &str, _params: &Value) -> InspectionResult {
if matches!(tool, "run_applescript" | "run_powershell") {
InspectionResult::Deny(
"desktop-driven shell execution is not allowed; use the governed shell tool".into(),
)
} else {
InspectionResult::Allow
}
}
}
struct DenyBroadGitStage;
impl Inspector for DenyBroadGitStage {
fn name(&self) -> &'static str {
"governed_host.deny_broad_git_stage"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
if verb(&seg) != Some("git") {
continue;
}
let add = seg.iter().position(|token| token == "add");
if let Some(index) = add {
if seg
.iter()
.skip(index + 1)
.any(|token| matches!(token.as_str(), "." | "-A" | "--all" | "-u" | "--update"))
{
return InspectionResult::Deny(
"broad git staging is not allowed; name only the files changed for this task"
.into(),
);
}
}
if seg.iter().any(|token| token == "commit")
&& seg.iter().any(|token| {
token == "--all"
|| token
.strip_prefix('-')
.filter(|short| !short.starts_with('-'))
.is_some_and(|short| short.contains('a'))
})
{
return InspectionResult::Deny(
"git commit -a is not allowed; stage only explicit task files".into(),
);
}
}
InspectionResult::Allow
}
}
pub(crate) fn stays_under(root: &Path, candidate: &str) -> bool {
if candidate.starts_with('~') || shell_root_escape(candidate) {
return false;
}
if let Some(variable) = leading_shell_variable(candidate) {
if !variable.suffix.is_empty()
|| !variable.modifier.is_empty()
|| !root.join(candidate).exists()
{
return false;
}
}
let p = Path::new(candidate);
let joined = if p.is_absolute() {
p.to_path_buf()
} else {
root.join(p)
};
if joined.exists() {
if let (Ok(real_root), Ok(real_candidate)) = (root.canonicalize(), joined.canonicalize()) {
return path_starts_with(&real_candidate, &real_root);
}
return false;
}
if let Ok(real_root) = root.canonicalize() {
let mut ancestor = joined.as_path();
while !ancestor.exists() {
let Some(parent) = ancestor.parent() else {
return false;
};
ancestor = parent;
}
match ancestor.canonicalize() {
Ok(real_ancestor) if path_starts_with(&real_ancestor, &real_root) => {}
_ => return false,
}
}
let mut stack: Vec<Component> = Vec::new();
for c in joined.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
if stack.pop().is_none() {
return false;
}
}
other => stack.push(other),
}
}
let normalized: PathBuf = stack.iter().collect();
path_starts_with(&normalized, root)
}
#[cfg(not(windows))]
fn path_starts_with(path: &Path, base: &Path) -> bool {
path.starts_with(base)
}
#[cfg(windows)]
fn path_starts_with(path: &Path, base: &Path) -> bool {
fn key(p: &Path) -> String {
let s = p.to_string_lossy().into_owned();
let s = if let Some(r) = s.strip_prefix(r"\\?\UNC\") {
format!(r"\\{r}")
} else if let Some(r) = s.strip_prefix(r"\\?\") {
r.to_string()
} else {
s
};
s.replace('/', "\\").to_ascii_lowercase()
}
let base_key = key(base);
let base_trim = base_key.trim_end_matches('\\');
let path_key = key(path);
path_key == base_trim || path_key.starts_with(&format!("{base_trim}\\"))
}
fn is_abs_or_traversal(arg: &str) -> bool {
arg.starts_with('/')
|| arg.starts_with('\\')
|| arg.contains("..")
|| Path::new(arg).is_absolute()
}
fn is_windows_switch(arg: &str) -> bool {
#[cfg(not(windows))]
{
let _ = arg;
false
}
#[cfg(windows)]
{
arg.strip_prefix('/')
.map(|rest| {
(1..=2).contains(&rest.len()) && rest.chars().all(|c| c.is_ascii_alphanumeric())
})
.unwrap_or(false)
}
}
fn segments(command: &str) -> Vec<Vec<String>> {
let outer: Vec<Vec<String>> =
split_shell_case_arm_bodies(&join_shell_line_continuations(command))
.replace("&&", "\n")
.replace("||", "\n")
.replace(['ï¼›', ';', '|'], "\n")
.lines()
.map(|seg| {
seg.split_whitespace()
.map(|t| t.trim_matches(|c| c == '"' || c == '\'').to_string())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
})
.filter(|toks: &Vec<String>| !toks.is_empty())
.collect();
let mut expanded = Vec::new();
for segment in outer {
expanded.push(segment.clone());
let mut command = segment.as_slice();
while let Some(verb_index) = shell_verb_index(command) {
let v = executable_name(&command[verb_index]);
let Some(invocation) = carrier_command(command, verb_index, &v) else {
break;
};
if invocation.command.is_empty() {
break;
}
expanded.push(invocation.command.to_vec());
command = invocation.command;
}
}
expanded
}
fn verb(tokens: &[String]) -> Option<&str> {
shell_verb_index(tokens).map(|index| tokens[index].as_str())
}
fn shell_command(tool: &str, params: &Value) -> Option<String> {
if tool != "shell" {
return None;
}
params
.get("command")
.and_then(Value::as_str)
.map(str::to_string)
}
struct DenyGitRemoteMutation;
impl Inspector for DenyGitRemoteMutation {
fn name(&self) -> &'static str {
"coder.deny_git_remote_mutation"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
let is_git = verb(&seg) == Some("git");
if !is_git {
continue;
}
if seg.iter().any(|t| t == "push") {
return InspectionResult::Deny(
"git push is not allowed from a coder session — results are delivered \
via the approved local branch"
.into(),
);
}
if seg.iter().any(|t| t == "remote")
&& seg
.iter()
.any(|t| t == "add" || t == "set-url" || t == "remove")
{
return InspectionResult::Deny("mutating git remotes is not allowed".into());
}
}
InspectionResult::Allow
}
}
struct DenyForgePublication;
const FORGE_VERBS: &[&str] = &["gh", "glab", "hub"];
const FORGE_READS: &[(&str, &[&str])] = &[
("pr", &["view", "list", "diff", "checks", "status"]),
("mr", &["view", "list", "diff", "checks", "status"]),
("issue", &["view", "list"]),
("repo", &["view"]),
("run", &["view", "list", "watch"]),
("release", &["view", "list"]),
("workflow", &["view", "list"]),
("label", &["list"]),
("cache", &["list"]),
("gist", &["view", "list"]),
("auth", &["status"]),
("search", &[]),
("status", &[]),
("version", &[]),
];
const FORGE_VALUE_FLAGS: &[&str] = &["-r", "--repo", "--hostname"];
const PUBLICATION_COMMANDS: &[(&str, &[&str])] = &[
("npm", &["publish"]),
("pnpm", &["publish"]),
("yarn", &["publish"]),
("cargo", &["publish"]),
("gem", &["push"]),
("twine", &["upload"]),
("docker", &["push", "login"]),
];
impl Inspector for DenyForgePublication {
fn name(&self) -> &'static str {
"coder.deny_forge_publication"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
let Some(v) = verb(&seg) else { continue };
let v = Path::new(&v.to_ascii_lowercase())
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
let v = v.strip_suffix(".exe").unwrap_or(&v).to_string();
let args: Vec<String> = seg.iter().skip(1).map(|a| a.to_ascii_lowercase()).collect();
if FORGE_VERBS.contains(&v.as_str()) {
if let Some(reason) = forge_denial(&v, &args) {
return InspectionResult::Deny(reason);
}
continue;
}
for (mgr, subs) in PUBLICATION_COMMANDS {
if v != *mgr {
continue;
}
if leading_operands(&args).iter().any(|sub| subs.contains(sub)) {
return InspectionResult::Deny(format!(
"'{mgr}' publication is not allowed from a coder session — results \
leave the worktree only through the approved merge branch"
));
}
}
}
InspectionResult::Allow
}
}
fn forge_operands(args: &[String]) -> Vec<&str> {
let mut operands = Vec::new();
let mut skip_value = false;
for arg in args {
if std::mem::take(&mut skip_value) {
continue;
}
if FORGE_VALUE_FLAGS.contains(&arg.as_str()) {
skip_value = true;
continue;
}
if arg.starts_with('-') {
continue;
}
operands.push(arg.as_str());
}
operands
}
fn leading_operands(args: &[String]) -> Vec<&str> {
args.iter()
.map(String::as_str)
.filter(|a| !a.starts_with('-') && !a.starts_with('+'))
.take(2)
.collect()
}
fn forge_denial(verb: &str, args: &[String]) -> Option<String> {
const BLOCKED: &str = "publishing from a coder session is not allowed — the runtime opens \
the pull request after `coder.approve_merge`";
if args
.iter()
.any(|a| matches!(a.as_str(), "--version" | "--help"))
{
return None;
}
let operands = forge_operands(args);
let Some(group) = operands.first().copied() else {
return None; };
if group == "api" {
let is_write_method = |v: &str| !v.is_empty() && v != "get";
let explicit_method = args
.windows(2)
.any(|pair| matches!(pair[0].as_str(), "--method" | "-x") && is_write_method(&pair[1]))
|| args.iter().any(|a| {
a.strip_prefix("--method=")
.or_else(|| a.strip_prefix("-x"))
.is_some_and(is_write_method)
});
let field_flag = |a: &String| {
matches!(a.as_str(), "-f" | "--field" | "--raw-field" | "--input")
|| a.starts_with("--field=")
|| a.starts_with("--raw-field=")
|| a.starts_with("--input=")
|| a.starts_with("-f")
};
let graphql = operands.get(1).is_some_and(|o| *o == "graphql");
let mutating_graphql = graphql
&& args
.iter()
.any(|a| a.contains("mutation") || a.contains("deletion"));
let implicit_post = !graphql && args.iter().any(field_flag);
return (explicit_method || implicit_post || mutating_graphql)
.then(|| format!("'{verb} api' with a write method is not allowed — {BLOCKED}"));
}
if group == "auth"
&& args
.iter()
.any(|a| a == "-t" || a == "--show-token" || a.starts_with("--show-token="))
{
return Some(format!(
"'{verb} auth status --show-token' prints the forge credential — {BLOCKED}"
));
}
let Some((_, subs)) = FORGE_READS.iter().find(|(g, _)| *g == group) else {
return Some(format!("'{verb} {group}' is not allowed — {BLOCKED}"));
};
if subs.is_empty() {
return None;
}
match operands.get(1).copied() {
Some(sub) if subs.contains(&sub) => None,
Some(sub) => Some(format!("'{verb} {group} {sub}' is not allowed — {BLOCKED}")),
None => Some(format!(
"'{verb} {group}' without a read-only subcommand is not allowed — {BLOCKED}"
)),
}
}
struct DenyForcePushAndRemoteReconfiguration;
impl Inspector for DenyForcePushAndRemoteReconfiguration {
fn name(&self) -> &'static str {
"governed_host.deny_force_push_and_remote_reconfiguration"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
if verb(&seg) != Some("git") {
continue;
}
let push = seg.iter().any(|token| token == "push");
let forced = seg.iter().any(|token| {
token == "--force"
|| token == "-f"
|| token.starts_with("--force-with-lease")
|| token.starts_with('+')
});
if push && forced {
return InspectionResult::Deny("force-push is never allowed".into());
}
if seg.iter().any(|token| token == "remote")
&& seg.iter().any(|token| {
token == "add" || token == "set-url" || token == "remove" || token == "rename"
})
{
return InspectionResult::Deny(
"mutating git remote configuration is not allowed".into(),
);
}
}
InspectionResult::Allow
}
}
struct DenyHistoryRewrite;
impl Inspector for DenyHistoryRewrite {
fn name(&self) -> &'static str {
"coder.deny_history_rewrite"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
if verb(&seg) != Some("git") {
continue;
}
if seg.iter().any(|t| t == "rebase" || t == "filter-branch") {
return InspectionResult::Deny("git history rewrite is not allowed".into());
}
if seg.iter().any(|t| t == "reset") && seg.iter().any(|t| t == "--hard") {
return InspectionResult::Deny("git reset --hard is not allowed".into());
}
if seg.iter().any(|t| t == "worktree") && seg.iter().any(|t| t == "remove") {
return InspectionResult::Deny(
"removing worktrees is the runtime's job, not the agent's".into(),
);
}
}
InspectionResult::Allow
}
}
struct DenyPrivilegeEscalation;
const PRIVILEGE_VERBS: &[&str] = &[
"sudo",
"doas",
"su",
"launchctl",
"systemctl", "runas",
"sc",
"psexec",
];
impl Inspector for DenyPrivilegeEscalation {
fn name(&self) -> &'static str {
"coder.deny_privilege_escalation"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
if let Some(v) = verb(&seg) {
if PRIVILEGE_VERBS.contains(&v.to_ascii_lowercase().as_str()) {
return InspectionResult::Deny(format!(
"'{v}' is not allowed in a coder session"
));
}
}
}
InspectionResult::Allow
}
}
struct DenyCredentialAccess;
const CREDENTIAL_PATH_MARKERS: [&str; 6] = [
"/.ssh",
"/.aws",
"/.gnupg",
"/.kube",
"/.car/secrets",
"/.netrc",
];
impl Inspector for DenyCredentialAccess {
fn name(&self) -> &'static str {
"coder.deny_credential_access"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let haystacks: Vec<String> = if let Some(cmd) = shell_command(tool, params) {
if cmd.contains("find-generic-password") || cmd.contains("find-internet-password") {
return InspectionResult::Deny("keychain access is not allowed".into());
}
let cmd_lower = cmd.to_ascii_lowercase();
if cmd_lower.contains("cmdkey") || cmd_lower.contains("vaultcmd") {
return InspectionResult::Deny(
"Windows Credential Manager access is not allowed".into(),
);
}
let sensitive_env = [
"_key",
"_token",
"_secret",
"_password",
"openai_",
"anthropic_",
"azure_client_",
"github_token",
"connection_string",
];
if sensitive_env
.iter()
.any(|marker| cmd_lower.contains(marker))
{
return InspectionResult::Deny(
"reading or expanding credential environment variables is not allowed".into(),
);
}
for seg in segments(&cmd) {
let Some(command) = verb(&seg).map(|value| value.to_ascii_lowercase()) else {
continue;
};
if command == "env" && seg.len() == 1
|| command == "printenv"
|| command == "set" && seg.len() == 1
{
return InspectionResult::Deny(
"dumping the process environment is not allowed".into(),
);
}
}
vec![cmd]
} else if matches!(
tool,
"read_file" | "write_file" | "edit_file" | "grep_files"
) {
params
.get("path")
.and_then(Value::as_str)
.map(|p| vec![p.to_string()])
.unwrap_or_default()
} else {
return InspectionResult::Allow;
};
for hay in &haystacks {
let hay = hay.replace('\\', "/");
let hay = hay
.replace("~/", "/HOME/.")
.replace("$HOME/", "/HOME/.")
.replace("%USERPROFILE%/", "/HOME/.")
.replace("%HOMEPATH%/", "/HOME/.");
let hay = hay.replace("/HOME/..", "/."); for marker in CREDENTIAL_PATH_MARKERS {
if hay.contains(marker) {
return InspectionResult::Deny(format!(
"access to credential path matching '{marker}' is not allowed"
));
}
}
}
InspectionResult::Allow
}
}
struct DenyDestructiveOutsideWorktree {
worktree: PathBuf,
}
const DESTRUCTIVE_VERBS: &[&str] = &[
"rm", "rmdir", "mv", "cp", "chmod", "chown", "truncate", "dd", "del", "erase", "rd", "move", "copy", "format", "ren", "rename",
];
impl Inspector for DenyDestructiveOutsideWorktree {
fn name(&self) -> &'static str {
"coder.deny_destructive_outside_worktree"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
let Some(v) = verb(&seg) else { continue };
let v_lower = v.to_ascii_lowercase();
if !DESTRUCTIVE_VERBS.contains(&v_lower.as_str()) {
continue;
}
for arg in seg
.iter()
.skip(1)
.filter(|a| !a.starts_with('-') && !is_windows_switch(a))
{
if arg.starts_with('~') {
return InspectionResult::Deny(format!(
"'{v}' on a home-relative path ('{arg}') is not allowed"
));
}
if is_abs_or_traversal(arg) && !stays_under(&self.worktree, arg) {
return InspectionResult::Deny(format!(
"'{v}' outside the worktree ('{arg}') is not allowed"
));
}
}
}
InspectionResult::Allow
}
}
struct DenyEnvironmentRepair;
const PACKAGE_MUTATIONS: &[(&str, &[&str])] = &[
("pip", &["install", "uninstall"]),
("pip3", &["install", "uninstall"]),
("conda", &["install", "remove", "uninstall", "update"]),
("poetry", &["add", "remove", "install", "update"]),
("uv", &["add", "remove", "sync"]),
("easy_install", &[]),
];
const SHIM_FILES: &[&str] = &["sitecustomize.py", "usercustomize.py"];
impl Inspector for DenyEnvironmentRepair {
fn name(&self) -> &'static str {
"coder.deny_environment_repair"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
if matches!(tool, "write_file" | "edit_file") {
let path = params.get("path").and_then(Value::as_str).unwrap_or("");
let base = Path::new(path)
.file_name()
.map(|f| f.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
if SHIM_FILES.contains(&base.as_str()) {
return InspectionResult::Deny(format!(
"writing '{base}' changes how the interpreter loads, not what your code \
does — the runtime re-runs the contract in the correct environment"
));
}
return InspectionResult::Allow;
}
let Some(cmd) = shell_command(tool, params) else {
return InspectionResult::Allow;
};
for seg in segments(&cmd) {
let Some(v) = verb(&seg) else { continue };
let v = Path::new(&v.to_ascii_lowercase())
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
let v = v.strip_suffix(".exe").unwrap_or(&v).to_string();
let args: Vec<String> = seg.iter().skip(1).map(|a| a.to_ascii_lowercase()).collect();
let module = args
.iter()
.position(|a| a == "-m")
.and_then(|i| args.get(i + 1))
.cloned();
let (effective, effective_args): (String, Vec<String>) = match module {
Some(m) if v.starts_with("python") || v.starts_with("py") => {
let rest = args
.iter()
.skip_while(|a| **a != m)
.skip(1)
.cloned()
.collect();
(m, rest)
}
_ => (v.clone(), args.clone()),
};
if effective == "venv" || effective == "virtualenv" {
return InspectionResult::Deny(
"creating an interpreter is environment repair, not part of the task — \
the runtime re-runs the contract in the correct environment"
.into(),
);
}
for (mgr, subs) in PACKAGE_MUTATIONS {
if effective != *mgr {
continue;
}
let mutates = subs.is_empty()
|| effective_args.iter().any(|a| subs.contains(&a.as_str()))
|| (effective == "uv" && effective_args.iter().any(|a| a == "install"));
if mutates {
return InspectionResult::Deny(format!(
"'{mgr}' package mutation is environment repair, not part of the task \
— the runtime re-runs the contract in the correct environment"
));
}
}
}
InspectionResult::Allow
}
}
struct DenyPathEscape {
worktree: PathBuf,
}
impl Inspector for DenyPathEscape {
fn name(&self) -> &'static str {
"coder.deny_path_escape"
}
fn inspect(&self, tool: &str, params: &Value) -> InspectionResult {
if !matches!(tool, "write_file" | "edit_file") {
return InspectionResult::Allow;
}
let Some(path) = params.get("path").and_then(Value::as_str) else {
return InspectionResult::Allow; };
if stays_under(&self.worktree, path) {
InspectionResult::Allow
} else {
InspectionResult::Deny(format!("write to '{path}' resolves outside the worktree"))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn chain() -> InspectorChain {
coder_inspector_chain(Path::new("/wt"))
}
fn denied(tool: &str, params: Value) -> bool {
chain().check(tool, ¶ms).is_some()
}
fn sh(cmd: &str) -> Value {
json!({ "command": cmd })
}
fn write_policy(dir: &Path, body: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("rules.toml"), body).unwrap();
}
#[test]
fn coder_chain_merges_machine_and_project_deny_rules() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().join("repo");
let machine = root.path().join("machine-policies");
let project = repo.join(".car").join("policies");
std::fs::create_dir_all(&repo).unwrap();
write_policy(&machine, "deny_tool = [\"write_file\"]\n");
write_policy(&project, "deny_keyword = [\"DO NOT RUN\"]\n");
let policy =
coder_inspector_chain_from_policy_dirs(&repo, &[machine.clone(), project.clone()])
.unwrap();
assert_eq!(
policy.denied_tools.iter().cloned().collect::<Vec<_>>(),
vec!["write_file".to_string()]
);
let chain = policy.chain;
assert!(chain
.check("write_file", &json!({"path": "x", "content": "ok"}))
.is_some());
assert!(chain
.check("shell", &json!({"command": "echo DO NOT RUN"}))
.is_some());
assert!(chain.check("read_file", &json!({"path": "x"})).is_none());
}
#[test]
fn built_in_denial_reason_wins_before_project_policy() {
let root = tempfile::tempdir().unwrap();
let policies = root.path().join("policies");
write_policy(&policies, "deny_tool = [\"shell\"]\n");
let chain = coder_inspector_chain_from_policy_dirs(root.path(), &[policies])
.unwrap()
.chain;
let reason = chain
.check("shell", &sh("git push origin main"))
.expect("both rules deny");
assert!(
reason.contains("git push"),
"built-in reason must win: {reason}"
);
assert!(
!reason.contains("operator policy"),
"wrong precedence: {reason}"
);
}
#[test]
fn coder_chain_denies_shell_function_declarations() {
for command in [
":(){ :|:& };:",
"function f { printf harmless; }",
"f() { printf harmless; }",
"(f() { printf harmless; })",
"case x in x) f() { printf harmless; };; esac",
] {
let reason = chain()
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("function declaration must be denied: {command}"));
assert!(
reason.contains("shell function declaration"),
"unexpected denial for {command}: {reason}"
);
}
}
#[test]
fn coder_chain_allows_function_declaration_text_inside_quotes() {
for command in [
"printf '%s\\n' ':(){ :|:& };:'",
"echo \"function f { printf harmless; }\"",
"grep 'f() { printf harmless; }' README.md",
] {
assert!(
chain().check("shell", &sh(command)).is_none(),
"quoted declaration text must stay inert: {command}"
);
}
}
#[test]
fn malformed_or_unenforced_policy_refuses_chain_construction() {
let root = tempfile::tempdir().unwrap();
let malformed = root.path().join("malformed");
write_policy(&malformed, "deny_tool = [not valid TOML\n");
assert!(coder_inspector_chain_from_policy_dirs(root.path(), &[malformed]).is_err());
let trace = root.path().join("trace");
write_policy(
&trace,
"[[trace_rule]]\nkind = \"never\"\ntool = \"deploy\"\n",
);
let err = coder_inspector_chain_from_policy_dirs(root.path(), &[trace])
.err()
.expect("trace rules are deliberately unenforced");
assert!(err.to_string().contains("not enforced"), "{err}");
}
#[test]
fn denies_package_mutation_and_interpreter_creation() {
for cmd in [
"pip install requests",
"pip3 uninstall -y six",
"python -m pip install --upgrade pip",
"/usr/bin/python3.11 -m pip install pytest",
"conda install numpy",
"poetry add httpx",
"uv pip install ruff",
"python -m venv .venv",
"virtualenv env",
"easy_install foo",
"cd /wt && pip install -e .",
] {
assert!(denied("shell", sh(cmd)), "should be denied: {cmd}");
}
}
#[test]
fn allows_read_only_package_queries_and_real_test_runs() {
for cmd in [
"pip list",
"pip show pytest",
"python -m pytest -q tests/test_x.py",
"/wt/.venv/bin/python -m pytest -q tests/test_x.py",
"cargo test -p car-engine",
"npm test",
] {
assert!(!denied("shell", sh(cmd)), "should be allowed: {cmd}");
}
}
#[test]
fn denies_interpreter_shims_but_not_ordinary_test_config() {
assert!(denied(
"write_file",
json!({ "path": "sitecustomize.py", "content": "x" })
));
assert!(denied(
"write_file",
json!({ "path": "src/usercustomize.py", "content": "x" })
));
for path in ["conftest.py", "pyproject.toml", "tox.ini", "setup.cfg"] {
assert!(
!denied("write_file", json!({ "path": path, "content": "x" })),
"must stay allowed: {path}"
);
}
}
#[test]
fn git_push_and_remote_mutation_denied() {
assert!(denied("shell", sh("git push origin main")));
assert!(denied("shell", sh("cargo test && git push --force")));
assert!(denied("shell", sh("git remote add evil https://x")));
assert!(denied("shell", sh("git remote set-url origin https://x")));
assert!(!denied("shell", sh("git remote -v")));
assert!(!denied("shell", sh("git commit -m 'x'")));
assert!(!denied("shell", sh("git status && git diff")));
assert!(!denied("shell", sh("echo push")));
}
#[test]
fn forge_publication_denied_but_reads_allowed() {
for cmd in [
"gh pr create --fill",
"gh pr merge --admin",
"gh api --method DELETE /repos/o/r/branches/main/protection",
"gh api -X POST /repos/o/r/issues",
"gh api repos/o/r/issues -f title=x",
"gh release create v9.9.9 ./x",
"gh auth token",
"gh repo fork",
"glab mr create",
"npm publish",
"cargo publish",
"docker push img",
"docker login ghcr.io",
"cargo test && gh pr create",
"/opt/homebrew/bin/gh pr create --fill",
"gh release create v9.9.9 --notes -h",
"gh pr create --title -h --body b --head mybranch --base main",
"gh auth status -t",
"gh auth status --show-token",
"gh api -XPOST repos/o/r/pulls --input=-",
"gh api -XPOST repos/o/r/pulls --field=title=x",
"gh api --method=post repos/o/r/pulls",
"gh api repos/o/r/issues --raw-field=title=x",
"gh api graphql --field=query=mutation{createpullrequest}",
"cargo +stable publish",
"docker image push img",
"npm --workspace x publish",
] {
assert!(denied("shell", sh(cmd)), "should be denied: {cmd}");
}
for cmd in [
"gh pr view 12",
"gh pr checks",
"gh pr diff 12",
"gh issue list",
"gh run view 5",
"gh run watch 5",
"gh api repos/o/r",
"gh api --method GET /repos/o/r",
"gh --repo o/r pr view 12",
"gh auth status",
"gh --version",
"/opt/homebrew/bin/gh pr list",
"echo gh pr create",
"cargo test -p car-engine",
"npm run build",
"docker build -t img .",
"gh api graphql -f query=query{viewer{login}}",
] {
assert!(!denied("shell", sh(cmd)), "should be allowed: {cmd}");
}
}
#[test]
fn governed_host_still_allows_ci_reads_and_approved_push() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for command in [
"gh run list",
"az pipelines runs list",
"git push origin HEAD:main",
] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"governed host must still allow {command}"
);
}
}
#[test]
fn governed_host_allows_only_normal_push_shape() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
assert!(chain
.check("shell", &sh("git push origin HEAD:main"))
.is_none());
for command in [
"git push --force origin main",
"git push --force-with-lease origin main",
"git push origin +HEAD:main",
"git remote set-url origin https://evil",
"git rebase -i HEAD~2",
"git add .",
"git add -A",
"git commit -am fix",
] {
assert!(
chain.check("shell", &sh(command)).is_some(),
"must deny {command}"
);
}
}
#[test]
fn governed_host_denies_direct_reads_outside_repository() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let outside = temp.path().join("outside.txt");
std::fs::create_dir(&repo).unwrap();
std::fs::write(&outside, "secret").unwrap();
let chain = governed_host_inspector_chain(&repo);
assert!(chain
.check("read_file", &json!({"path": outside}))
.is_some());
assert!(chain
.check("shell", &sh(&format!("cat {}", outside.display())))
.is_some());
assert!(chain.check("shell", &sh("cd ..")).is_some());
assert!(chain.check("shell", &sh(r"cat \../outside.txt")).is_some());
for path in [
"~/notes.txt",
"$HOME/notes.txt",
"${HOME}/notes.txt",
"${HOME:-/tmp}/notes.txt",
"${HOME:=/tmp}/notes.txt",
"$TMPDIR/notes.txt",
"${TMPDIR}/notes.txt",
"${TMPDIR:-/tmp}/notes.txt",
] {
assert!(
chain.check("read_file", &json!({"path": path})).is_some(),
"expanded shell root must be denied by read_file: {path}"
);
}
for command in [
"cat ~/notes.txt",
"cat ~someone/notes.txt",
"cat $HOME/notes.txt",
"cat ${HOME}/notes.txt",
"cat ${HOME:-/tmp}/notes.txt",
"cat ${HOME:=/tmp}/notes.txt",
"cat $TMPDIR/notes.txt",
"cat ${TMPDIR}/notes.txt",
"cat ${TMPDIR:=/tmp}/notes.txt",
"sed -f ~/evil.sed file",
] {
assert!(
chain.check("shell", &sh(command)).is_some(),
"home-relative path must be denied: {command}"
);
}
assert!(chain.check("shell", &sh("cat src/lib.rs")).is_none());
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside, repo.join("escape")).unwrap();
assert!(chain
.check("read_file", &json!({"path": "escape"}))
.is_some());
assert!(chain.check("shell", &sh("cat escape")).is_some());
}
}
#[test]
fn governed_host_denies_uninspectable_variable_operands() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let cargo_target = temp.path().join("cargo-target");
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::create_dir_all(cargo_target.join("debug")).unwrap();
std::fs::write(repo.join("src/x"), "x\n").unwrap();
std::fs::write(repo.join("src/lib.rs"), "pub fn control() {}\n").unwrap();
std::fs::write(repo.join("$HOME_fixture"), "literal\n").unwrap();
let chain = governed_host_inspector_chain(&repo);
for command in [
"cat $NOPE/etc/hosts",
"cat ${NOPE}/x",
"cat ${NOPE:-${PWD}}/x",
"cat $NOPE",
"cat $MISSING_fixture",
"cat $HOME_fixture/inside",
"cat ${HOME_fixture}/inside",
"cat ${PWD%repo}/x",
"cat $1/etc/hosts",
"cat ${1}",
"cat $@",
"cat $*",
"cat $?",
"cat $-",
"cat $$",
"cat $!",
"cat src/$x",
"cat src/${x}/file",
"cat src/deeper/$x",
"cat src/file$x",
"cat src/$1",
"cat $PWD/src/$x",
] {
let reason = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("variable operand must be denied: {command}"));
assert!(
reason.contains("variable operand cannot be inspected"),
"unexpected variable denial for {command}: {reason}"
);
}
for command in [
"cat $PWD/src/x",
"cat ${PWD}/src/x",
"cat src/$PWD",
"cat src/${PWD}/x",
"cat src/lib.rs",
"cat $HOME_fixture",
"env RUST_LOG=debug cargo test",
] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"resolvable or repo-prefixed control must stay allowed: {command}"
);
}
let path_gate = DenyGovernedShellPathEscape {
worktree: repo.clone(),
oldpwd_is_worktree: true,
cargo_target_dir: Some(cargo_target.clone()),
};
for command in [
"cat $OLDPWD/src/x",
"cat $CARGO_TARGET_DIR/debug/output",
"cat src/$OLDPWD/x",
"cat src/$CARGO_TARGET_DIR/output",
] {
assert!(
matches!(
path_gate.inspect("shell", &sh(command)),
InspectionResult::Allow
),
"pinned variable root must stay allowed: {command}"
);
}
assert!(matches!(
path_gate.inspect("shell", &sh("cat $CARGO_TARGET_DIR/../outside")),
InspectionResult::Deny(reason)
if reason.contains("resolves outside its governed root")
));
}
#[test]
fn governed_host_gates_every_shell_command_segment() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let outside = temp.path().join("outside.txt");
std::fs::create_dir(&repo).unwrap();
std::fs::write(&outside, "secret").unwrap();
let chain = governed_host_inspector_chain(&repo);
for separator in ["\n", "\r\n", ";", "&&", "||"] {
let command = format!("printf harmless{separator}cat {}", outside.display());
assert!(
chain.check("shell", &sh(&command)).is_some(),
"the command after separator {separator:?} must be path-gated"
);
}
}
#[test]
fn governed_host_inspects_case_arm_body_verbs() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for (command, bare) in [
("case x in x) sudo ls;; esac", "sudo ls"),
("case x in x) cat /etc/passwd;; esac", "cat /etc/passwd"),
("case x in x) PWD=/etc;; esac", "PWD=/etc"),
("case \"\" in x) sudo ls;; esac", "sudo ls"),
("case \"$(printf in)\" in x) sudo ls;; esac", "sudo ls"),
("case $(printf in) in x) sudo ls;; esac", "sudo ls"),
] {
let expected = chain
.check("shell", &sh(bare))
.unwrap_or_else(|| panic!("bare command must deny: {bare}"));
let actual = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("case arm body must deny: {command}"));
assert_eq!(actual, expected, "case arm must preserve the bare reason");
}
}
#[test]
fn governed_host_inspects_nested_case_arm_body_verbs() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
let bare = "sudo ls";
let expected = chain
.check("shell", &sh(bare))
.expect("bare sudo must deny");
for command in [
"case x in x) case y in y) sudo ls;; esac;; esac",
"{ case x in x) sudo ls;; esac; }",
"if true; then case x in x) sudo ls;; esac; fi",
"(case x in x) sudo ls;; esac)",
] {
let actual = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("nested or wrapped case arm body must deny: {command}"));
assert_eq!(
actual, expected,
"nested or wrapped case must preserve the bare reason"
);
}
}
#[test]
fn governed_host_inspects_multiple_case_arms_and_patterns() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
let expected = chain
.check("shell", &sh("sudo ls"))
.expect("bare sudo must deny");
for command in [
"case x in a) ls src;; b) sudo ls;; esac",
"case x in a|b) sudo ls;; esac",
"case x in a) ls src;& b) sudo ls;; esac",
"case x in a) ls src;;& b) sudo ls;; esac",
] {
let actual = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("later case arm body must deny: {command}"));
assert_eq!(actual, expected, "case arm must preserve the bare reason");
}
for command in [
"echo \"x)\"",
"echo ')'",
"echo case x in x) sudo ls",
"case x in \"x)\") ls src;; esac",
"case x in x\\)) ls src;; esac",
] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"quoted closing parenthesis must stay inert: {command}"
);
}
}
#[test]
fn governed_host_joins_continued_lines_before_path_gating() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let outside = temp.path().join("outside.txt");
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::write(repo.join("src/lib.rs"), "pub fn example() {}\n").unwrap();
std::fs::write(&outside, "secret").unwrap();
let chain = governed_host_inspector_chain(&repo);
for verb in ["cat", "rm"] {
let command = format!("{verb} \\\n{}", outside.display());
assert!(
chain.check("shell", &sh(&command)).is_some(),
"a continued {verb} path must remain governed: {command:?}"
);
}
assert!(
chain
.check("shell", &sh("sed -n '/pub fn/p' \\\n src/lib.rs"))
.is_none(),
"a continued repository-local sed operand must remain allowed"
);
}
#[test]
fn governed_host_gates_paths_embedded_in_sed_and_awk_programs() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let outside = temp.path().join("outside.txt");
std::fs::create_dir(&repo).unwrap();
std::fs::write(repo.join("file"), "x\n").unwrap();
std::fs::write(repo.join("script.sed"), "p\n").unwrap();
std::fs::write(&outside, "secret").unwrap();
let chain = governed_host_inspector_chain(&repo);
let outside = outside.display();
for command in [
format!("sed 'r {outside}' file"),
format!("sed -n 'R {outside}' file"),
format!("sed -e '1w {outside}' file"),
format!("sed '/x/W {outside}' file"),
format!("awk '{{ getline < \"{outside}\" }}' file"),
format!("awk '{{ getline line < \"{outside}\" }}' file"),
format!("awk '{{ print $0 > \"{outside}\" }}' file"),
format!("awk '{{ print $0 > (\"{outside}\") }}' file"),
format!("awk '{{ printf \"%s\", $0 >> \"{outside}\" }}' file"),
format!("sed -n 's/x/y/w {outside}' file"),
format!("sed -n '/x/s//y/gw {outside}' file"),
format!("sed -n 's/x/;/w {outside}' file"),
format!("sed -n 's;x;y;w {outside}' file"),
format!("sed -n 's/x/{{/w {outside}' file"),
format!("sed -n 'r{outside}' file"),
format!("sed -n 'R{outside}' file"),
format!("sed -n 'w{outside}' file"),
format!("sed -n 'W{outside}' file"),
format!("sed -nf {outside} file"),
format!("sed -ne 'r {outside}' file"),
format!("sed -i.bak 's/x/y/' {outside}"),
format!("awk 'BEGIN {{ system(\"cat {outside}\") }}'"),
format!("awk 'BEGIN {{ \"cat {outside}\" | getline line }}'"),
format!("awk 'BEGIN {{ \"cat {outside}\" |& getline line }}'"),
format!("awk '{{ print $0 | \"cat > {outside}\" }}' file"),
format!("awk '{{ printf \"%s\", $0 | \"cat > {outside}\" }}' file"),
format!("sed -n '1e cat {outside}' file"),
"sed -n 's/x/y/e' file".to_string(),
] {
assert!(
chain.check("shell", &sh(&command)).is_some(),
"dangerous embedded operand or command execution must be denied: {command}"
);
}
for separator in ["\n", "\r\n"] {
let command = format!("printf harmless{separator}sed -n 's/x/y/w {outside}' file");
assert!(
chain.check("shell", &sh(&command)).is_some(),
"a sed write after {separator:?} must remain governed"
);
}
for continuation in ["\\\n", "\\\r\n"] {
let command = format!("sed -n 's/x/y/w {continuation}{outside}' file");
assert!(
chain.check("shell", &sh(&command)).is_some(),
"a continued sed write must remain governed: {command:?}"
);
}
for command in [
"sed 'r file' file",
"sed -e '1w generated.txt' file",
"awk '{ getline < \"file\" }' file",
"awk '{ print $0 > \"generated.txt\" }' file",
"sed -nf script.sed file",
"sed -ne '/x/p' file",
"sed -i.bak 's/x/y/' file",
"sed 'y/x/;/' file",
"sed ':example; /x/p' file",
] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"repository-relative embedded file operand must remain allowed: {command}"
);
}
}
#[test]
fn governed_host_denies_shell_fed_sed_and_awk_programs() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let outside = temp.path().join("outside.txt");
std::fs::create_dir(&repo).unwrap();
std::fs::write(repo.join("file"), "x\n").unwrap();
std::fs::write(&outside, "secret").unwrap();
let chain = governed_host_inspector_chain(&repo);
let outside = outside.display();
for command in [
format!("sed -f - file <<'EOF'\nw {outside}\nEOF"),
format!("awk -f /dev/stdin file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
format!("sed -f - file <<< 'w {outside}'"),
format!("printf 'w {outside}\\n' | sed -f - file"),
format!("sed -f <(printf 'w {outside}\\n') file"),
format!("awk -f /dev/fd/0 file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
"awk -f /proc/self/fd/0 file".to_string(),
format!("sed -e '' -f - file <<'EOF'\nw {outside}\nEOF"),
format!("awk -e '' -f - file <<'EOF'\n{{ print > \"{outside}\" }}\nEOF"),
"sed -f '' file".to_string(),
"sed -f\"\" file".to_string(),
"sed -f $(printf -) file".to_string(),
"awk -f `printf -` file".to_string(),
"sed -f >(printf p) file".to_string(),
r"sed -f \- file".to_string(),
r"awk -f \- file".to_string(),
"busybox awk -f - file".to_string(),
r"sed --file=\- file".to_string(),
"sed -f {-,} file".to_string(),
"x=-; sed -f $x file".to_string(),
"x=-; sed -f \"$x\" file".to_string(),
] {
let reason = chain
.check("shell", &sh(&command))
.unwrap_or_else(|| panic!("shell-fed program must be denied: {command}"));
assert!(
reason.contains("script source") && reason.contains("cannot be inspected"),
"program channels must not be misclassified as governed paths: {reason}"
);
}
}
#[test]
fn governed_host_denies_nested_shell_command_carriers() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for command in [
"eval 'sed -f - file'",
"bash -c 'sed -f - file'",
"sh -c 'cat /etc/passwd'",
"zsh -c 'awk -f - file'",
"dash -c 'cat /etc/passwd'",
"ksh -c 'cat /etc/passwd'",
"fish -c 'cat /etc/passwd'",
"busybox sh -c 'cat /etc/passwd'",
"busybox ash -c 'cat /etc/passwd'",
"perl -e 'open F, q(/etc/passwd)'",
"perl -E 'say qx(cat /etc/passwd)'",
"perl -wE 'say qx(cat /etc/passwd)'",
"python3 -c 'open(\"/etc/passwd\").read()'",
"python3.14 -c 'open(\"/etc/passwd\").read()'",
"ruby -e 'puts File.read(\"/etc/passwd\")'",
"node -e 'require(\"fs\").readFileSync(\"/etc/passwd\")'",
"osascript -e 'do shell script \"cat /etc/passwd\"'",
"script -c 'cat /etc/passwd'",
"bash -lc 'cat /etc/passwd'",
"MODE=check /bin/bash --noprofile -c 'cat /etc/passwd'",
"env -S 'cat /etc/passwd'",
"bash",
"nice bash",
"nohup bash",
] {
let reason = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("nested shell carrier must be denied: {command}"));
assert!(
reason.contains("nested shell command"),
"unexpected nested-shell denial reason: {reason}"
);
}
assert!(chain.check("shell", &sh("echo eval")).is_none());
assert!(chain.check("shell", &sh("bash script.sh")).is_none());
assert!(chain
.check("shell", &sh("nice bash scripts/check.sh"))
.is_none());
assert!(chain.check("shell", &sh("python3 script.py")).is_none());
assert!(chain.check("shell", &sh("python3 -E script.py")).is_none());
assert!(chain
.check("shell", &sh("ruby -E UTF-8 script.rb"))
.is_none());
assert!(chain.check("shell", &sh("node script.js")).is_none());
assert!(chain
.check("shell", &sh("perl -MExtUtils::MakeMaker scripts/build.pl"))
.is_none());
assert!(chain.check("shell", &sh("command -v sudo")).is_none());
}
#[test]
fn compound_command_keywords_do_not_take_the_verb_slot() {
for keyword in [
"{", "}", "if", "then", "elif", "else", "fi", "for", "do", "done", "while", "until",
"case", "esac", "function", "time", "!",
] {
assert!(
compound_command_keyword(keyword),
"shell grammar word must not be classified as a verb: {keyword}"
);
}
for control in ["echo", "a{b}", "{2,}", "important", "timer"] {
assert!(
!compound_command_keyword(control),
"ordinary word must remain a possible verb: {control}"
);
}
}
#[test]
fn governed_host_denies_assignments_inside_compound_commands() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for (command, bare_assignment) in [
("{ PWD=/etc; }", "PWD=/etc"),
("if HOME=/tmp; then :; fi", "HOME=/tmp"),
("if false; then :; elif PATH=/tmp; then :; fi", "PATH=/tmp"),
("if false; then :; else IFS=:; fi", "IFS=:"),
("for i in 1; do PWD=/etc; done", "PWD=/etc"),
("while HOME=/tmp; do break; done", "HOME=/tmp"),
("until HOME=/tmp; false; do break; done", "HOME=/tmp"),
("case x in x) IFS=:;; esac", "IFS=:"),
("time PATH=/tmp true", "PATH=/tmp"),
("! HOME=/tmp", "HOME=/tmp"),
] {
let expected = chain
.check("shell", &sh(bare_assignment))
.unwrap_or_else(|| {
panic!("bare protected assignment must deny: {bare_assignment}")
});
let actual = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("compound assignment must deny: {command}"));
assert_eq!(
actual, expected,
"compound syntax must preserve the existing assignment reason: {command}"
);
}
}
#[test]
fn governed_host_denies_compound_assignments_after_shell_separators() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for command in [
"{ true; PWD=/etc; }",
"{ true && PATH=/tmp; }",
"{ false || HOME=/tmp; }",
"{ printf x | IFS=:; }",
"{ true\nPWD=/etc\n}",
] {
let reason = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("separated compound assignment must deny: {command:?}"));
assert!(
reason.contains("environment assignment"),
"unexpected assignment reason for {command:?}: {reason}"
);
}
}
#[test]
fn governed_host_compound_syntax_controls_stay_allowed() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for command in [
"echo '{ PWD=/etc; }'",
"echo '{'",
"cargo test 'a{b}'",
"grep -E '{2,}' src/lib.rs",
] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"quoted or embedded brace must not become shell grammar: {command}"
);
}
}
#[test]
fn governed_host_compound_paths_preserve_later_prefix_checks() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for command in [
"source=repo-local; if true; then cat $source/file; fi",
"source=repo-local; { cat $source/file; }",
] {
let reason = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("compound path-prefix assignment must deny: {command}"));
assert_eq!(
reason,
"environment assignment 'source' may change a governed path operand"
);
}
}
fn compound_wrappings(bare: &str) -> Vec<String> {
vec![
format!("{{ {bare}; }}"),
format!("if true; then {bare}; fi"),
format!("if false; then :; else {bare}; fi"),
format!("for i in 1; do {bare}; done"),
format!("while true; do {bare}; done"),
format!("until false; do {bare}; done"),
format!("! {bare}"),
]
}
fn function_declaration_wrappings(bare: &str) -> Vec<String> {
vec![
format!("function wrapped {{ {bare}; }}; wrapped"),
format!("wrapped() {{ {bare}; }}; wrapped"),
format!("wrapped () {{ {bare}; }}; wrapped"),
format!("wrapped(){{ {bare}; }}; wrapped"),
]
}
#[test]
fn governed_host_function_declarations_deny_categorically() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for bare in ["ls src", "PWD=/etc", "sudo ls", "cat /etc/passwd"] {
for wrapped in function_declaration_wrappings(bare) {
let reason = chain
.check("shell", &sh(&wrapped))
.unwrap_or_else(|| panic!("declaration must deny: {wrapped}"));
assert_eq!(
reason,
"shell function declarations require explicit human approval and cannot be run by a coding model",
"declaration must be refused categorically: {wrapped}"
);
}
}
}
#[test]
fn shell_verb_index_skips_the_declared_function_name() {
let tokens =
|line: &str| -> Vec<String> { line.split_whitespace().map(str::to_string).collect() };
let declaration = tokens("function wrapped { sudo ls");
assert_eq!(
verb(&declaration),
Some("sudo"),
"the declared name must not hide the body's verb"
);
let ordinary = tokens("grep function src/lib.rs");
assert_eq!(verb(&ordinary), Some("grep"));
for head in [
"f() { sudo ls",
"f(){ sudo ls",
"f () { sudo ls",
"f ( ) { sudo ls",
] {
assert_eq!(
verb(&tokens(head)),
Some("sudo"),
"POSIX declaration head must not hide the body's verb: {head}"
);
}
for ordinary in [
"grep () file",
"grep ()",
"echo f()",
"grep main() src",
"cargo test foo()",
"python -c f()",
] {
let parsed = tokens(ordinary);
assert_eq!(
verb(&parsed),
ordinary.split_whitespace().next(),
"a parenthesised argument is not a declaration head: {ordinary}"
);
}
assert!(
posix_function_head(&tokens("( sudo ls )")).is_none(),
"a bare subshell paren is not a function declaration head"
);
let timed = tokens("time { cargo build");
assert_eq!(verb(&timed), Some("time"));
}
#[test]
fn governed_host_inspects_verbs_inside_compound_commands() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for bare in [
"cat /etc/passwd",
"cat ../outside/file",
"head -1 /etc/passwd",
"sudo ls",
"git push --force origin main",
"git remote set-url origin http://evil",
"git add -A",
"git rebase -i HEAD~3",
"rm -rf /Users/other",
"cat $f",
"wc -l \"$f\"",
] {
let expected = chain
.check("shell", &sh(bare))
.unwrap_or_else(|| panic!("bare command must deny: {bare}"));
for wrapped in compound_wrappings(bare) {
let actual = chain
.check("shell", &sh(&wrapped))
.unwrap_or_else(|| panic!("compound command must deny: {wrapped}"));
assert_eq!(
actual, expected,
"compound syntax must preserve the bare verdict for {bare:?}: {wrapped:?}"
);
}
}
}
#[test]
fn governed_host_inspects_loop_body_variable_operands() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for (loop_form, bare) in [
("for f in src/*.rs; do cat $f; done", "cat $f"),
("while read -r f; do cat $f; done", "cat $f"),
("for f in src/*.rs; do wc -l \"$f\"; done", "wc -l \"$f\""),
] {
let expected = chain
.check("shell", &sh(bare))
.unwrap_or_else(|| panic!("bare command must deny: {bare}"));
let actual = chain
.check("shell", &sh(loop_form))
.unwrap_or_else(|| panic!("loop body must deny: {loop_form}"));
assert_eq!(
actual, expected,
"loop body must preserve the bare verdict: {loop_form}"
);
assert!(
expected.contains("variable operand cannot be inspected"),
"unexpected reason for {bare}: {expected}"
);
}
}
#[test]
fn governed_host_compound_verb_controls_stay_allowed() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for bare in ["ls src", "wc -l src/lib.rs", "cargo test"] {
assert!(
chain.check("shell", &sh(bare)).is_none(),
"control must stay allowed bare: {bare}"
);
for wrapped in compound_wrappings(bare) {
assert!(
chain.check("shell", &sh(&wrapped)).is_none(),
"compound syntax must not invent a denial: {wrapped}"
);
}
}
for command in [
"{ cargo build; cargo test; }",
"time { cargo build; }",
"find . -name x -exec echo {} \\;",
"xargs -I{} cat {}",
"echo a{b,c}",
"echo \"f()\"",
"grep 'main()' src",
"cargo test 'foo()'",
"grep '()' src/lib.rs",
] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"brace-bearing control must stay allowed: {command}"
);
}
let reason = chain
.check("shell", &sh("python -c \"f()\""))
.expect("an inline python program is a nested shell carrier");
assert!(
reason.contains("nested shell command through 'python'"),
"parenthesised argument must not cost python its verb: {reason}"
);
}
#[test]
fn governed_host_inspects_command_environment_assignments() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let outside = temp.path().join("outside");
std::fs::create_dir_all(repo.join("target")).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let chain = governed_host_inspector_chain(&repo);
for name in [
"BASH_ENV",
"ENV",
"PATH",
"LD_PRELOAD",
"DYLD_INSERT_LIBRARIES",
"PYTHONPATH",
"PERL5LIB",
"RUBYLIB",
"NODE_OPTIONS",
"CARGO_HOME",
"RUSTUP_HOME",
"GIT_EXEC_PATH",
"GIT_SSH_COMMAND",
] {
let command = format!("env {name}=repo-local cargo test");
let reason = chain
.check("shell", &sh(&command))
.unwrap_or_else(|| panic!("redirecting assignment must be denied: {command}"));
assert!(reason.contains("redirect executable code"), "{reason}");
}
for command in [
"BASH_ENV=repo-local bash script.sh".to_string(),
format!("env CACHE_DIR={} cargo test", outside.display()),
"env EDITOR=repo-editor git commit".to_string(),
"VISUAL=repo-editor git rebase --interactive main".to_string(),
"env CARGO_TARGET_DIR=target cargo test".to_string(),
] {
assert!(
chain.check("shell", &sh(&command)).is_some(),
"unsafe assignment must be denied: {command}"
);
}
for command in [
"env RUST_LOG=debug cargo test".to_string(),
"RUST_LOG=debug".to_string(),
"RUST_LOG[0]=debug; cargo test".to_string(),
"RUST_LOG[$index]=debug; cargo test".to_string(),
"RUST_LOG[$((1+1))]=debug; cargo test".to_string(),
"RUST_LOG[ 0 ]=debug; cargo test".to_string(),
"RUST_LOG[0 ]=debug; cargo test".to_string(),
"RUST_LOG[ 0]=debug; cargo test".to_string(),
"RUST_LOG[\t0]=debug; cargo test".to_string(),
"RUST_LOG[ 0 ]+=,debug; cargo test".to_string(),
"cargo test 'a[0]'".to_string(),
"CACHE_KIND=local".to_string(),
"env EDITOR=vim cargo test".to_string(),
] {
assert!(
chain.check("shell", &sh(&command)).is_none(),
"plain assignment must stay allowed: {command}"
);
}
for command in [
"PWD=repo-local",
"OLDPWD=repo-local",
"CARGO_TARGET_DIR=target",
"HOME=repo-local",
"TMPDIR=tmp",
"IFS=: ",
"PATH=bin",
"PWD=/etc; cat $PWD/passwd",
"PWD[0]=/etc; cat $PWD/passwd",
"PWD[0]+=/x",
"PWD[ 0 ]=/etc; cat $PWD/passwd",
"PWD[0 ]=/etc; cat $PWD/passwd",
"PWD[ 0]=/etc; cat $PWD/passwd",
"PWD[\t0]=/etc; cat $PWD/passwd",
"PWD[\t0]+=/x",
"env PWD[0]=/etc cat $PWD/passwd",
"source=../../outside; cat $source/file",
"source[0]=repo-local; cat $source/file",
"env input=../../outside cat $input/file",
] {
let reason = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("path-changing assignment must be denied: {command}"));
assert!(
reason.contains("environment assignment"),
"unexpected assignment denial for {command}: {reason}"
);
}
}
#[test]
fn governed_host_inspects_assignments_inside_subshells() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for command in [
"(PWD=/etc; cat $PWD/passwd)",
"( PWD=/etc; cat $PWD/passwd )",
"(printf harmless; (PWD=/etc; cat $PWD/passwd))",
"printf harmless $(PWD=/etc; cat $PWD/passwd)",
] {
let reason = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("subshell assignment must be denied: {command}"));
assert_eq!(
reason, "environment assignment 'PWD' may change a governed path operand",
"unexpected subshell-assignment denial for {command}"
);
}
let reason = chain
.check("shell", &sh("(cat /etc/passwd)"))
.expect("a governed verb inside a subshell must still be inspected");
assert!(
reason.contains("outside the governed repository"),
"unexpected subshell-path denial: {reason}"
);
for command in [
"echo \"(a=b)\"",
"grep -E \"(x|y)\" file",
"find . ( -name a -o -name b )",
"((count += 1))",
"((total = (count + 1) * 2))",
"echo $((1 + (2 * 3)))",
] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"quoted parentheses, find expressions, and arithmetic must stay allowed: {command}"
);
}
}
#[test]
fn governed_host_inspects_assignment_builtins() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for command in [
"export PWD=/etc; cat $PWD/passwd",
"readonly PWD=/etc; cat $PWD/passwd",
"declare PWD=/etc; cat $PWD/passwd",
"typeset PWD=/etc; cat $PWD/passwd",
"local PWD=/etc; cat $PWD/passwd",
"export PWD[1]=/etc; cat $PWD/passwd",
"export PWD[ 1 ]=/etc; cat $PWD/passwd",
"declare -a PWD[\t0]=/etc; cat $PWD/passwd",
"readonly PWD[1]=/etc; cat $PWD/passwd",
"declare PATH[0]=/tmp; cargo test",
"declare -a arr[0]=/etc",
"typeset HOME[0]=/tmp; cargo test",
"local PWD[1]=/etc; cat $PWD/passwd",
"unset PWD",
"export -n PWD",
] {
let reason = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("assignment builtin must be denied: {command}"));
assert!(
reason.contains("environment assignment"),
"unexpected assignment-builtin denial for {command}: {reason}"
);
}
for builtin in ["export", "readonly", "declare", "typeset", "local"] {
for assignment in ["source=repo-local", "source[0]=repo-local"] {
let command = format!("{builtin} {assignment}; cat $source/file");
let reason = chain.check("shell", &sh(&command)).unwrap_or_else(|| {
panic!("later operand-prefix assignment must be denied: {command}")
});
assert!(reason.contains("environment assignment"), "{reason}");
}
}
for command in [
"PWD+=/../../../etc; cat $PWD/passwd",
"env PWD+=/../../../etc cat $PWD/passwd",
"export PWD+=/../../../etc; cat $PWD/passwd",
"readonly PWD+=/../../../etc; cat $PWD/passwd",
"declare PATH+=/../../../bin; cargo test",
"typeset PWD+=/../../../etc; cat $PWD/passwd",
"local PWD+=/../../../etc; cat $PWD/passwd",
] {
let reason = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("append assignment must be denied: {command}"));
assert!(
reason.contains("environment assignment"),
"unexpected append-assignment denial for {command}: {reason}"
);
}
for command in [
"RUST_LOG[$(id)]=debug; cargo test",
"RUST_LOG[$(printf 0)]=debug; cargo test",
"env RUST_LOG[$(id)]=debug cargo test",
"export RUST_LOG[$(id)]=debug; cargo test",
"readonly RUST_LOG[`printf 0`]=debug; cargo test",
"declare RUST_LOG[$(id)]=debug; cargo test",
"typeset RUST_LOG[$(id)]=debug; cargo test",
"local RUST_LOG[$(id)]=debug; cargo test",
] {
let reason = chain.check("shell", &sh(command)).unwrap_or_else(|| {
panic!("command-substitution subscript must be denied: {command}")
});
assert!(reason.contains("cannot be inspected"), "{reason}");
}
for command in [
"export RUST_LOG=debug; cargo test",
"export RUST_LOG+=,debug; cargo test",
"export RUST_LOG[0]=debug; cargo test",
"export RUST_LOG[ 0 ]=debug; cargo test",
"declare RUST_LOG[\t0]=debug; cargo test",
"declare RUST_LOG[$index]=debug; cargo test",
"typeset RUST_LOG[$((1+1))]=debug; cargo test",
"echo 'RUST_LOG[$(id)]=debug'",
"local x=1",
"unset RUST_LOG",
"export -n RUST_LOG",
] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"harmless assignment builtin must stay allowed: {command}"
);
}
}
#[test]
fn governed_host_denies_busybox_install_mode() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
for command in [
"busybox --install",
"busybox --install -s",
"busybox --install=/tmp/bin",
] {
let reason = chain
.check("shell", &sh(command))
.unwrap_or_else(|| panic!("busybox install must be denied: {command}"));
assert!(reason.contains("busybox --install"), "{reason}");
}
assert!(chain
.check("shell", &sh("busybox grep foo src/input.txt"))
.is_none());
}
#[test]
fn governed_host_unwraps_prefix_command_carriers() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
let outside = temp.path().join("outside.txt");
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::write(repo.join("src/input.txt"), "foo\n").unwrap();
std::fs::write(&outside, "secret\n").unwrap();
let chain = governed_host_inspector_chain(&repo);
let path_gate = DenyGovernedShellPathEscape::new(&repo);
let outside = outside.display();
for command in [
format!("env FOO=1 bash -c 'cat {outside}'"),
format!("env -i -u FOO FOO=1 cat {outside}"),
format!("command -p cat {outside}"),
format!("exec -a reader cat {outside}"),
format!("nohup -- cat {outside}"),
format!("time -f '%E' cat {outside}"),
format!("nice --adjustment 5 cat {outside}"),
format!("caffeinate -t 1 cat {outside}"),
format!("script -q transcript cat {outside}"),
format!("xargs -0P 2 cat {outside}"),
"builtin eval 'cat /etc/passwd'".to_string(),
format!("sudo -u root cat {outside}"),
format!("ionice -c 2 cat {outside}"),
format!("timeout --signal TERM 5 cat {outside}"),
format!("env -i time nice -n 1 cat {outside}"),
] {
assert!(
chain.check("shell", &sh(&command)).is_some(),
"carrier must not hide the governed command: {command}"
);
}
assert!(matches!(
path_gate.inspect("shell", &sh("env")),
InspectionResult::Allow
));
for command in [
"time cargo build",
"xargs -0 grep foo src/input.txt",
"busybox grep foo src/input.txt",
] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"safe carrier control must stay allowed: {command}"
);
}
}
#[test]
fn prefix_carriers_do_not_bypass_other_shell_inspectors() {
for command in [
"env FOO=1 git push origin main",
"command gh pr create --fill",
"time cargo publish",
"nice -n 1 pip install requests",
] {
assert!(
denied("shell", sh(command)),
"must deny carried command: {command}"
);
}
}
#[test]
fn governed_host_distinguishes_sed_and_awk_programs_from_paths() {
let temp = tempfile::tempdir().unwrap();
let repo = temp.path().join("repo");
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::write(repo.join("src/lib.rs"), "pub fn example() {}\n").unwrap();
std::fs::write(repo.join("file.rs"), "pub fn example() {}\n").unwrap();
std::fs::write(repo.join("file"), "x\n").unwrap();
std::fs::write(repo.join("script.sed"), "p\n").unwrap();
std::fs::write(repo.join("-"), "p\n").unwrap();
#[cfg(unix)]
{
let mut escaped_dash = repo.clone();
escaped_dash.push(r"\-");
std::fs::write(escaped_dash, "p\n").unwrap();
}
let chain = governed_host_inspector_chain(&repo);
assert!(
chain
.check("shell", &sh("sed -n /pub fn/p file.rs"))
.is_none(),
"a sed address is a program, not the absolute path /pub"
);
assert!(
chain
.check("shell", &sh("sed -n '/pub fn/p' src/lib.rs"))
.is_none(),
"a quoted sed address is a program, not the absolute path /pub"
);
assert!(
chain
.check("shell", &sh("sed -e '/pub fn/p' src/lib.rs"))
.is_none(),
"a sed -e operand is an inline program, not a path"
);
assert!(
chain
.check("shell", &sh("sed -n '/pub \\/etc/p' src/lib.rs"))
.is_none(),
"quoted whitespace must not split one sed program into path operands"
);
assert!(
chain.check("shell", &sh("awk /pub/ file.rs")).is_none(),
"an awk pattern is a program, not the absolute path /pub"
);
assert!(
chain.check("shell", &sh("awk '/x/{print}' file")).is_none(),
"an awk pattern-action is a program, not a path"
);
assert!(
chain
.check("shell", &sh("awk '/x/ { print \"/etc\" }' file"))
.is_none(),
"a quoted awk program remains one non-path argument"
);
assert!(
chain.check("shell", &sh("sed -n p /etc/passwd")).is_some(),
"sed input files remain governed"
);
assert!(
chain.check("shell", &sh("sed -f /etc/evil")).is_some(),
"sed -f names a script file and must remain governed"
);
assert!(
chain.check("shell", &sh("awk -E/path file")).is_some(),
"an attached awk -E script path must remain governed"
);
assert!(
chain
.check("shell", &sh("sed -f script.sed file"))
.is_none(),
"a literal repository script must remain allowed"
);
assert!(
chain
.check("shell", &sh("sed -e '' -f script.sed file"))
.is_none(),
"empty -e padding must not deny a literal repository script"
);
assert!(
chain.check("shell", &sh("sed -f ./- file")).is_none(),
"./- is a literal repository path, not sed's stdin marker"
);
#[cfg(unix)]
{
for command in [r"sed -f '\-' file", r#"sed -f "\-" file"#] {
assert!(
chain.check("shell", &sh(command)).is_none(),
"a backslash preserved by shell quotes remains a repository filename: {command}"
);
}
}
}
#[test]
fn governed_host_denies_gui_shell_automation() {
let chain = governed_host_inspector_chain(Path::new("/wt"));
assert!(chain
.check(
"run_applescript",
&json!({"script": "tell application \"Terminal\" to do script \"az deploy\""})
)
.is_some());
assert!(chain
.check("run_powershell", &json!({"script": "az deploy"}))
.is_some());
}
#[test]
fn history_rewrite_denied() {
assert!(denied("shell", sh("git rebase -i HEAD~3")));
assert!(denied("shell", sh("git reset --hard HEAD~1")));
assert!(denied("shell", sh("git filter-branch --all")));
assert!(denied("shell", sh("git worktree remove /wt")));
assert!(!denied("shell", sh("git reset HEAD file.txt"))); }
#[test]
fn privilege_escalation_denied() {
assert!(denied("shell", sh("sudo rm -rf /tmp/x")));
assert!(denied("shell", sh("doas pkg_add x")));
assert!(denied("shell", sh("FOO=1 sudo make install")));
assert!(denied("shell", sh("launchctl unload foo")));
assert!(!denied("shell", sh("echo sudo"))); }
#[test]
fn credential_access_denied_for_shell_and_file_tools() {
assert!(denied("shell", sh("cat ~/.ssh/id_rsa")));
assert!(denied("shell", sh("cat $HOME/.aws/credentials")));
assert!(denied("shell", sh("security find-generic-password -s x")));
assert!(denied("read_file", json!({"path": "/Users/u/.ssh/id_rsa"})));
assert!(denied("read_file", json!({"path": "~/.netrc"})));
assert!(!denied("read_file", json!({"path": "src/main.rs"})));
}
#[test]
fn destructive_ops_scoped_to_worktree() {
assert!(denied("shell", sh("rm -rf /etc")));
assert!(denied("shell", sh("rm -rf ../other-checkout")));
assert!(denied("shell", sh("mv target ~/elsewhere")));
assert!(denied("shell", sh("chmod 777 /usr/local/bin/x")));
assert!(!denied("shell", sh("rm -rf target/debug")));
assert!(!denied("shell", sh("rm /wt/scratch.txt")));
assert!(!denied("shell", sh("cp a.txt b.txt")));
}
#[test]
fn write_path_escape_denied_but_reads_allowed() {
assert!(denied(
"write_file",
json!({"path": "/etc/hosts", "content": "x"})
));
assert!(denied("edit_file", json!({"path": "../outside.txt"})));
assert!(!denied(
"write_file",
json!({"path": "src/new.rs", "content": "x"})
));
assert!(!denied(
"write_file",
json!({"path": "/wt/src/new.rs", "content": "x"})
));
assert!(!denied(
"read_file",
json!({"path": "/usr/include/stdio.h"})
));
}
#[test]
fn stays_under_is_lexical_and_strict() {
let root = Path::new("/wt");
assert!(stays_under(root, "src/x.rs"));
assert!(stays_under(root, "a/../b.txt"));
assert!(stays_under(root, "/wt/deep/file"));
assert!(!stays_under(root, "../escape"));
assert!(!stays_under(root, "a/../../escape"));
assert!(!stays_under(root, "/etc/passwd"));
assert!(!stays_under(root, "/wtevil/file")); assert!(!stays_under(root, "~"));
assert!(!stays_under(root, "~/outside"));
assert!(!stays_under(root, "~someone/outside"));
assert!(!stays_under(root, "$HOME"));
assert!(!stays_under(root, "$HOME/outside"));
assert!(!stays_under(root, "${HOME}/outside"));
assert!(!stays_under(root, "${HOME:-/tmp}/outside"));
assert!(!stays_under(root, "${HOME:=/tmp}/outside"));
assert!(!stays_under(root, "$TMPDIR/outside"));
assert!(!stays_under(root, "${TMPDIR}/outside"));
assert!(!stays_under(root, "${TMPDIR:-/tmp}/outside"));
assert!(!stays_under(root, "${TMPDIR:=/tmp}/outside"));
assert!(!stays_under(root, "$HOME_fixture"));
assert!(!stays_under(root, "${HOME_fixture}/inside"));
assert!(stays_under(root, "src/$x"));
assert!(stays_under(root, "src/~fixture"));
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("$HOME_fixture"), "literal").unwrap();
assert!(stays_under(temp.path(), "$HOME_fixture"));
}
#[cfg(windows)]
#[test]
fn windows_destructive_and_privilege_denied() {
let chain = coder_inspector_chain(Path::new(r"C:\wt"));
let denied = |cmd: &str| chain.check("shell", &sh(cmd)).is_some();
assert!(denied(r"del C:\Windows\System32\drivers\etc\hosts"));
assert!(denied(r"rd /s /q C:\Windows"));
assert!(denied(r"del /q C:\Users\victim\file")); assert!(denied(r"move C:\wt\keep.txt C:\Users\public\stolen.txt"));
assert!(denied("runas /user:Administrator cmd"));
assert!(denied("sc stop windefend"));
assert!(!denied(r"del C:\wt\target\debug\app.exe"));
assert!(!denied(r"del build\out.txt"));
assert!(!denied("dir")); }
#[cfg(windows)]
#[test]
fn windows_credential_access_denied() {
let chain = coder_inspector_chain(Path::new(r"C:\wt"));
assert!(chain
.check("shell", &sh(r"type %USERPROFILE%\.ssh\id_rsa"))
.is_some());
assert!(chain.check("shell", &sh("cmdkey /list")).is_some());
assert!(chain
.check(
"read_file",
&json!({"path": r"C:\Users\u\.aws\credentials"})
)
.is_some());
assert!(chain
.check("read_file", &json!({"path": r"C:\wt\src\main.rs"}))
.is_none());
}
#[cfg(windows)]
#[test]
fn stays_under_handles_verbatim_prefix_and_case() {
let root = Path::new(r"\\?\C:\wt");
assert!(stays_under(root, r"C:\WT\src\main.rs"));
assert!(stays_under(root, r"c:\wt\src\main.rs"));
assert!(!stays_under(root, r"C:\other\x"));
assert!(!stays_under(root, r"C:\wtevil\x")); }
}