use std::path::{Path, PathBuf};
use tree_sitter::{Node, Parser};
use super::scan::{self, CdScan};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellMode {
Full,
ReadOnly,
}
const MUTATING_COMMANDS: &[&str] = &[
"shred",
"mkfifo",
"mknod",
"ln",
"install",
"truncate",
"fallocate",
"split",
"csplit",
"patch",
"scp",
"sftp",
"chmod",
"chown",
"chattr",
"chflags",
"setfacl",
"rsync",
"unzip",
"vim",
"vi",
"nvim",
"nano",
"pico",
"emacs",
"ed",
"code",
"gedit",
"sponge",
"kill",
"pkill",
"killall",
"shutdown",
"reboot",
"halt",
"poweroff",
"make",
"cmake",
"npm",
"yarn",
"pnpm",
"pip",
"pip3",
"pipenv",
"poetry",
"brew",
"port",
];
const GIT_SAFE_SUBCOMMANDS: &[&str] = &[
"status",
"log",
"diff",
"show",
"blame",
"annotate",
"shortlog",
"describe",
"ls-files",
"ls-tree",
"rev-parse",
"rev-list",
"for-each-ref",
"grep",
"help",
"version",
"name-rev",
"count-objects",
"verify-pack",
"verify-commit",
"verify-tag",
"check-attr",
"check-ignore",
"check-mailmap",
"check-ref-format",
"cat-file",
"cherry",
"diff-files",
"diff-index",
"diff-tree",
"fmt-merge-msg",
"fsck",
"merge-base",
"whatchanged",
"reflog",
"range-diff",
"request-pull",
"worktree list",
"hash-object",
"stripspace",
"remote",
"branch",
"tag",
"show-ref",
"ls-remote",
];
const GIT_ALWAYS_MUTATE: &[(&str, &str, &str)] = &[
(
"mktag",
"`git mktag` is not allowed — it always writes a tag object to the object database.",
"use `git verify-tag` or `git cat-file` to inspect existing tag objects.",
),
(
"mktree",
"`git mktree` is not allowed — it always writes a tree object to the object database.",
"use `git ls-tree` to inspect existing tree objects.",
),
(
"merge-file",
"`git merge-file` is not allowed — it mutates files or writes to the object database.",
"use `git diff` to compare files, or `diff`/`diff3` for three-way comparisons.",
),
(
"merge-tree",
"`git merge-tree` is not allowed — it writes tree objects in its default mode.",
"use `git merge-base` to find the merge base, or `git diff-tree` to inspect trees.",
),
(
"push",
"`git push` is not allowed — it writes to a remote repository.",
"inspect remote state with `git ls-remote`, `git remote show`, or `git status`.",
),
(
"clean",
"`git clean` is not allowed — it deletes untracked files.",
"inspect untracked files with `git status` or `git ls-files --others`.",
),
];
const GIT_REF_SHORT_LIST: &[char] = &['l'];
const GIT_TAG_SHORT_LIST: &[char] = &['l', 'n'];
const GIT_REMOTE_MUTATIONS: &[&str] = &[
"add",
"remove",
"rm",
"rename",
"set-url",
"set-head",
"set-branches",
"update",
"prune",
];
pub(super) struct CheckContext {
pub(crate) workspace_root: std::path::PathBuf,
pub(crate) temp_roots: Vec<std::path::PathBuf>,
pub(crate) temp_vars: Vec<(String, String)>,
}
impl CheckContext {
pub(super) fn for_workspace(workspace_root: &Path) -> Self {
Self {
workspace_root: workspace_root.to_path_buf(),
temp_roots: crate::tools::path::allowed_temp_roots(),
temp_vars: vec![("TMPDIR".to_string(), super::shell_tmpdir())],
}
}
}
#[derive(Debug, Clone)]
struct VarBinding {
value: Option<String>,
poisoned: bool,
}
struct ValidationState<'a> {
ctx: &'a CheckContext,
cwd: Option<std::path::PathBuf>,
cd_count: u64,
vars: std::collections::HashMap<String, VarBinding>,
created_dirs: std::collections::HashSet<std::path::PathBuf>,
}
impl<'a> ValidationState<'a> {
fn new(ctx: &'a CheckContext) -> ValidationState<'a> {
let mut vars = std::collections::HashMap::new();
for (name, value) in &ctx.temp_vars {
vars.insert(
name.clone(),
VarBinding {
value: Some(value.clone()),
poisoned: false,
},
);
}
ValidationState {
ctx,
cwd: Some(ctx.workspace_root.clone()),
cd_count: 0,
vars,
created_dirs: std::collections::HashSet::new(),
}
}
fn snapshot(&self) -> ValidationState<'a> {
ValidationState {
ctx: self.ctx,
cwd: self.cwd.clone(),
cd_count: self.cd_count,
vars: self.vars.clone(),
created_dirs: self.created_dirs.clone(),
}
}
}
pub(super) fn check_command(command_str: &str, ctx: &CheckContext) -> Result<(), String> {
let trimmed = command_str.trim();
if trimmed.is_empty() {
return Ok(());
}
let mut state = ValidationState::new(ctx);
parse_and_walk(trimmed, &mut state)
}
fn parse_error(cmd: &str) -> String {
format!(
"⚠️ Read-only mode: the command could not be parsed as valid bash — rejected fail-closed.\n\
Command: `{cmd}`\n\
If this command is a known bash construct (herestrings, `<>` redirects, unquoted `%(` in git --format), \
rewrite it in a plainer form (e.g. use `$()` not backticks, quote format strings, use a quoted heredoc delimiter)."
)
}
#[derive(Clone, Copy, Default)]
struct WalkFlags {
negated: bool,
time_external: bool,
}
struct W<'a> {
src: String,
last_start: ValidationState<'a>,
}
struct LastStartGuard<'a, 'w> {
w: &'w mut W<'a>,
saved: ValidationState<'a>,
}
impl<'a, 'w> LastStartGuard<'a, 'w> {
fn new(w: &'w mut W<'a>) -> Self {
let saved = w.last_start.snapshot();
LastStartGuard { w, saved }
}
fn w(&mut self) -> &mut W<'a> {
self.w
}
}
impl Drop for LastStartGuard<'_, '_> {
fn drop(&mut self) {
self.w.last_start = self.saved.snapshot();
}
}
fn is_commandish(kind: &str) -> bool {
matches!(
kind,
"command"
| "list"
| "do_group"
| "redirected_statement"
| "pipeline"
| "negated_command"
| "if_statement"
| "while_statement"
| "until_statement"
| "for_statement"
| "c_style_for_statement"
| "case_statement"
| "compound_statement"
| "subshell"
| "function_definition"
| "test_command"
| "declaration_command"
| "unset_command"
| "variable_assignment"
| "variable_assignments"
| "command_substitution"
| "process_substitution"
)
}
fn is_wordish(kind: &str) -> bool {
matches!(
kind,
"word"
| "string"
| "raw_string"
| "ansi_c_string"
| "translated_string"
| "concatenation"
| "expansion"
| "simple_expansion"
| "special_variable_name"
| "arithmetic_expansion"
| "binary_expression"
| "unary_expression"
| "postfix_expression"
| "parenthesized_expression"
| "ternary_expression"
| "subscript"
| "array"
| "number"
| "extglob_pattern"
| "regex"
| "brace_expression"
| "variable_name"
| "string_content"
| "test_operator"
| "heredoc_content"
| "heredoc_body"
| "comment"
)
}
fn node_text(node: Node, w: &W) -> String {
node.utf8_text(w.src.as_bytes()).unwrap_or("").to_string()
}
fn walk_word_substitutions<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
flags: WalkFlags,
) -> Result<(), String> {
if node.is_error() || node.is_missing() {
return Err(parse_error(&w.src));
}
match node.kind() {
"command_substitution" => {
if node_text(node, w).starts_with('`') {
return Err(format!(
"⚠️ Read-only mode: backtick command substitution is not allowed — its content cannot be safely tracked.\n\
Command: `{}`\n\
Suggestion: use `$()` instead of backticks, e.g. `echo \"$(ls)\"`.",
w.src
));
}
let mut snap = state.snapshot();
walk_substitution_body(node, w, &mut snap, flags)
}
"process_substitution" => {
let mut snap = state.snapshot();
walk_substitution_body(node, w, &mut snap, flags)
}
kind if is_wordish(kind)
|| kind == "variable_assignment"
|| kind == "command_name"
|| kind == "herestring_redirect"
|| kind == "test_command" =>
{
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_word_substitutions(child, w, state, flags)?;
}
Ok(())
}
_ => Ok(()),
}
}
fn walk_substitution_body<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
flags: WalkFlags,
) -> Result<(), String> {
let mut g = LastStartGuard::new(w);
let w = g.w();
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
let inner: Vec<Node> = children
.iter()
.copied()
.filter(|c| is_commandish(c.kind()))
.collect();
if inner.is_empty() {
return Ok(());
}
walk_sequence_of(&inner, w, state, flags, &[])
}
fn walk_node<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
flags: WalkFlags,
extras: Vec<String>,
) -> Result<(), String> {
if node.is_error() || node.is_missing() {
return Err(parse_error(&w.src));
}
match node.kind() {
"program" | "list" | "do_group" => {
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
walk_sequence_of(&children, w, state, flags, &extras)
}
"command" => walk_command(node, w, state, flags, extras),
"redirected_statement" => walk_redirected(node, w, state, flags, extras),
"pipeline" => walk_pipeline(node, w, state, flags, &extras),
"negated_command" => {
let inner = node
.children(&mut node.walk())
.find(|c| is_commandish(c.kind()))
.expect("negated command has a command child");
let f = WalkFlags {
negated: true,
..flags
};
walk_node(inner, w, state, f, extras)
}
"if_statement" => walk_if(node, w, state, &extras),
"while_statement" | "until_statement" => walk_while_until(node, w, state, &extras),
"for_statement" => walk_for(node, w, state, &extras),
"c_style_for_statement" => walk_c_style_for(node, w, state, &extras),
"case_statement" => walk_case(node, w, state, &extras),
"compound_statement" => walk_compound(node, w, state, &extras),
"subshell" => walk_subshell(node, w, state, &extras),
"function_definition" => walk_function(node, w, state, &extras),
"test_command" => walk_word_substitutions(node, w, state, WalkFlags::default()),
"variable_assignment" | "variable_assignments" => {
walk_word_substitutions(node, w, state, WalkFlags::default())?;
bind_assignments(node, w, state)
}
"declaration_command" => walk_declaration(node, w, state),
"unset_command" => walk_unset(node, w, state),
"command_substitution" | "process_substitution" => {
let mut snap = state.snapshot();
walk_substitution_body(node, w, &mut snap, flags)
}
kind if is_wordish(kind) => walk_word_substitutions(node, w, state, flags),
"file_redirect" | "heredoc_redirect" | "herestring_redirect" => {
validate_redirect(node, w, state)
}
"comment" => Ok(()),
_ => Err(unrecognized_node(node, w)),
}
}
fn unrecognized_node(node: Node, w: &W) -> String {
format!(
"⚠️ Read-only mode: the command contains an unrecognized shell construct (`{}`) — rejected fail-closed.\n\
Command: `{}`",
node.kind(),
w.src
)
}
fn walk_sequence_of<'a>(
nodes: &[Node],
w: &mut W<'a>,
state: &mut ValidationState<'a>,
flags: WalkFlags,
extras: &[String],
) -> Result<(), String> {
let mut chain_start = state.snapshot();
let last_cmd = nodes.iter().rposition(|n| is_commandish(n.kind()));
let mut first_cmd = true;
for (i, child) in nodes.iter().enumerate() {
let kind = child.kind();
if !is_commandish(kind) {
match kind {
"&" => {
*state = chain_start.snapshot();
chain_start = state.snapshot();
}
";;" | ";&" | ";;&" => {
return Err(format!(
"⚠️ Read-only mode: a stray `{kind}` terminator appears outside a case construct — rejected fail-closed (bash rejects it too).\n\
Command: `{}`",
w.src
));
}
"ERROR" => return Err(parse_error(&w.src)),
_ => {} }
continue;
}
let child_flags = if first_cmd {
first_cmd = false;
flags
} else {
WalkFlags {
time_external: false,
..flags
}
};
w.last_start = state.snapshot();
let child_extras = if Some(i) == last_cmd {
extras.to_vec()
} else {
Vec::new()
};
walk_node(*child, w, state, child_flags, child_extras)?;
}
Ok(())
}
fn collect_command_words(cmd: Node, w: &W) -> (Vec<String>, Vec<String>) {
let mut words: Vec<String> = Vec::new();
let mut assignments: Vec<String> = Vec::new();
let mut cursor = cmd.walk();
for child in cmd.children(&mut cursor) {
match child.kind() {
"command_name" => {
let mut c2 = child.walk();
for inner in child.children(&mut c2) {
if is_wordish(inner.kind()) {
push_word(&mut words, node_text(inner, w));
}
}
}
"variable_assignment" => {
let text = node_text(child, w);
assignments.push(text.clone());
push_word(&mut words, text);
}
"$" => push_word(&mut words, "$".to_string()), kind if is_wordish(kind) => push_word(&mut words, node_text(child, w)),
_ => {}
}
}
(words, assignments)
}
fn push_word(words: &mut Vec<String>, word: String) {
if let Some(prev) = words.last_mut()
&& prev.ends_with('$')
{
prev.push_str(&word);
} else {
words.push(word);
}
}
fn walk_command<'a>(
cmd: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
flags: WalkFlags,
mut extras: Vec<String>,
) -> Result<(), String> {
w.last_start = state.snapshot();
let (mut words, assignments) = collect_command_words(cmd, w);
words.append(&mut extras);
let mut cursor = cmd.walk();
for child in cmd.children(&mut cursor) {
if child.kind() == "subshell" {
walk_node(child, w, state, WalkFlags::default(), Vec::new())?;
} else {
walk_word_substitutions(child, w, state, flags)?;
}
}
for a in &assignments {
check_git_env_binding(a)?;
}
let mut words_refs: Vec<&str> = words.iter().map(String::as_str).collect();
check_words(&mut words_refs, state, flags, &words)
}
#[expect(clippy::too_many_lines)] fn check_words(
words: &mut [&str],
state: &mut ValidationState,
flags: WalkFlags,
originals: &[String],
) -> Result<(), String> {
if words.is_empty() {
return Ok(());
}
let negated = flags.negated || flags.time_external;
let (verb_idx, verb) = match resolve_verb(words, negated) {
VerbResolution::Informational | VerbResolution::None => {
apply_env_bindings(words, state, None)?;
return Ok(());
}
VerbResolution::Verb {
class: VerbClass::Unprovable,
..
} => {
if !is_bare_substitution_segment(words) {
let cmd = originals.join(" ");
return reject(
&cmd,
"the command verb cannot be proven safe (concatenated quotes, escapes, or substitution-formed).",
"write the command name literally (e.g. `cd`, `rm`) so it can be validated.",
);
}
apply_env_bindings(words, state, None)?;
return Ok(());
}
VerbResolution::Verb {
idx,
class: VerbClass::Literal(v),
} => (idx, v),
};
if matches!(verb, "cd" | "pushd" | "popd") {
process_cd_words(words, verb_idx, verb, state);
return Ok(());
}
if verb == "eval" {
handle_eval_body(&words[verb_idx + 1..], state)?;
return Ok(());
}
if matches!(verb, "{" | "}") {
let cmd = originals.join(" ");
return reject(
&cmd,
"`{`/`}` at command position — a brace group (`{ ...; }`) here was flattened by the parser, or the brace is a stray terminator. Rejected fail-closed rather than validate its flattened words.",
"remove the stray brace, or write the command without the brace group.",
);
}
if matches!(
verb,
")" | "fi" | "done" | "esac" | "then" | "do" | "elif" | "else" | ";;" | ";&" | ";;&"
) {
let cmd = originals.join(" ");
return reject(
&cmd,
&format!(
"`{verb}` is a shell control keyword appearing outside its construct — rejected fail-closed (bash rejects it too)."
),
"remove the stray keyword, or complete the construct it belongs to.",
);
}
apply_env_bindings(words, state, Some((verb_idx, verb)))?;
let segment = originals.join(" ");
let first_word = super::first_command_word(&segment);
if first_word.is_empty() {
return Ok(());
}
let first_word = match classify_verb_word(first_word) {
VerbClass::Literal(v) => v,
VerbClass::Unprovable => {
return reject(
&segment,
"the command verb cannot be proven safe (concatenated quotes, escapes, or substitution-formed).",
"write the command name literally (e.g. `rm`, `touch`) so it can be validated.",
);
}
};
if first_word == "mktemp" {
return Ok(());
}
for check in MUTATOR_CHECKS {
if !check.verbs.contains(&first_word) {
continue;
}
if check
.rejects
.is_none_or(|reject| reject(&segment, first_word, state))
{
let (education, fallback) = check.suggestions;
return reject(
&segment,
&check.rejection.replace("{verb}", first_word),
if has_unresolved_var_path(&segment, state) {
education
} else {
fallback
},
);
}
if first_word == "mkdir" {
record_mkdir_targets(&segment, state);
}
return Ok(());
}
if first_word == "git" {
return check_git_segment(&segment);
}
for check in FLAG_CHECKS {
if first_word == check.verb && (check.predicate)(&segment, state) {
return reject(&segment, check.rejection, check.suggestion);
}
}
Ok(())
}
fn walk_redirected<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
flags: WalkFlags,
extras: Vec<String>,
) -> Result<(), String> {
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
let Some((body_idx, &body)) = children
.iter()
.enumerate()
.find(|(_, c)| is_commandish(c.kind()))
else {
return Err(unrecognized_node(node, w));
};
let redirects = &children[body_idx + 1..];
let mut extra = extras;
for r in redirects {
collect_redirect_extras(*r, w, &mut extra);
}
if !extra.is_empty() && !matches!(body.kind(), "command" | "list" | "pipeline") {
return Err(redirect_extras_on_construct(w));
}
w.last_start = state.snapshot();
walk_body(body, w, state, flags, extra)?;
let mut redirect_state = w.last_start.snapshot();
let owner_negated = flags.negated || flags.time_external || body.kind() == "negated_command";
let (mut assignments, assignment_only) = owning_command(body).map_or_else(
|| (Vec::new(), false),
|n| owning_command_assignments(n, w, owner_negated),
);
if assignment_only {
for a in &assignments {
bind_assignment_word(a, &mut redirect_state);
}
assignments = Vec::new();
}
for r in redirects {
if r.kind() == "heredoc_redirect" {
validate_heredoc(*r, w, &mut redirect_state, &assignments, state)?;
} else {
validate_file_redirect(*r, w, &mut redirect_state)?;
}
}
Ok(())
}
fn owning_command(body: Node) -> Option<Node> {
match body.kind() {
"command" | "variable_assignment" | "variable_assignments" => Some(body),
"negated_command" => body
.children(&mut body.walk())
.find(|c| is_commandish(c.kind()))
.and_then(owning_command),
"list" | "pipeline" => body
.children(&mut body.walk())
.filter(|c| is_commandish(c.kind()))
.last()
.and_then(owning_command),
_ => None,
}
}
fn owning_command_assignments(owner: Node, w: &W, negated: bool) -> (Vec<String>, bool) {
match owner.kind() {
"variable_assignment" => (vec![node_text(owner, w)], true),
"variable_assignments" => (
owner
.children(&mut owner.walk())
.filter(|c| c.kind() == "variable_assignment")
.map(|c| node_text(c, w))
.collect(),
true,
),
"command" => {
let (words, mut assignments) = collect_command_words(owner, w);
let word_refs: Vec<&str> = words.iter().map(String::as_str).collect();
let mut assignment_only = true;
let mut i = 0;
while i < words.len() {
let unquoted = scan::strip_outer_quotes(&words[i]).map_or("", |(c, _)| c);
match unquoted {
"command" | "builtin" => {
assignment_only = false;
break;
}
"time" => {
let Some(past) = consume_time_prefix(&word_refs, i, negated) else {
assignment_only = false;
break;
};
let (after, head) = scan_time_operand_head(&word_refs, past);
assignments.extend(head.into_iter().map(String::from));
i = after;
}
_ => {
assignment_only = false;
break;
}
}
}
(assignments, assignment_only)
}
_ => (Vec::new(), false),
}
}
fn walk_body<'a>(
body: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
flags: WalkFlags,
extras: Vec<String>,
) -> Result<(), String> {
match body.kind() {
"command" => walk_command(body, w, state, flags, extras),
"list" => {
let mut cursor = body.walk();
let children: Vec<Node> = body.children(&mut cursor).collect();
walk_sequence_of(&children, w, state, flags, &extras)
}
"pipeline" => walk_pipeline(body, w, state, flags, &extras),
kind if is_commandish(kind) => walk_node(body, w, state, flags, extras),
_ => Err(unrecognized_node(body, w)),
}
}
fn collect_redirect_extras(node: Node, w: &W<'_>, out: &mut Vec<String>) {
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
match node.kind() {
"file_redirect" => {
let mut seen_target = false;
for c in &children {
if c.kind() == "file_descriptor" {
continue;
}
if is_wordish(c.kind()) {
if !seen_target {
seen_target = true;
continue;
}
out.push(node_text(*c, w));
}
}
}
"heredoc_redirect" => {
let mut marker_end = 0usize;
let mut after_start = false;
for c in &children {
if c.kind() == "heredoc_start" {
after_start = true;
marker_end = c.end_byte();
continue;
}
if !after_start || c.kind() == "heredoc_body" || c.kind() == "heredoc_end" {
continue;
}
if is_wordish(c.kind())
&& !node_text(*c, w).starts_with('\n')
&& !text_between_has_newline(&w.src, marker_end, c.start_byte())
{
out.push(node_text(*c, w));
}
}
}
_ => {}
}
}
fn text_between_has_newline(src: &str, from: usize, to: usize) -> bool {
src.get(from..to).is_some_and(|s| s.contains('\n'))
}
fn validate_redirect<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
) -> Result<(), String> {
match node.kind() {
"file_redirect" => validate_file_redirect(node, w, state),
"heredoc_redirect" => {
let mut snap = state.snapshot();
validate_heredoc(node, w, state, &[], &mut snap)
}
"herestring_redirect" => Err(parse_error(&w.src)),
_ => Err(unrecognized_node(node, w)),
}
}
fn validate_file_redirect<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
) -> Result<(), String> {
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
for c in &children {
walk_word_substitutions(*c, w, state, WalkFlags::default())?;
}
let mut op: Option<String> = None;
let mut target: Option<Node> = None;
let mut fd_dup_target = false;
for c in &children {
match c.kind() {
"file_descriptor" => {}
">" | ">>" | ">|" | ">&" | "<" | "<&" | "&>" | "&>>" | "<>" => {
op = Some(c.kind().to_string());
}
kind if is_wordish(kind) || kind == "number" => {
if target.is_none() {
target = Some(*c);
fd_dup_target = c.kind() == "number";
}
}
"ERROR" => return Err(parse_error(&w.src)),
_ => {}
}
}
let Some(op) = op else {
return Err(unrecognized_node(node, w));
};
let is_output = matches!(op.as_str(), ">" | ">>" | ">|" | ">&" | "&>" | "&>>");
let is_dup = op == ">&" || op == "<&";
if !is_output {
return Ok(()); }
if is_dup && fd_dup_target {
return Ok(()); }
let Some(target) = target else {
return Err(format!(
"⚠️ Read-only mode: command contains a disallowed output redirect (bare redirect with no target).\n\
Command: `{}`\n\
Suggestion: write the redirect target explicitly, or drop the redirect.",
w.src
));
};
let target_text = node_text(target, w);
if target_text == "/dev/null" {
return Ok(());
}
if writes_outside_temp(&target_text, state) {
return Err(disallowed_redirect_err(&w.src, &target_text));
}
Ok(())
}
fn disallowed_redirect_err(cmd: &str, target: &str) -> String {
format!(
"⚠️ Read-only mode: command contains a disallowed output redirect (`{target}`).\n\
Command: `{cmd}`\n\
Redirects are only allowed to /dev/null, 2>&1, 1>&2, or paths under /tmp, /var/tmp, or the OS temp directory.\n\
Suggestion: pipe to a pager (e.g., `| less`) or use `| head` to limit output."
)
}
fn validate_heredoc<'a>(
node: Node,
w: &mut W<'a>,
cmd_state: &mut ValidationState<'a>,
assignments: &[String],
tail_state: &mut ValidationState<'a>,
) -> Result<(), String> {
let mut body_state = cmd_state.snapshot();
for a in assignments {
bind_assignment_word(a, &mut body_state);
}
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
let mut unquoted = true;
let mut body: Option<Node> = None;
let mut marker_line_end = 0usize;
let mut seen_start = false;
let mut seen_body = false;
for c in &children {
match c.kind() {
"<<" | "<<-" | "&&" | "||" => {}
"heredoc_start" => {
seen_start = true;
marker_line_end = c.end_byte();
let marker = node_text(*c, w);
let bare = marker.strip_prefix(['\'', '"']).unwrap_or(&marker);
let bare = bare.strip_suffix(['\'', '"']).unwrap_or(bare);
if bare.is_empty()
|| bare.chars().any(|ch| {
ch.is_whitespace()
|| matches!(ch, '|' | '>' | '<' | '&' | ';' | '(' | ')' | '`')
})
{
return Err(format!(
"⚠️ Read-only mode: malformed heredoc delimiter `{marker}` — rejected fail-closed.\n\
Command: `{}`\n\
Suggestion: give the heredoc a plain word delimiter (e.g. `<<'EOF'`) and put commands after the terminator line.",
w.src
));
}
unquoted = !marker.starts_with(['\'', '"']);
}
"heredoc_body" => {
seen_body = true;
body = Some(*c);
}
"heredoc_end" => break,
"file_redirect" => {
validate_file_redirect(*c, w, cmd_state)?;
}
"pipeline" => {
let mut snap = cmd_state.snapshot();
walk_node(*c, w, &mut snap, WalkFlags::default(), Vec::new())?;
}
kind if is_commandish(kind) => {
walk_node(*c, w, tail_state, WalkFlags::default(), Vec::new())?;
}
kind if is_wordish(kind) => {
let marker_line = !seen_body
&& seen_start
&& !node_text(*c, w).starts_with('\n')
&& !text_between_has_newline(&w.src, marker_line_end, c.start_byte());
if marker_line {
walk_word_substitutions(*c, w, cmd_state, WalkFlags::default())?;
} else if unquoted {
walk_word_substitutions(*c, w, &mut body_state, WalkFlags::default())?;
minimal_body_scan(&node_text(*c, w))?;
}
}
_ => walk_word_substitutions(*c, w, cmd_state, WalkFlags::default())?,
}
}
if let Some(body_node) = body
&& unquoted
{
let mut bcur = body_node.walk();
let bchildren: Vec<Node> = body_node.children(&mut bcur).collect();
if bchildren.is_empty() {
minimal_body_scan(&node_text(body_node, w))?;
} else {
for bc in &bchildren {
match bc.kind() {
"command_substitution"
| "process_substitution"
| "expansion"
| "arithmetic_expansion" => {
let mut snap = body_state.snapshot();
walk_word_substitutions(*bc, w, &mut snap, WalkFlags::default())?;
}
"heredoc_content" => minimal_body_scan(&node_text(*bc, w))?,
_ => {}
}
}
}
}
Ok(())
}
fn minimal_body_scan(text: &str) -> Result<(), String> {
let mut i = 0;
while i < text.len() {
let c = text[i..].chars().next().expect("i < len");
if c == '\\' {
i += c.len_utf8();
if i < text.len() {
i += text[i..].chars().next().expect("i < len").len_utf8();
}
continue;
}
if c == '`' || (c == '$' && text[i + c.len_utf8()..].starts_with('(')) {
return Err("⚠️ Read-only mode: command substitution inside an unquoted heredoc body is not allowed.\n\
Suggestion: quote the heredoc delimiter (e.g. `<<'EOF'`) to make the body literal, or remove the `$()`/backticks.".to_string());
}
i += c.len_utf8();
}
Ok(())
}
fn note_part_cd(part: &ValidationState, base: &mut ValidationState) {
if part.cd_count != base.cd_count {
base.cwd = None;
base.cd_count = part.cd_count;
}
for (name, pb) in &part.vars {
match base.vars.get(name) {
Some(bb) if !bb.poisoned && !pb.poisoned && bb.value == pb.value => {}
None | Some(_) => {
base.vars.insert(
name.clone(),
VarBinding {
poisoned: true,
value: pb.value.clone(),
},
);
}
}
}
}
fn head_has_separator(src: &str, after: usize, start: usize) -> bool {
src.get(after..start)
.is_some_and(|s| s.contains(['\n', ';']))
}
fn walk_part<'a>(
nodes: &[Node],
w: &mut W<'a>,
state: &mut ValidationState<'a>,
time_external: bool,
head_end: usize,
) -> Result<(), String> {
let mut part = state.snapshot();
let demote = time_external
&& !nodes
.iter()
.take_while(|n| !is_commandish(n.kind()))
.any(|n| n.kind() == ";")
&& !nodes
.iter()
.find(|n| is_commandish(n.kind()))
.is_some_and(|c| head_has_separator(&w.src, head_end, c.start_byte()));
walk_sequence_of(
nodes,
w,
&mut part,
WalkFlags {
time_external: demote,
..WalkFlags::default()
},
&[],
)?;
note_part_cd(&part, state);
Ok(())
}
fn split_children<'t>(
node: Node<'t>,
_w: &W<'_>,
start: &str,
mid: &str,
end: &str,
) -> (Vec<Node<'t>>, Vec<Node<'t>>, Vec<Node<'t>>, bool) {
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
let mut first: Vec<Node> = Vec::new();
let mut second: Vec<Node> = Vec::new();
let mut third: Vec<Node> = Vec::new();
let mut phase = 0u8;
let mut closed = false;
for c in &children {
match c.kind() {
k if k == start => phase = 1,
k if k == mid => {
phase = 2;
closed = true;
}
k if k == end => {
phase = 3;
closed = true;
}
_ => match phase {
1 => first.push(*c),
2 => second.push(*c),
_ => third.push(*c),
},
}
}
(first, second, third, closed)
}
fn walk_if<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
extras: &[String],
) -> Result<(), String> {
let mut g = LastStartGuard::new(w);
let w = g.w();
let (cond, body, _rest, _) = split_children(node, w, "if", "then", "fi");
walk_part(&cond, w, state, true, keyword_end(node, "if"))?;
walk_part(&body, w, state, false, 0)?;
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"elif_clause" => {
let (econd, ebody, _, _) = split_children(child, w, "elif", "then", "fi");
walk_part(&econd, w, state, true, keyword_end(child, "elif"))?;
walk_part(&ebody, w, state, false, 0)?;
}
"else_clause" => {
let (ebody, _, _, _) = split_children(child, w, "else", "then", "fi");
walk_part(&ebody, w, state, false, 0)?;
}
_ => {}
}
}
reject_redirect_extras(extras, w)
}
fn keyword_end(node: Node<'_>, kind: &str) -> usize {
let mut cursor = node.walk();
node.children(&mut cursor)
.find(|c| c.kind() == kind)
.map_or(0, |c| c.end_byte())
}
fn redirect_extras_on_construct(w: &W) -> String {
format!(
"⚠️ Read-only mode: a word follows a redirect on a compound command — rejected fail-closed (bash rejects it as a syntax error).\n\
Command: `{}`",
w.src
)
}
fn reject_redirect_extras(extras: &[String], w: &W) -> Result<(), String> {
if extras.is_empty() {
Ok(())
} else {
Err(redirect_extras_on_construct(w))
}
}
fn walk_while_until<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
extras: &[String],
) -> Result<(), String> {
let mut g = LastStartGuard::new(w);
let w = g.w();
let (cond, _, _, _) = split_children(node, w, "while", "do", "done");
if cond.is_empty() {
let (cond, _, _, _) = split_children(node, w, "until", "do", "done");
walk_part(&cond, w, state, true, keyword_end(node, "until"))?;
} else {
walk_part(&cond, w, state, true, keyword_end(node, "while"))?;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "do_group" {
let mut part = state.snapshot();
walk_node(child, w, &mut part, WalkFlags::default(), Vec::new())?;
note_part_cd(&part, state);
}
}
reject_redirect_extras(extras, w)
}
fn walk_for<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
extras: &[String],
) -> Result<(), String> {
let mut g = LastStartGuard::new(w);
let w = g.w();
let mut header_words: Vec<String> = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"in" | "for" | "select" | ";" | "\n" | "variable_name" => {}
kind if is_wordish(kind) => {
header_words.push(node_text(child, w));
}
"do_group" => {
let mut part = state.snapshot();
if let Some(binding) = loop_var_binding(&header_words, state) {
let name = loop_var_name(node, w);
part.vars.insert(name, binding);
}
walk_node(child, w, &mut part, WalkFlags::default(), Vec::new())?;
note_part_cd(&part, state);
}
_ => {
walk_word_substitutions(child, w, state, WalkFlags::default())?;
}
}
}
reject_redirect_extras(extras, w)
}
fn loop_var_name(node: Node, w: &W) -> String {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "variable_name" {
return node_text(child, w);
}
}
String::new()
}
fn walk_c_style_for<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
extras: &[String],
) -> Result<(), String> {
let mut g = LastStartGuard::new(w);
let w = g.w();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"do_group" => {
let mut part = state.snapshot();
walk_node(child, w, &mut part, WalkFlags::default(), Vec::new())?;
note_part_cd(&part, state);
}
_ => walk_word_substitutions(child, w, state, WalkFlags::default())?,
}
}
reject_redirect_extras(extras, w)
}
fn walk_case<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
extras: &[String],
) -> Result<(), String> {
let mut g = LastStartGuard::new(w);
let w = g.w();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"case_item" => {
let mut part = state.snapshot();
walk_case_item(child, w, &mut part)?;
note_part_cd(&part, state);
}
"case" | "in" | "esac" | ";" | "\n" => {}
_ => walk_word_substitutions(child, w, state, WalkFlags::default())?,
}
}
reject_redirect_extras(extras, w)
}
fn walk_case_item<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
) -> Result<(), String> {
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
let mut body: Vec<Node> = Vec::new();
for c in &children {
match c.kind() {
")" | ";;" | ";&" | ";;&" | "|" => {}
kind if is_wordish(kind) => {
walk_word_substitutions(*c, w, state, WalkFlags::default())?;
}
kind if is_commandish(kind) => body.push(*c),
_ => {}
}
}
if body.is_empty() {
return Ok(());
}
let paren_end = children
.iter()
.find(|c| c.kind() == ")")
.map_or(0, tree_sitter::Node::end_byte);
let demote = !body
.first()
.is_some_and(|c| head_has_separator(&w.src, paren_end, c.start_byte()));
let mut part = state.snapshot();
walk_sequence_of(
&body,
w,
&mut part,
WalkFlags {
time_external: demote,
..WalkFlags::default()
},
&[],
)?;
note_part_cd(&part, state);
Ok(())
}
fn walk_compound<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
extras: &[String],
) -> Result<(), String> {
let mut g = LastStartGuard::new(w);
let w = g.w();
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
if children.iter().any(|c| c.kind() == "((") {
reject_redirect_extras(extras, w)?;
for c in &children {
walk_word_substitutions(*c, w, state, WalkFlags::default())?;
}
return Ok(());
}
let entry_count = state.cd_count;
let body: Vec<Node> = children
.iter()
.copied()
.filter(|c| is_commandish(c.kind()))
.collect();
walk_sequence_of(&body, w, state, WalkFlags::default(), extras)?;
if state.cd_count != entry_count {
state.cwd = None;
}
Ok(())
}
fn walk_subshell<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
extras: &[String],
) -> Result<(), String> {
let mut g = LastStartGuard::new(w);
let w = g.w();
let mut snap = state.snapshot();
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
let body: Vec<Node> = children
.iter()
.copied()
.filter(|c| is_commandish(c.kind()))
.collect();
walk_sequence_of(&body, w, &mut snap, WalkFlags::default(), extras)
}
fn walk_function<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
extras: &[String],
) -> Result<(), String> {
reject_redirect_extras(extras, w)?;
let mut g = LastStartGuard::new(w);
let w = g.w();
let mut snap = state.snapshot();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if is_commandish(child.kind()) {
walk_node(child, w, &mut snap, WalkFlags::default(), Vec::new())?;
}
}
Ok(())
}
fn walk_pipeline<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
flags: WalkFlags,
extras: &[String],
) -> Result<(), String> {
let mut g = LastStartGuard::new(w);
let w = g.w();
let mut cursor = node.walk();
let members: Vec<Node> = node
.children(&mut cursor)
.filter(|c| is_commandish(c.kind()))
.collect();
let base = state.snapshot();
for (i, m) in members.iter().enumerate() {
let mflags = if i == 0 {
flags
} else {
WalkFlags {
time_external: false,
..flags
}
};
let mextras = if i + 1 == members.len() {
extras.to_vec()
} else {
Vec::new()
};
let mut mstate = base.snapshot();
walk_node(*m, w, &mut mstate, mflags, mextras)?;
}
Ok(())
}
fn walk_declaration<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
) -> Result<(), String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_word_substitutions(child, w, state, WalkFlags::default())?;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"variable_assignment" => {
let text = node_text(child, w);
check_git_env_binding(&text)?;
bind_assignment_word(&text, state);
}
"string" => {
let text = node_text(child, w);
if let Some((inner, _)) = scan::strip_outer_quotes(&text) {
check_git_env_binding(inner)?;
bind_assignment_word(inner, state);
}
}
kind if is_wordish(kind) => {
let text = node_text(child, w);
check_git_env_binding(&text)?;
bind_assignment_word(&text, state);
}
_ => {}
}
}
Ok(())
}
fn walk_unset<'a>(
node: Node,
w: &mut W<'a>,
state: &mut ValidationState<'a>,
) -> Result<(), String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_word_substitutions(child, w, state, WalkFlags::default())?;
match child.kind() {
"variable_name" | "word" => {
let text = node_text(child, w);
let name = scan::strip_quoted_word(&text);
if !name.starts_with('-') && !name.is_empty() {
state.vars.insert(
name.to_string(),
VarBinding {
value: Some(String::new()),
poisoned: false,
},
);
}
}
_ => {}
}
}
Ok(())
}
fn bind_assignments<'a>(
node: Node,
w: &W<'a>,
state: &mut ValidationState<'a>,
) -> Result<(), String> {
if node.kind() == "variable_assignment" {
let text = node_text(node, w);
check_git_env_binding(&text)?;
bind_assignment_word(&text, state);
return Ok(());
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "variable_assignment" {
let text = node_text(child, w);
check_git_env_binding(&text)?;
bind_assignment_word(&text, state);
}
}
Ok(())
}
fn temp_anchor_path(ctx: &CheckContext) -> String {
let root = crate::temp_root::bare_mktemp_landing_root();
let root = if ctx.temp_roots.contains(&root) {
root
} else {
ctx.temp_roots
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from("/tmp"))
};
format!("{}/{TEMP_ANCHOR_SEGMENT}", root.display())
}
const TEMP_ANCHOR_SEGMENT: &str = "__mahbot_temp__";
const OPAQUE_SEGMENT: &str = "__mahbot_opaque__";
fn is_under_temp_prefix(out: &str, state: &ValidationState) -> bool {
let p = Path::new(out);
p.is_absolute() && crate::tools::path::is_path_under_roots(p, &state.ctx.temp_roots)
}
fn parse_var_ref(rest: &str) -> Option<(&str, usize)> {
let after_dollar = rest.strip_prefix('$')?;
if let Some(braced) = after_dollar.strip_prefix('{') {
let end = braced.find('}')?;
let name = &braced[..end];
if name.is_empty()
|| name.contains([':', '-', '=', '?', '+', '/', '#', '%', '!', '@', '*', '['])
{
return None;
}
return Some((name, end + 3)); }
let name = after_dollar
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect::<String>();
if name.is_empty() {
return None;
}
Some((&after_dollar[..name.len()], name.len() + 1))
}
enum VarValue {
Concrete(String),
TempRoot,
Opaque,
Blocked,
}
fn resolve_var(name: &str, state: &ValidationState) -> VarValue {
match name {
"PWD" => match state.vars.get(name) {
Some(b) => {
if b.poisoned {
VarValue::Blocked
} else {
match &b.value {
Some(v) => VarValue::Concrete(v.clone()),
None => VarValue::TempRoot,
}
}
}
None => match &state.cwd {
Some(cwd) => VarValue::Concrete(cwd.to_string_lossy().into_owned()),
None => VarValue::Blocked,
},
},
"HOME" => VarValue::Blocked,
_ => match state.vars.get(name) {
None => VarValue::Opaque,
Some(VarBinding { poisoned: true, .. }) => VarValue::Blocked,
Some(VarBinding { value: None, .. }) => VarValue::TempRoot,
Some(VarBinding {
value: Some(v),
poisoned: false,
}) if !v.is_empty() => VarValue::Concrete(v.clone()),
Some(VarBinding {
value: Some(_),
poisoned: false,
}) => VarValue::Concrete(String::new()),
},
}
}
fn expand_vars(word: &str, single_quoted: bool, state: &ValidationState) -> Option<String> {
if single_quoted {
if word.contains('$') {
return None;
}
return Some(word.to_string());
}
let mut out = String::with_capacity(word.len());
let mut opaque_from: Option<usize> = None;
let mut i = 0;
while i < word.len() {
let rest = &word[i..];
if let Some(next) = rest.strip_prefix('\\') {
let c = next.chars().next()?;
if matches!(c, '$' | '\\' | '`') {
out.push(c);
} else {
out.push('\\');
out.push(c);
}
i += 1 + c.len_utf8();
continue;
}
if let Some((name, len)) = parse_var_ref(rest) {
match resolve_var(name, state) {
VarValue::Concrete(text) => out.push_str(&text),
VarValue::TempRoot => out.push_str(&temp_anchor_path(state.ctx)),
VarValue::Opaque => {
if name != "RANDOM" {
return None;
}
if opaque_from.is_none() && !is_under_temp_prefix(&out, state) {
return None;
}
if opaque_from.is_none() {
opaque_from = Some(out.len());
}
out.push_str(OPAQUE_SEGMENT);
}
VarValue::Blocked => return None,
}
i += len;
} else {
let c = rest.chars().next().expect("i < len");
if c == '`'
|| (c == '$'
&& rest
.as_bytes()
.get(1)
.is_some_and(|b| matches!(*b, b'(' | b'{')))
{
return None;
}
out.push(c);
i += c.len_utf8();
}
}
if let Some(start) = opaque_from
&& out[start..].split('/').any(|seg| seg == "..")
{
return None;
}
Some(out)
}
fn resolve_path_word(word: &str, state: &ValidationState) -> Option<std::path::PathBuf> {
let word = word.trim();
if word.is_empty() || word.starts_with('~') {
return None;
}
let (clean, single_quoted) =
scan::strip_outer_quotes(word).map_or((word, false), |(c, q)| (c, q));
let expanded = expand_vars(clean, single_quoted, state)?;
let p = Path::new(&expanded);
if p.is_absolute() {
return Some(p.to_path_buf());
}
if crate::tools::path::contains_glob(&expanded, false) {
return None;
}
let cwd = state.cwd.as_ref()?;
Some(cwd.join(p))
}
fn writes_outside_temp(word: &str, state: &ValidationState) -> bool {
let Some(p) = resolve_path_word(word, state) else {
return true;
};
!is_path_under_temp(&p, state.ctx)
}
fn is_path_under_temp(path: &std::path::Path, ctx: &CheckContext) -> bool {
if !crate::tools::path::is_path_under_roots(path, &ctx.temp_roots) {
return false;
}
let mut probe = path;
loop {
if let Ok(canon) = std::fs::canonicalize(probe) {
return crate::tools::path::is_path_under_roots(&canon, &ctx.temp_roots);
}
let Some(parent) = probe.parent() else {
return true;
};
if parent == probe {
return true;
}
probe = parent;
}
}
fn check_git_env_binding(word: &str) -> Result<(), String> {
let w = scan::strip_quoted_word(word);
if let Some((name, _)) = w.split_once('=')
&& name.starts_with("GIT_")
&& name != "GIT_PAGER"
{
return reject(
word,
&format!(
"`{name}` environment bindings are not allowed — git env vars can execute programs (GIT_EXTERNAL_DIFF, GIT_SSH_COMMAND, ...)."
),
"run git without GIT_* environment assignments.",
);
}
Ok(())
}
fn apply_env_bindings(
words: &[&str],
state: &mut ValidationState,
export_verb: Option<(usize, &str)>,
) -> Result<(), String> {
let first_non_assign = words
.iter()
.position(|w| !super::is_env_assignment(w))
.unwrap_or(words.len());
for w in &words[..first_non_assign] {
check_git_env_binding(w)?;
bind_assignment_word(w, state);
}
if let Some((idx, v)) = export_verb
&& v == "export"
{
for w in &words[idx + 1..] {
check_git_env_binding(w)?;
bind_assignment_word(w, state);
}
}
Ok(())
}
fn bind_assignment_word(w: &str, state: &mut ValidationState) {
let Some((name, value)) = w.split_once('=') else {
return;
};
apply_single_binding(name, value, state);
}
fn apply_single_binding(name: &str, value: &str, state: &mut ValidationState) {
let name = scan::strip_quoted_word(name);
let (clean, single_quoted) =
scan::strip_outer_quotes(value).map_or((value, false), |(c, q)| (c, q));
if !single_quoted {
if let Some(binding) = mktemp_binding(clean, state) {
state.vars.insert(name.to_string(), binding);
return;
}
if substitution_body(clean).is_some() {
state.vars.insert(
name.to_string(),
VarBinding {
value: Some(clean.to_string()),
poisoned: true,
},
);
return;
}
}
let resolved = if !clean.is_empty() && !clean.starts_with('~') {
if single_quoted && clean.contains('$') {
None } else {
resolve_path_word(clean, state)
}
} else {
None
};
let under_temp = resolved
.as_ref()
.is_some_and(|p| is_path_under_temp(p, state.ctx));
state.vars.insert(
name.to_string(),
VarBinding {
value: Some(
resolved.map_or_else(|| clean.to_string(), |p| p.to_string_lossy().into_owned()),
),
poisoned: !under_temp,
},
);
}
fn substitution_body(value: &str) -> Option<&str> {
if let Some(inner) = value.strip_prefix("$(") {
return inner.strip_suffix(')');
}
if let Some(inner) = value.strip_prefix('`') {
return inner.strip_suffix('`');
}
None
}
fn mktemp_binding(value: &str, state: &ValidationState) -> Option<VarBinding> {
let inner = substitution_body(value)?;
let mut words = inner.split_whitespace();
if words.next()? != "mktemp" {
return None;
}
let args: Vec<&str> = words.collect();
let mut target_dir: Option<&str> = None;
let mut template: Option<&str> = None;
let mut after_ddash = false;
let mut i = 0;
while i < args.len() {
let a = args[i];
if a == "--" {
after_ddash = true;
i += 1;
continue;
}
if !after_ddash {
if a == "-p" {
if i + 1 < args.len() {
target_dir = Some(args[i + 1]);
i += 2;
continue;
}
return None; }
if a == "--tmpdir" {
return None;
}
if let Some(dir) = a.strip_prefix("--tmpdir=") {
target_dir = Some(dir);
i += 1;
continue;
}
if a == "-t" {
if i + 1 < args.len() {
i += 2;
continue;
}
return None;
}
if matches!(a, "-d" | "-q" | "-u" | "--dry-run" | "--quiet") {
i += 1;
continue;
}
if a.starts_with('-') {
return None;
}
}
if template.is_some() {
return None;
}
template = Some(a);
i += 1;
}
if let Some(t) = template {
let clean = scan::strip_quoted_word(t);
if !clean.ends_with("XXXXXX") {
return None;
}
let p = resolve_path_word(clean, state)?;
let parent = p.parent()?;
let parent_norm = crate::tools::path::normalize_path(parent);
if !is_path_under_temp(&p, state.ctx)
|| !path_exists_or_created(parent, &parent_norm, state)
{
return None;
}
}
match target_dir {
None => Some(VarBinding {
value: None,
poisoned: false,
}),
Some(dir) => {
let clean = scan::strip_quoted_word(dir);
let raw = resolve_path_word(clean, state)?;
let normalized = crate::tools::path::normalize_path(&raw);
if is_path_under_temp(&normalized, state.ctx)
&& path_exists_or_created(&raw, &normalized, state)
{
Some(VarBinding {
value: None,
poisoned: false,
})
} else {
None
}
}
}
}
fn path_exists_or_created(
raw: &std::path::Path,
normalized: &std::path::Path,
state: &ValidationState,
) -> bool {
raw.is_dir()
|| (!raw
.components()
.any(|c| c == std::path::Component::ParentDir)
&& state.created_dirs.contains(normalized))
}
fn process_cd_words(words: &[&str], cd_idx: usize, verb: &str, state: &mut ValidationState) {
state.cd_count += 1;
if verb != "cd" {
state.cwd = None;
return;
}
let (target, next) = match scan::cd_target_after_options(words, cd_idx + 1) {
CdScan::Target(target, next) => (target, next),
CdScan::Bare | CdScan::BadOption => {
state.cwd = None;
return;
}
};
if words.get(next).is_some() {
state.cwd = None;
return;
}
if target.starts_with('/') {
let p = std::path::PathBuf::from(target);
if is_path_under_temp(&p, state.ctx) {
let normalized = crate::tools::path::normalize_path(&p);
state.cwd = if path_exists_or_created(&p, &normalized, state) {
Some(p)
} else {
None
};
} else {
state.cwd = if p.is_dir() { Some(p) } else { None };
}
} else {
let Some(cwd) = &state.cwd else {
state.cwd = None;
return;
};
if !is_path_under_temp(cwd, state.ctx) {
state.cwd = None;
return;
}
if target.starts_with('~') || target == "-" {
state.cwd = None;
return;
}
let Some(resolved) = resolve_path_word(target, state) else {
state.cwd = None;
return;
};
let normalized = crate::tools::path::normalize_path(&resolved);
state.cwd = if is_path_under_temp(&normalized, state.ctx)
&& path_exists_or_created(&resolved, &normalized, state)
{
Some(normalized)
} else {
None
};
}
}
fn handle_eval_body(body_words: &[&str], state: &mut ValidationState) -> Result<(), String> {
let joined = body_words.join(" ");
let decoded = if let Some((content, _)) = scan::strip_outer_quotes(&joined) {
content.to_string()
} else {
let mut s = String::with_capacity(joined.len());
s.extend(joined.chars().filter(|c| !matches!(c, '\'' | '"')));
s
};
if decoded.trim().is_empty() {
return Ok(());
}
parse_and_walk(&decoded, state)
}
fn parse_and_walk(text: &str, state: &mut ValidationState) -> Result<(), String> {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_bash::LANGUAGE.into())
.map_err(|_| "failed to initialize the bash parser".to_string())?;
let tree = parser
.parse(text, None)
.ok_or_else(|| "failed to parse the command".to_string())?;
let root = tree.root_node();
if root.has_error() {
return Err(parse_error(text));
}
let mut w = W {
src: text.to_string(),
last_start: state.snapshot(),
};
walk_node(root, &mut w, state, WalkFlags::default(), Vec::new())
}
fn record_mkdir_targets(segment: &str, state: &mut ValidationState) {
let parents = segment
.split_whitespace()
.any(|w| w == "-p" || w == "--parents");
for arg in non_flag_path_args(segment) {
let Some(resolved) = resolve_path_word(&arg, state) else {
continue;
};
let dir = crate::tools::path::normalize_path(&resolved);
if !is_path_under_temp(&dir, state.ctx) {
continue;
}
if parents {
state.created_dirs.insert(dir.clone());
let mut d = dir;
while let Some(parent) = d.parent() {
d = parent.to_path_buf();
if !is_path_under_temp(&d, state.ctx) {
break;
}
state.created_dirs.insert(d.clone());
}
} else if dir.parent().is_some_and(|p| {
p.is_dir()
|| state
.created_dirs
.contains(&crate::tools::path::normalize_path(p))
}) {
state.created_dirs.insert(dir);
}
}
}
fn loop_var_binding(words: &[String], state: &ValidationState) -> Option<VarBinding> {
if words.is_empty() {
return None;
}
words
.iter()
.all(|w| {
let prefix = w.find(['*', '?', '[']).map_or(w.as_str(), |g| &w[..g]);
if prefix.is_empty() {
return false;
}
resolve_path_word(prefix, state).is_some_and(|p| {
is_path_under_temp(&crate::tools::path::normalize_path(&p), state.ctx)
})
})
.then_some(VarBinding {
value: None,
poisoned: false,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum VerbClass<'a> {
Literal(&'a str),
Unprovable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum VerbResolution<'a> {
Verb { idx: usize, class: VerbClass<'a> },
Informational,
None,
}
fn resolve_verb<'a>(words: &[&'a str], negated: bool) -> VerbResolution<'a> {
let mut i = 0;
while i < words.len() && super::is_env_assignment(words[i]) {
i += 1;
}
loop {
if i >= words.len() {
return VerbResolution::None;
}
let w = words[i];
let unquoted = scan::strip_outer_quotes(w).map_or("", |(c, _)| c);
if !matches!(unquoted, "command" | "builtin" | "time") {
return VerbResolution::Verb {
idx: i,
class: classify_verb_word(w),
};
}
let opts = &words[i + 1..];
let mut j = 0;
let executes = match unquoted {
"command" => loop {
let Some(o) = opts.get(j) else {
return VerbResolution::Informational;
};
let oq = scan::strip_outer_quotes(o).map_or("", |(c, _)| c);
if oq == "--" {
j += 1;
break true;
}
if oq.starts_with('-') && oq.len() > 1 && oq[1..].bytes().all(|b| b == b'p') {
j += 1;
continue;
}
if oq.starts_with('-') && oq.len() > 1 {
return VerbResolution::Informational;
}
break true;
},
"builtin" => {
let Some(o) = opts.first() else {
return VerbResolution::Informational;
};
let oq = scan::strip_outer_quotes(o).map_or("", |(c, _)| c);
if oq == "--" {
j = 1;
true
} else if oq.starts_with('-') && oq.len() > 1 {
return VerbResolution::Informational;
} else {
true
}
}
_ => {
let Some(past) = consume_time_prefix(words, i, negated) else {
return VerbResolution::Verb {
idx: i,
class: classify_verb_word(w),
};
};
j = past - (i + 1);
if opts.get(j).is_some_and(|o| {
let oq = scan::strip_outer_quotes(o).map_or("", |(c, _)| c);
oq.starts_with('-') && oq.len() > 1
}) {
return VerbResolution::Informational;
}
true
}
};
if !executes {
return VerbResolution::Informational;
}
let idx = i + 1 + j;
if idx >= words.len() {
return VerbResolution::None;
}
i = idx;
if unquoted == "time" {
let (after, _) = scan_time_operand_head(words, i);
i = after;
}
}
}
fn consume_time_prefix(words: &[&str], i: usize, negated: bool) -> Option<usize> {
if i != 0 || words.get(i) != Some(&"time") || negated {
return None;
}
let mut j = i + 1;
if words.get(j) == Some(&"-p") {
j += 1;
}
Some(j)
}
fn scan_time_operand_head<'a>(words: &[&'a str], mut i: usize) -> (usize, Vec<&'a str>) {
if words.get(i) == Some(&"!") {
i += 1;
}
let mut assignments = Vec::new();
while words.get(i).is_some_and(|w| super::is_env_assignment(w)) {
assignments.push(words[i]);
i += 1;
}
(i, assignments)
}
fn classify_verb_word(w: &str) -> VerbClass<'_> {
if w.contains(['$', '\\', '{', '}', ',']) {
return VerbClass::Unprovable;
}
if let Some((content, _)) = scan::strip_outer_quotes(w) {
if content.is_empty() || content.contains(['\'', '"']) {
return VerbClass::Unprovable;
}
return VerbClass::Literal(content);
}
if w.contains(['\'', '"']) {
return VerbClass::Unprovable;
}
VerbClass::Literal(w)
}
fn is_bare_substitution_segment(words: &[&str]) -> bool {
let Some(first) = words.first() else {
return false;
};
if words.len() != 1 {
return false;
}
let s = first.trim();
scan::substitution_span(s, 0).is_some_and(|(_, next)| next == s.len() && !s.starts_with("${"))
}
fn reject<T>(cmd: &str, why: &str, suggestion: &str) -> Result<T, String> {
Err(format!(
"⚠️ Read-only mode: {why}\n\
Command: `{cmd}`\n\
Suggestion: {suggestion}"
))
}
const SCRATCH_MUTATORS: &[&str] = &["tee", "touch", "mkdir"];
const TEMP_MUTATORS: &[&str] = &[
"cp", "mv", "rm", "rmdir", "unlink", "gzip", "gunzip", "bzip2", "xz", "zstd", "zip",
];
struct MutatorCheck {
verbs: &'static [&'static str],
rejects: Option<fn(&str, &str, &ValidationState) -> bool>,
rejection: &'static str,
suggestions: (&'static str, &'static str),
}
fn scratch_rejects(segment: &str, _verb: &str, state: &ValidationState) -> bool {
!scratch_paths_under_temp(segment, state)
}
fn temp_rejects(segment: &str, verb: &str, state: &ValidationState) -> bool {
!temp_mutator_paths_under_temp(segment, verb, state)
}
const READONLY_ALTERNATIVES: &str = "use read-only alternatives to inspect files, e.g. `cat`, `head`, `tail`, `ls`, `file`, `stat`.";
const MUTATOR_CHECKS: &[MutatorCheck] = &[
MutatorCheck {
verbs: SCRATCH_MUTATORS,
rejects: Some(scratch_rejects),
rejection: "`{verb}` is not allowed outside temp directories — it modifies the workspace.",
suggestions: (
"use a literal path under /tmp, or bind the directory first with `NAME=$(mktemp -d)` and reference `$NAME`.",
READONLY_ALTERNATIVES,
),
},
MutatorCheck {
verbs: TEMP_MUTATORS,
rejects: Some(temp_rejects),
rejection: "`{verb}` is not allowed outside temp directories — it modifies files outside /tmp.",
suggestions: (
"use a literal path under /tmp, or bind the directory first with `NAME=$(mktemp -d)` and reference `$NAME`.",
"use paths under /tmp, /var/tmp, or the OS temp directory, or use read-only alternatives like `cat`, `head`, `tail`, `ls`, `file`, `stat`.",
),
},
MutatorCheck {
verbs: MUTATING_COMMANDS,
rejects: None,
rejection: "`{verb}` is not allowed — it modifies the workspace.",
suggestions: (READONLY_ALTERNATIVES, READONLY_ALTERNATIVES),
},
];
struct FlagCheck {
verb: &'static str,
predicate: fn(&str, &ValidationState) -> bool,
rejection: &'static str,
suggestion: &'static str,
}
const FLAG_CHECKS: &[FlagCheck] = &[
FlagCheck {
verb: "sed",
predicate: has_sed_mutation,
rejection: "`sed` in-place editing (`-i`/`-I`/`--in-place`) is not allowed outside temp directories — it modifies files in-place.",
suggestion: "use `sed` without in-place flags to output to stdout, e.g. `sed 's/a/b/' file`, or use `-i` with a path under /tmp.",
},
FlagCheck {
verb: "awk",
predicate: has_inplace,
rejection: "`awk -i inplace` is not allowed — it edits files in-place.",
suggestion: "use `awk` without `-i inplace` to output to stdout, e.g. `awk '{print $1}' file`, or use `-i inplace` with a path under /tmp.",
},
FlagCheck {
verb: "dd",
predicate: has_dd_mutation,
rejection: "`dd of=...` is not allowed outside temp directories — it writes a file.",
suggestion: "use `dd of=/tmp/...` to write under the OS temp directory, or use read-only alternatives like `cat`, `head`, `tail`, `ls`, `file`, `stat`.",
},
FlagCheck {
verb: "curl",
predicate: has_curl_mutation,
rejection: "`curl` with output flags is not allowed outside temp directories.",
suggestion: "use `curl` without output flags to display content in stdout, or use `curl -o /tmp/...` to save to temp.",
},
FlagCheck {
verb: "tar",
predicate: is_tar_mutating,
rejection: "`tar` is only allowed in list mode (`-t`/`--list`) — extraction/creation modifies files.",
suggestion: "use `tar -tf archive.tar.gz` to list contents, or `tar -xzf archive.tar.gz -C /tmp` to extract to temp.",
},
FlagCheck {
verb: "base64",
predicate: has_base64_mutation,
rejection: "`base64` with output flags is not allowed outside temp directories.",
suggestion: "use `base64` without output flags to print to stdout, or use `base64 -o /tmp/...` to save to temp.",
},
FlagCheck {
verb: "wget",
predicate: has_wget_mutation,
rejection: "`wget` with output flags is not allowed outside temp directories.",
suggestion: "use `curl` without output flags to display content in stdout, or use `wget -O /tmp/...` to save to temp.",
},
];
fn non_flag_path_args(segment: &str) -> Vec<String> {
let words = scan::split_words_keeping_substitutions(segment);
let Some(cmd_idx) = super::find_first_command_word_index(&words) else {
return vec![];
};
let mut args = Vec::new();
let mut skip_redirect_target = false;
for w in &words[cmd_idx + 1..] {
if skip_redirect_target {
skip_redirect_target = false;
continue;
}
if w.starts_with('-') {
continue;
}
if let scan::TokenKind::Redirect { needs_target } = scan::classify_shell_token(w) {
skip_redirect_target = needs_target;
continue;
}
args.push(w.to_string());
}
args
}
fn has_unresolved_var_path(segment: &str, state: &ValidationState) -> bool {
non_flag_path_args(segment)
.iter()
.any(|p| p.contains('$') && resolve_path_word(p, state).is_none())
}
fn scratch_paths_under_temp(segment: &str, state: &ValidationState) -> bool {
let paths = non_flag_path_args(segment);
!paths.is_empty() && paths.iter().all(|p| !writes_outside_temp(p, state))
}
fn temp_mutator_paths_under_temp(segment: &str, first_word: &str, state: &ValidationState) -> bool {
if first_word == "cp" {
return cp_destination_under_temp(segment, state);
}
scratch_paths_under_temp(segment, state)
}
fn cp_destination_under_temp(segment: &str, state: &ValidationState) -> bool {
let words = scan::split_words_keeping_substitutions(segment);
let Some(cmd_idx) = super::find_first_command_word_index(&words) else {
return false;
};
let rest = &words[cmd_idx + 1..];
if let Some(val) = output_flag_value(
rest,
&["-t", "--target-directory"],
Some("--target-directory="),
) {
return !writes_outside_temp(val, state);
}
let Some(dest) = rest.iter().rfind(|w| {
!w.starts_with('-')
&& !matches!(
scan::classify_shell_token(w),
scan::TokenKind::Redirect { .. }
)
}) else {
return false;
};
!writes_outside_temp(dest, state)
}
fn word_has_substitution(w: &str) -> bool {
let mut found = false;
scan::for_each_substitution(w, |_, _, _, _| {
found = true;
false });
found
}
fn shell_word(word: &str) -> String {
let word = word
.strip_prefix('$')
.filter(|rest| rest.starts_with(['\'', '"']))
.unwrap_or(word);
word.replace(['\'', '"', '\\'], "")
}
fn is_unprovable_flag_token(part: &str) -> bool {
(part.starts_with("$'") || part.starts_with("$\"")) && part.contains('\\')
}
fn substitution_could_form_flag(w: &str, p: &str) -> bool {
debug_assert_eq!(p, shell_word(w));
(word_has_substitution(w) && (p.starts_with('-') || p.starts_with(['$', '`'])))
|| unquoted_span_could_field_split_flag(w)
}
fn unquoted_span_could_field_split_flag(w: &str) -> bool {
let mut splits = false;
scan::for_each_substitution(w, |span, content, _, in_double| {
if !in_double
&& !span.starts_with("$((") && !span.starts_with("<(") && !span.starts_with(">(")
&& !matches!(
simple_echo_output(content),
Some(out) if !out.contains(char::is_whitespace)
)
{
splits = true;
return false;
}
true
});
splits
}
enum WordPart {
Lit(String),
Any,
}
fn word_parts(w: &str) -> Vec<WordPart> {
let mut parts: Vec<WordPart> = Vec::new();
let mut pos = 0;
scan::for_each_substitution(w, |span, content, end, _| {
let start = end - span.len();
if start > pos {
parts.push(WordPart::Lit(w[pos..start].to_string()));
}
parts.push(match simple_echo_output(content) {
Some(out) => WordPart::Lit(out),
None => WordPart::Any,
});
pos = end;
true
});
if pos < w.len() {
parts.push(WordPart::Lit(w[pos..].to_string()));
}
parts
}
fn simple_echo_output(body: &str) -> Option<String> {
let body = body.trim();
let (cmd, args) = body.split_once(char::is_whitespace)?;
if cmd != "echo" {
return None;
}
let toks: Vec<&str> = args.split_whitespace().collect();
if toks.is_empty()
|| toks.iter().any(|t| {
t.starts_with('-')
|| !t.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '_' | '.' | '/' | '+' | ':' | '@' | '%' | '=' | ',' | '-')
})
})
{
return None;
}
Some(toks.join(" "))
}
fn word_could_form_token(w: &str, token: &str, benign_prefix: &str) -> bool {
if !word_has_substitution(w) {
return is_unprovable_flag_token(w);
}
let parts = word_parts(&shell_word(w));
if parts.iter().any(|p| matches!(p, WordPart::Any)) {
return true;
}
let mut full = String::new();
for p in parts {
if let WordPart::Lit(s) = p {
full.push_str(&s);
}
}
full.split_whitespace()
.filter(|f| benign_prefix.is_empty() || !f.starts_with(benign_prefix))
.any(|f| f.starts_with(token))
}
fn contains_bare_var(w: &str) -> bool {
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
let mut it = w.chars().peekable();
while let Some(c) = it.next() {
let was_escaped = escaped;
super::track_char_context(c, &mut in_single, &mut in_double, &mut escaped);
if c == '$'
&& !in_single
&& !was_escaped
&& it.peek().is_some_and(|n| {
n.is_ascii_alphanumeric()
|| matches!(n, '_' | '@' | '#' | '?' | '$' | '!' | '-' | '*')
})
{
return true;
}
}
false
}
fn unprovable_flag_word(w: &str, p: &str) -> bool {
debug_assert_eq!(p, shell_word(w));
substitution_could_form_flag(w, p) || (contains_bare_var(w) && p.starts_with(['$', '`']))
}
fn word_could_form_token_or_bare_var(w: &str, token: &str) -> bool {
word_could_form_token(w, token, "") || contains_bare_var(w)
}
fn word_has_unprovable_expansion(w: &str) -> bool {
word_has_substitution(w) || contains_bare_var(w)
}
fn has_cluster_char(command: &str, mutation: &[char], value_taking: &[char]) -> bool {
scan::split_words_keeping_substitutions(command)
.into_iter()
.any(|w| {
let p = shell_word(w);
if unprovable_flag_word(w, &p) {
return true; }
if !p.starts_with('-') || p.starts_with("--") {
return false;
}
let b = p.as_bytes();
let mut k = 1;
while k < b.len() {
let c = b[k] as char;
if mutation.contains(&c) {
return true;
}
if value_taking.contains(&c) {
return false; }
k += 1;
}
false
})
}
fn has_sed_mutation(command: &str, state: &ValidationState) -> bool {
let parts: Vec<&str> = scan::split_words_keeping_substitutions(command);
let i_pos = parts.iter().position(|part| {
let p = shell_word(part);
(p.starts_with('-') && !p.starts_with("--") && p.contains(['i', 'I']))
|| p.starts_with("--i")
|| is_unprovable_flag_token(part)
|| unprovable_flag_word(part, &p)
});
let Some(i_pos) = i_pos else {
return false; };
let mut file_operands: Vec<&str> = Vec::new();
let mut seen_file_operand = false;
for part in &parts[i_pos + 1..] {
if part.starts_with('-') {
continue; }
if seen_file_operand {
file_operands.push(part);
continue;
}
if Path::new(&shell_word(part)).is_absolute() || is_unprovable_flag_token(part) {
seen_file_operand = true;
file_operands.push(part);
}
}
if file_operands.is_empty() {
return true; }
file_operands.iter().any(|p| writes_outside_temp(p, state))
}
fn has_inplace(command: &str, _state: &ValidationState) -> bool {
let parts: Vec<&str> = scan::split_words_keeping_substitutions(command);
parts.windows(2).any(|w| {
let p0 = shell_word(w[0]);
let p1 = shell_word(w[1]);
let bare_operand = contains_bare_var(w[1]) && {
let prefix = &p1[..p1.find('$').unwrap_or(p1.len())];
prefix.is_empty() || "inplace".starts_with(prefix)
};
(p0 == "-i" || word_could_form_token_or_bare_var(w[0], "-i"))
&& (p1 == "inplace" || word_could_form_token(w[1], "inplace", "") || bare_operand)
})
}
fn dd_of_value(w: &str) -> Option<String> {
let p = shell_word(w);
let mut out = String::new();
for part in word_parts(&p) {
match part {
WordPart::Lit(s) => out.push_str(&s),
WordPart::Any => return None,
}
}
out.strip_prefix("of=").map(str::to_string)
}
fn has_dd_mutation(command: &str, state: &ValidationState) -> bool {
let parts: Vec<&str> = scan::split_words_keeping_substitutions(command);
for w in &parts {
if let Some(val) = dd_of_value(w) {
if writes_outside_temp(&val, state) {
return true;
}
} else if word_could_form_token_or_bare_var(w, "of=") {
return true;
}
}
false
}
const CURL_VALUE_TAKING: &[char] = &[
'A', 'b', 'c', 'C', 'd', 'D', 'e', 'E', 'F', 'H', 'h', 'K', 'm', 'P', 'Q', 'r', 't', 'T', 'u',
'U', 'w', 'x', 'X', 'y', 'Y', 'z',
];
struct OutputFlagSpec {
always_blocked_short: &'static [char],
output_short: char,
value_taking: &'static [char],
always_blocked_long: &'static [&'static str],
}
const CURL_OUTPUT_FLAGS: OutputFlagSpec = OutputFlagSpec {
always_blocked_short: &['O'],
output_short: 'o',
value_taking: CURL_VALUE_TAKING,
always_blocked_long: &["--remote-name", "--remote-name-all"],
};
const BASE64_OUTPUT_FLAGS: OutputFlagSpec = OutputFlagSpec {
always_blocked_short: &[],
output_short: 'o',
value_taking: &['i', 'b'],
always_blocked_long: &[],
};
fn has_output_mutation(command: &str, state: &ValidationState, spec: &OutputFlagSpec) -> bool {
let parts: Vec<&str> = scan::split_words_keeping_substitutions(command);
if parts
.iter()
.any(|w| substitution_could_form_flag(w, &shell_word(w)))
{
return true;
}
for (i, part) in parts.iter().enumerate() {
let w = shell_word(part);
if spec.always_blocked_long.contains(&w.as_str()) {
return true;
}
if let Some(path) = w.strip_prefix("--output=") {
if writes_outside_temp(path, state) {
return true;
}
} else if w == "--output"
&& let Some(next) = parts.get(i + 1)
&& writes_outside_temp(next, state)
{
return true;
}
}
if parts.iter().any(|p| is_unprovable_flag_token(p)) {
return true;
}
for (i, part) in parts.iter().enumerate() {
let p = shell_word(part);
if !p.starts_with('-') || p.starts_with("--") || p.len() < 2 {
continue;
}
let b = p.as_bytes();
let mut k = 1;
while k < b.len() {
let c = b[k] as char;
if spec.always_blocked_short.contains(&c) {
return true;
}
if c == spec.output_short {
let path = if k + 1 < b.len() {
&p[k + 1..]
} else {
parts.get(i + 1).copied().unwrap_or("")
};
if !path.is_empty() && writes_outside_temp(path, state) {
return true;
}
break; }
if spec.value_taking.contains(&c) {
break; }
k += 1;
}
}
false
}
fn has_curl_mutation(command: &str, state: &ValidationState) -> bool {
has_output_mutation(command, state, &CURL_OUTPUT_FLAGS)
}
fn has_wget_mutation(command: &str, state: &ValidationState) -> bool {
let parts: Vec<&str> = scan::split_words_keeping_substitutions(command);
if parts
.iter()
.any(|w| substitution_could_form_flag(w, &shell_word(w)))
{
return true;
}
if let Some(path) = output_flag_value(
&parts,
&["-O", "--output-document"],
Some("--output-document="),
) {
return writes_outside_temp(path, state);
}
if let Some(path) = output_flag_value(
&parts,
&["-P", "--directory-prefix"],
Some("--directory-prefix="),
) {
return writes_outside_temp(path, state);
}
true
}
const TAR_SAFE_CHARS: &[char] = &['v', 'f', 'z', 'j', 'J'];
fn is_tar_list_only(command: &str) -> bool {
let parts: Vec<&str> = scan::split_words_keeping_substitutions(command);
for part in &parts {
if *part == "--list" {
return true;
}
if part.starts_with('-') && !part.starts_with("--") {
if part.len() == 2 && TAR_SAFE_CHARS.contains(&part.chars().nth(1).unwrap()) {
continue;
}
let ops: String = part
.chars()
.skip(1) .filter(|c| !TAR_SAFE_CHARS.contains(c))
.collect();
if !ops.is_empty() {
return ops == "t";
}
}
}
false
}
fn is_tar_mutating(command: &str, _state: &ValidationState) -> bool {
!is_tar_list_only(command)
}
fn has_base64_mutation(command: &str, state: &ValidationState) -> bool {
has_output_mutation(command, state, &BASE64_OUTPUT_FLAGS)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RefLongKind {
List,
Value,
}
const GIT_REF_LONG_OPTS: &[(&str, RefLongKind)] = &[
("--merged", RefLongKind::List),
("--no-merged", RefLongKind::List),
("--contains", RefLongKind::List),
("--no-contains", RefLongKind::List),
("--points-at", RefLongKind::List),
("--list", RefLongKind::List),
("--format", RefLongKind::Value),
("--verify", RefLongKind::List),
("--sort", RefLongKind::Value),
];
fn resolve_ref_long(kind: &str, sub: &str) -> Option<RefLongKind> {
let base = kind.split('=').next().unwrap_or(kind);
if base.len() < 3 {
return None; }
let mut matches = GIT_REF_LONG_OPTS
.iter()
.filter(|(name, _)| name.starts_with(base) && !(sub == "branch" && *name == "--verify"));
let first = matches.next()?;
if matches.next().is_some() {
return None; }
Some(first.1)
}
fn git_mutation_rejection(subcommand: &str) -> String {
format!(
"⚠️ Read-only mode: `git {subcommand}` is not allowed — it mutates.\n\
Suggestion: inspect state with read-only git commands instead \
(e.g. `git status`, `git log`, `git diff`, `git show`)."
)
}
fn check_git_ref_subcommand(subcommand: &str, sub: &str) -> Result<(), String> {
let words = scan::split_words_keeping_substitutions(subcommand);
let mut list = false;
let mut verify = false;
let mut saw_name = false;
let mut consume_next = false;
for raw in words.iter().skip(1) {
let w = shell_word(raw);
if unprovable_flag_word(raw, &w) {
return Err(git_mutation_rejection(subcommand));
}
if w.starts_with('-') && w.len() > 1 {
let is_name_less_mutation = if w.starts_with("--") {
[
"--set-upstream-to",
"--unset-upstream",
"--edit-description",
]
.iter()
.any(|m| w == *m || w.starts_with(&format!("{m}=")))
} else {
w[1..].contains('u') };
if is_name_less_mutation {
return Err(git_mutation_rejection(subcommand));
}
}
if consume_next {
consume_next = false;
continue; }
if w == "--" || w == "--end-of-options" {
saw_name = true; continue;
}
if let Some(kind) = resolve_ref_long(&w, sub) {
match kind {
RefLongKind::List => list = true,
RefLongKind::Value => {
if !w.contains('=') {
consume_next = true;
}
}
}
} else if w.starts_with('-') && !w.starts_with("--") && w.len() > 1 {
let b = w.as_bytes();
let mut k = 1;
while k < b.len() {
let c = b[k] as char;
let list_shorts = if sub == "tag" {
GIT_TAG_SHORT_LIST
} else {
GIT_REF_SHORT_LIST
};
if list_shorts.contains(&c) {
list = true;
} else if sub == "tag" && c == 'v' {
verify = true;
} else {
break;
}
k += 1;
}
} else if !w.starts_with('-') {
saw_name = true;
}
}
if verify || list || !saw_name {
return Ok(());
}
Err(format!(
"⚠️ Read-only mode: `git {subcommand}` is not allowed — it names a {sub} ref, which creates or modifies it.\n\
Suggestion: use `git {sub} --list` or `git {sub} --merged` to list existing {sub}es read-only."
))
}
fn check_git_subcommand_mutation(subcommand: &str, mutation_tokens: &[&str]) -> Result<(), String> {
let words = scan::split_words_keeping_substitutions(subcommand);
let (flag_tokens, bare_tokens): (Vec<&str>, Vec<&str>) = mutation_tokens
.iter()
.copied()
.partition(|t| t.starts_with('-'));
let short_chars: Vec<char> = flag_tokens
.iter()
.filter_map(|t| {
let b = t.as_bytes();
(b.len() == 2 && b[0] == b'-').then(|| b[1] as char)
})
.collect();
for arg in words.iter().skip(1) {
let a = shell_word(arg);
if !a.starts_with('-') {
continue;
}
let is_mutating = if a.starts_with("--") {
matches_mutation_token(&a, &flag_tokens)
} else {
short_chars.iter().any(|c| a[1..].contains(*c))
};
if is_mutating {
return Err(git_mutation_rejection(subcommand));
}
}
if !bare_tokens.is_empty()
&& let Some(first_non_flag_arg) = words
.iter()
.skip(1)
.find(|w| !shell_word(w).starts_with('-'))
{
let bare = shell_word(first_non_flag_arg);
let is_mutating = word_has_substitution(first_non_flag_arg)
|| matches_mutation_token(&bare, &bare_tokens);
if is_mutating {
return Err(git_mutation_rejection(subcommand));
}
}
Ok(())
}
fn matches_mutation_token(word: &str, tokens: &[&str]) -> bool {
tokens.contains(&word)
|| tokens
.iter()
.any(|t| word.strip_prefix(t).is_some_and(|r| r.starts_with('=')))
}
fn extract_git_subcommand(segment: &str) -> String {
let words = scan::split_words_keeping_substitutions(segment);
let Some(git_idx) = super::find_first_command_word_index(&words) else {
return String::new();
};
let git_word = words[git_idx]
.rsplit('/')
.next()
.expect("rsplit always yields at least one element");
let git_word = scan::strip_quoted_word(git_word);
if git_word != "git" {
return String::new();
}
let remaining = &words[git_idx + 1..];
if let Some(sub_start) = super::find_first_non_flag_index(remaining, true) {
remaining[sub_start..].join(" ")
} else {
String::new()
}
}
const GIT_EXEC_LONG_FLAGS: &[&str] = &[
"--ext-diff",
"--textconv",
"--show-signature",
"--filters",
"--upload-pack",
"--exec",
"--receive-pack",
];
const GIT_TEXT_BENIGN_SUBCOMMANDS: &[&str] = &[
"grep",
"diff",
"log",
"show",
"blame",
"annotate",
"diff-files",
"diff-index",
"diff-tree",
"whatchanged",
"shortlog",
"rev-list",
"range-diff",
"stash",
];
fn check_git_exec_flags(trimmed: &str, base: &str, words: &[&str]) -> Result<(), String> {
for w in words {
if w.contains("$'") && w.contains('\\') {
return reject(
trimmed,
"ANSI-C quoted git arguments with backslash escapes cannot be proven safe.",
"write git flags and arguments literally.",
);
}
let wq = shell_word(w);
let opt = wq.split('=').next().unwrap_or(&wq);
if opt.len() <= 2 {
continue;
}
let text_benign = opt == "--text" && GIT_TEXT_BENIGN_SUBCOMMANDS.contains(&base);
if opt != "--filter"
&& !text_benign
&& GIT_EXEC_LONG_FLAGS.iter().any(|f| f.starts_with(opt))
{
return reject(
trimmed,
&format!(
"`{opt}` is not allowed in read-only mode — it selects a git feature that executes external programs."
),
"drop the flag; the corresponding git feature stays disabled without it.",
);
}
}
Ok(())
}
fn check_git_scoped_flags(trimmed: &str, base: &str, words: &[&str]) -> Result<(), String> {
for w in words {
let wq = shell_word(w);
let opt = wq.split('=').next().unwrap_or(&wq);
let is_scoped = match base {
"grep" => {
(opt.len() > 2 && "--open-files-in-pager".starts_with(opt))
|| (wq.starts_with('-') && !wq.starts_with("--") && wq[1..].contains('O'))
}
"help" => {
(opt.len() > 2
&& ["--web", "--man", "--info"]
.iter()
.any(|f| f.starts_with(opt)))
|| (wq.starts_with('-')
&& !wq.starts_with("--")
&& wq[1..].contains(['w', 'm', 'i']))
}
_ => false,
};
if is_scoped {
return reject(
trimmed,
&format!(
"`{wq}` on `git {base}` is not allowed in read-only mode — it runs an external program."
),
"drop the flag.",
);
}
}
Ok(())
}
fn check_git_output_flag(trimmed: &str, subcommand: &str) -> Result<(), String> {
if subcommand.starts_with("config") {
return Ok(());
}
let words = scan::split_words_keeping_substitutions(subcommand);
let is_remote = words.first().is_some_and(|w| shell_word(w) == "remote");
if words.iter().any(|w| {
let p = shell_word(w);
p == "--output"
|| p.starts_with("--output=")
|| word_could_form_token(w, "--output", "--output-indicator")
|| (!is_remote && contains_bare_var(w))
}) {
return reject(
trimmed,
"`--output` is not allowed in read-only mode — it writes the git output to a file.",
"drop the flag; use a shell redirect like `git diff > /tmp/out` to save output to the OS temp directory.",
);
}
Ok(())
}
fn check_git_exec_vectors(trimmed: &str) -> Result<(), String> {
let words = scan::split_words_keeping_substitutions(trimmed);
let Some(git_idx) = super::find_first_command_word_index(&words) else {
return Ok(());
};
for w in &words[..git_idx] {
if super::is_env_assignment(w) {
check_git_env_binding(w)?;
}
}
let sub_idx = super::find_first_non_flag_index(&words[git_idx + 1..], true)
.map_or(words.len(), |i| git_idx + 1 + i);
let base = words.get(sub_idx).copied().unwrap_or("");
for w in &words[git_idx + 1..sub_idx] {
let wq = shell_word(w);
if wq.starts_with('-') && !wq.starts_with("--") && wq[1..].contains(['c', 'C']) {
return reject(
trimmed,
"`-c`/`-C` git global options are not allowed in read-only mode — they inject config or redirect the repository, both of which can execute programs.",
"run git without global `-c`/`-C` options (use `cd` to change directory).",
);
}
for opt in [
"--git-dir",
"--work-tree",
"--config-env",
"--config-file",
"--exec-path",
] {
if wq == opt || wq.starts_with(&format!("{opt}=")) {
return reject(
trimmed,
&format!(
"`{opt}` is not allowed in read-only mode — it redirects the repository or injects config, enabling program execution."
),
"run git against the workspace repository without repo-redirect or config-injection options.",
);
}
}
}
check_git_exec_flags(trimmed, base, &words)?;
check_git_scoped_flags(trimmed, base, &words)
}
fn check_git_read_only_extensions(trimmed: &str, subcommand: &str) -> Option<Result<(), String>> {
if subcommand.starts_with("stash") {
let words = scan::split_words_keeping_substitutions(subcommand);
let stash_cmd = shell_word(words.get(1).copied().unwrap_or(""));
if matches!(stash_cmd.as_str(), "show" | "list") {
return Some(Ok(()));
}
return Some(reject(
trimmed,
"`git stash` is not allowed — it modifies the working tree.",
"use `git stash list` to view stashes, or `git diff` to preview changes.",
));
}
if subcommand.starts_with("config") {
let words = scan::split_words_keeping_substitutions(subcommand);
let rest = &words[1..];
if rest.iter().any(|w| {
let w = shell_word(w);
matches!(
w.as_str(),
"--list" | "-l" | "--get" | "--get-all" | "--get-regexp" | "--name-only"
)
}) {
return Some(Ok(()));
}
if rest.iter().any(|w| word_has_substitution(w)) {
return Some(reject(
trimmed,
"`git config` with a substitution cannot be proven read-only.",
"write git config arguments literally.",
));
}
if rest.iter().any(|w| {
let w = shell_word(w);
matches!(
w.as_str(),
"--add"
| "--unset"
| "--unset-all"
| "--edit"
| "--remove-section"
| "--rename-section"
| "--replace-all"
)
}) || has_cluster_char(&rest.join(" "), &['e'], &['f'])
{
return Some(reject(
trimmed,
"`git config` write/edit forms are not allowed — they modify repository or global config.",
"use `git config user.name` (key read), `git config --list`, or `git config --get <key>` to inspect configuration.",
));
}
match rest.iter().filter(|w| !w.starts_with('-')).count() {
0 | 1 => Some(Ok(())),
_ => Some(reject(
trimmed,
"`git config` with a value is not allowed — it writes configuration.",
"use `git config user.name` (key read) or `git config --list` to inspect configuration.",
)),
}
} else if subcommand.starts_with("rebase") {
let words = scan::split_words_keeping_substitutions(subcommand);
if words.len() >= 2 && shell_word(words[1]) == "--show-current" {
return Some(Ok(()));
}
Some(reject(
trimmed,
"`git rebase` is not allowed — it rewrites branch history.",
"use `git rebase --show-current` to see the in-progress rebase, or `git log` to inspect history.",
))
} else if subcommand.starts_with("submodule") {
let words = scan::split_words_keeping_substitutions(subcommand);
match words.get(1) {
None => Some(Ok(())),
Some(sub) if shell_word(sub) == "status" => Some(Ok(())),
Some(sub) => Some(reject(
trimmed,
&format!("`git submodule {sub}` is not allowed — it modifies submodules."),
"use `git submodule status` to inspect submodule state.",
)),
}
} else {
None
}
}
fn check_git_segment(segment: &str) -> Result<(), String> {
let trimmed = segment.trim();
let subcommand = extract_git_subcommand(trimmed);
check_git_exec_vectors(trimmed)?;
if subcommand.is_empty() || subcommand == "git" {
return Ok(());
}
check_git_output_flag(trimmed, &subcommand)?;
if let Some(result) = check_git_read_only_extensions(trimmed, &subcommand) {
return result;
}
for &(prefix, why, suggestion) in GIT_ALWAYS_MUTATE {
if subcommand.starts_with(prefix) {
return reject(trimmed, why, suggestion);
}
}
let matched_safe = GIT_SAFE_SUBCOMMANDS
.iter()
.copied()
.find(|safe| subcommand == *safe || subcommand.starts_with(&format!("{safe} ")));
if matched_safe.is_none() {
return Err(format!(
"⚠️ Read-only mode: the `git {subcommand}` subcommand is not allowed — it may mutate the repository.\n\
Command: `{trimmed}`\n\
Allowed git subcommands for read-only mode: status, log, diff, show, blame, branch, tag, remote,\n\
stash list/show, show-ref, ls-remote, submodule status, config reads, rebase --show-current,\n\
and other inspection-only commands. Suggestion: use these for repository exploration."
));
}
match matched_safe {
Some("branch") => check_git_ref_subcommand(&subcommand, "branch")?,
Some("tag") => check_git_ref_subcommand(&subcommand, "tag")?,
Some("remote") => {
check_git_subcommand_mutation(&subcommand, GIT_REMOTE_MUTATIONS)?;
}
Some("hash-object") => {
if has_cluster_char(trimmed, &['w'], &['t']) {
return reject(
trimmed,
"`git hash-object` with `-w` is not allowed — it writes objects to the object database.",
"use `git hash-object` without `-w` to compute the hash without storing the object.",
);
}
}
Some("reflog") => {
let words = scan::split_words_keeping_substitutions(&subcommand);
if let Some(raw) = words.get(1) {
let reflog_sub = shell_word(raw);
if word_has_unprovable_expansion(raw)
|| reflog_sub == "expire"
|| reflog_sub == "delete"
{
return reject(
trimmed,
&format!("`git reflog {raw}` is not allowed — it modifies the reflog."),
"use `git reflog show` or bare `git reflog` to view reflog entries.",
);
}
}
}
Some("fsck")
if scan::split_words_keeping_substitutions(&subcommand)
.iter()
.any(|raw| {
shell_word(raw).starts_with("--l") || word_has_unprovable_expansion(raw)
}) =>
{
return reject(
trimmed,
"`git fsck --lost-found` is not allowed — it writes dangling objects to `.git/lost-found/`.",
"drop the flag; dangling objects are still listed on stdout without `--lost-found`.",
);
}
_ => {}
}
Ok(())
}
fn flag_value<'a>(parts: &'a [&'a str], flag: &str) -> Option<&'a str> {
parts.windows(2).find_map(|w| {
if w[0] == flag {
let val = w[1];
if val.starts_with('-') && val != "-" {
None
} else {
Some(val)
}
} else {
None
}
})
}
fn flag_value_equals<'a>(parts: &'a [&'a str], prefix: &str) -> Option<&'a str> {
parts.iter().find_map(|p| p.strip_prefix(prefix))
}
fn output_flag_value<'a>(
parts: &'a [&'a str],
flags: &[&str],
equals_prefix: Option<&str>,
) -> Option<&'a str> {
flags
.iter()
.find_map(|f| flag_value(parts, f))
.or_else(|| equals_prefix.and_then(|p| flag_value_equals(parts, p)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::shell::{NON_DELEGATING_PREFIXES, SHELL_PREFIXES};
fn test_ctx() -> CheckContext {
CheckContext {
workspace_root: std::path::PathBuf::from("/__mahbot_readonly_test_ws__"),
temp_roots: crate::tools::path::allowed_temp_roots(),
temp_vars: vec![("TMPDIR".to_string(), crate::tools::shell::shell_tmpdir())],
}
}
fn ok(cmd: &str) {
let ctx = test_ctx();
assert!(
check_command(cmd, &ctx).is_ok(),
"expected ALLOW but got REJECT for: `{cmd}`"
);
}
fn assert_rejected(cmd: &str) {
let ctx = test_ctx();
assert!(
check_command(cmd, &ctx).is_err(),
"expected REJECT but got ALLOW for: `{cmd}`"
);
}
fn run_cases(cases: &[(&str, bool)]) {
for &(command, allowed) in cases {
if allowed {
ok(command);
} else {
assert_rejected(command);
}
}
}
fn assert_all_rejected(items: &[&str], template: impl Fn(&str) -> String) {
for &item in items {
assert_rejected(&template(item));
}
}
fn assert_all_allowed(items: &[&str], template: impl Fn(&str) -> String) {
for &item in items {
ok(&template(item));
}
}
#[test]
fn empty_whitespace_and_unknown() {
let cases = [
("", true),
(" ", true),
("some_obscure_tool --flag", true),
];
run_cases(&cases);
}
#[test]
fn all_git_safe_subcommands_allowed() {
assert_all_allowed(GIT_SAFE_SUBCOMMANDS, |subcmd| format!("git {subcmd}"));
}
#[test]
fn git_individual_commands() {
let cases = [
("git commit -m test", false),
("git push", false),
("git stash", false),
("git stash list", true),
("git merge feature", false),
("git rebase main", false),
("git branch my-feature", false),
("git branch --force my-feature", false),
("git branch -f my-feature", false),
("git tag v1.0", false),
("git tag -f v1.0", false),
("git tag --force v1.0", false),
("git branch -u origin/main", false),
("git branch --set-upstream-to=origin/main", false),
("git branch --track feature", false),
("git branch --no-track feature", false),
("git branch -t", true), ("git branch --unset-upstream", false),
("git tag -m msg v1.0", false),
("git tag --message msg v1.0", false),
("git tag -F file v1.0", false),
("git tag --file file v1.0", false),
("git tag -e v1.0", false),
("git tag --edit v1.0", false),
("git remote", true),
("git remote -v", true),
("git remote show origin", true),
("git remote get-url origin", true),
("git mktag <tag_object", false),
("git mktag", false),
("git mktree <tree_contents", false),
("git mktree", false),
("git merge-file a.txt b.txt c.txt", false),
("git merge-file -p a.txt b.txt c.txt", false),
("git merge-tree base branch1 branch2", false),
("git merge-tree --write-tree base branch1 branch2", false),
("git hash-object file.txt", true),
("git hash-object --stdin", true),
("git hash-object -w file.txt", false),
("git hash-object -w --stdin", false),
("git hash-object file.txt -w", false),
("git reflog", true),
("git reflog show", true),
("git reflog list", true),
("git reflog show HEAD", true),
("git reflog expire --all", false),
("git reflog delete HEAD@{0}", false),
("git fsck", true),
("git fsck --strict --full", true),
("git fsck --no-lost-found", true),
("git fsck --lost-found", false),
("git fsck \"--lost-found\"", false),
("git fsck --lost", false),
("git fsck --l", false),
("git diff --output=/tmp/x", false),
("git show --output /tmp/x", false),
("git log -p --output=/tmp/x", false),
("git blame --output=src/lib.rs", false),
("git diff-tree --output=/tmp/x", false),
("git diff --output -1", false),
];
run_cases(&cases);
}
#[test]
fn git_bare_flag() {
let cases = [
("git --bare status", true),
("git --bare log --oneline", true),
("git --bare diff", true),
("git --bare push", false),
("git --bare commit -m test", false),
("git --bare reset --hard", false),
];
run_cases(&cases);
}
#[test]
fn git_combined_short_flag_clusters() {
let cases = [
("git branch -df feature", false),
("git branch -fd feature", false),
("git branch -Dd feature", false),
("git branch '-df' feature", false),
("git branch -v '-df' feature", false), ("git branch -mv old new", false),
("git branch -cv old new", false),
("git branch -fv main origin/main", false),
("git branch -uorigin/main", false),
("git tag -am 'msg' v1.0", false),
("git tag -afm 'msg' v1.0", false),
("git tag -fam 'msg' v1.0", false),
("git tag -ma v1.0", false),
("git tag -mprobe-msg v1.0", false),
("git tag -F/file v1.0", false),
("git tag '-am' 'msg' v1.0", false),
("git tag -n '-am' 'msg' v1.0", true), ("git hash-object -wt blob a.txt", false),
("git hash-object -wtblob a.txt", false),
("git hash-object '-wt' blob a.txt", false),
("git hash-object -w -t blob a.txt", false),
("git config -e", false),
("git config '-e'", false),
("git config -f /tmp/cfg -e", false),
("git config --'edit'", false),
("git config --'unset' key", false),
("git config --'list'", true),
("git branch -v", true),
("git branch -vv", true),
("git branch -a", true),
("git branch -r", true),
("git tag -n", true),
("git tag -n1", true),
("git hash-object -t blob a.txt", true),
("git hash-object --stdin", true),
("git config -l", true),
];
run_cases(&cases);
}
#[test]
fn all_mutating_commands_rejected() {
assert_all_rejected(MUTATING_COMMANDS, |cmd| format!("{cmd} /etc/blocked_test"));
}
#[test]
fn git_remote_mutation_verbs_rejected() {
assert_all_rejected(GIT_REMOTE_MUTATIONS, |verb| {
format!("git remote {verb} origin")
});
}
#[test]
fn git_branch_tag_safe_listing() {
let cases = [
("git branch", true),
("git branch --list", true),
("git branch --list feature", true),
("git branch -l", true),
("git branch -l feature", true),
("git branch --merged", true),
("git branch --merged main", true),
("git branch --no-merged", true),
("git branch --no-merged main", true),
("git branch -a", true),
("git branch -r", true),
("git branch -v", true),
("git branch -vv", true),
("git branch --sort=-committerdate", true),
("git branch --contains abc123", true),
("git branch --points-at abc123", true),
("git branch --format='%(refname)'", true),
("git branch --format %(refname)", false), ("git branch --sort committerdate", true),
("git branch --format \"%(refname) %(objectname)\"", true),
("git branch --format=\"%(refname) %(objectname)\"", true),
("git tag --format \"%(refname) %(objectname)\"", true),
("git tag --format=\"%(refname) %(objectname)\"", true),
("git branch --abbrev=10", true),
("git branch --color=always", true),
("git branch --column=dense", true),
("git branch --mer main", true),
("git branch --con HEAD", true),
("git branch --no-contains HEAD", true),
("git branch --no-merged main", true),
("git branch feature --list", true),
("git branch --show-current", true),
("git tag", true),
("git tag --list", true),
("git tag -l", true),
("git tag -l v1*", true),
("git tag --contains abc123", true),
("git tag --merged main", true),
("git tag --points-at abc123", true),
("git tag -n", true),
("git tag -v v1.0", true), ("git tag --verify v1.0", true),
("git tag -n 1", true), ("git tag feature --list", true), ];
run_cases(&cases);
}
#[test]
fn git_branch_tag_creation_bypass_rejected() {
let cases = [
("git branch --abbrev 10", false),
("git branch --color always", false),
("git branch --column dense", false),
("git branch -- foo", false),
("git branch --end-of-options foo", false),
("git tag --end-of-options foo", false),
("git branch --no-list foo", false),
("git branch --no-verbose foo", false),
("git branch --no-delete foo", false),
("git branch --no-move foo", false),
("git branch --no-force foo", false),
("git branch --no-points-at foo", false),
("git tag --no-annotate foo", false),
("git tag --no-force foo", false),
("git branch --forc foo", false),
("git branch --tr foo", false),
("git branch --uns foo", false),
("git branch --mov foo", false),
("git branch --del foo", false),
("git branch --edit-d foo", false),
("git tag --mes foo", false),
("git branch --mes foo", false),
("git branch --bogus foo", false),
("git branch --sort --merged foo", false),
("git branch --sort --list foo", false),
("git branch --format %(refname) foo", false),
("git tag --sort --list foo", false),
(
"git branch --format \"%(refname) %(objectname)\" foo",
false,
),
("git tag --format \"%(refname) %(objectname)\" foo", false),
("git branch --merged -d", true), ("git branch --sort -d foo", false),
("git branch -a foo", false),
("git branch -r foo", false),
("git tag -i foo", false),
("git tag --column foo", false),
("git tag --sort=key foo", false),
];
run_cases(&cases);
}
#[test]
fn git_mutation_in_later_position() {
let cases = [
("git branch --sort=-committerdate -d", true), ("git branch --sort=-committerdate --delete feature", false),
("git tag -l v1.* --delete", true), ("git tag -l v1.* -d", true), ("git remote show add", true),
("git remote -v update", false),
];
run_cases(&cases);
}
#[test]
fn flag_dependent_tests() {
let cases = [
("sed 's/a/b/' file", true),
("sed -i 's/a/b/' file", false),
("sed -i.bak 's/a/b/' file", false),
("awk '{print $1}' file", true),
("awk -i inplace '{print $1}' file", false),
("dd if=/dev/zero bs=1 count=10", true),
("dd if=/dev/zero of=file bs=1 count=10", false),
("curl https://example.com", true),
("curl -o file https://example.com", false),
("curl -O https://example.com/file", false),
("curl -so out URL", false),
("curl -sO URL", false),
("curl -OJ URL", false),
("curl -LO URL", false),
("curl '-so' out URL", false),
("curl -so/tmp/x URL", true),
("curl -do out URL", true), ("curl -HContent-Type:o URL", true), ("curl -ho URL", true), ("curl $'\\x2do' /etc/passwd URL", false), ("curl -O --output /tmp/x URL", false),
("curl -o out --output /tmp/x URL", false),
("curl -o /tmp/x --output out URL", false),
("curl -o /tmp/x --output /tmp/y URL", true),
("curl --output=/tmp/x URL", true),
("curl --output=out URL", false),
("curl --'output' out URL", false), ("curl --remote-name-all URL", false), ("tar -tf archive.tar.gz", true),
("tar -xzf archive.tar.gz", false),
("tar -czf archive.tar.gz dir/", false),
("tar --list -f archive.tar.gz", true),
("base64 -d file.txt", true),
("base64 -d -o out.bin file.txt", false),
("base64 --decode --output out.bin file.txt", false),
("base64 -do out", false),
("base64 '-do' out", false),
("base64 -do/tmp/x", true),
("base64 -o out file", false), ("base64 -dio out", true), ("base64 $'\\x2do' /etc/passwd", false), ("base64 --output /tmp/x -o out file", false),
("base64 -o out --output /tmp/x file", false), ("base64 -o /tmp/x --output /tmp/y file", true),
("base64 --output=/tmp/x file", true),
("base64 --output=out file", false),
];
run_cases(&cases);
}
#[test]
fn chained_commands() {
let cases = [
("cargo check && cargo test", true),
("cargo check && rm file", false),
("git status && cargo fmt", true),
("git log --oneline | head -20", true),
("cargo check; rm file", false),
];
run_cases(&cases);
}
#[test]
fn redirect_tests() {
let cases = [
("echo hello > file.txt", false),
("echo hello > /dev/null", true),
("echo hello > /tmp/output.txt", true),
("cmd 2>&1", true),
("echo \"hello > world\"", true),
("echo hello >> /tmp/log", true),
("echo hello >| /tmp/force", true),
("cargo build > /dev/null 2>&1", true),
("echo hello > /var/tmp/output.txt", true),
("echo hello >> /var/tmp/log", true),
("cmd > output.txt", false),
("cmd 1>&2", true),
("cmd 2> /tmp/errors.log", true),
("cmd 2> errors.log", false),
("cmd >&2", true), ("echo \\> /tmp/file", true),
("echo \\>", true),
("echo \\\\\\> file", true),
("echo \"> /tmp/foo", false), ("echo '> /tmp/foo", false), ];
run_cases(&cases);
}
#[test]
fn parse_error_fail_closed() {
let cases = [
("cat > /tmp/x <<< hi", false),
("cat <> f", false),
("cat <<A <<B\nx\nA\ny\nB", false),
("git branch --format %(refname)", false),
("echo \"unterminated", false),
("cat <<EOF > /tmp/out", false), ("echo $(cmd 2>/dev/null} done", false), ("cat <<", false), ("echo hi ;;", false), ];
run_cases(&cases);
}
#[test]
fn heredoc_marker_line_guard() {
let cases = [
("cat <<EOF | rm f\nbody\nEOF", false),
("cat <<EOF && touch f\nbody\nEOF", false),
("cat <<EOF && echo hi\nbody\nEOF", true),
("cat <<EOF | grep x\nbody\nEOF", true),
("cat <<EOF > /tmp/out\nbody\nEOF", true),
("cat <<EOF > workspace_file\nbody\nEOF", false),
("cat <<EOF| rm f\nbody\nEOF", false),
("cat <<EOF>file\nbody\nEOF", false),
("cat <<EOF && cd /tmp && touch f\nbody\nEOF", true),
];
run_cases(&cases);
}
#[test]
fn declaration_unset_substitutions() {
let cases = [
("unset $(rm -rf /)", false),
("unset `rm -rf /`", false),
("declare $(rm -rf /)", false),
("export x=$(rm -rf /)", false),
("declare \"x=$(rm -rf /)\"", false),
("declare -a arr=( $(rm -rf /) )", false),
("export x=<(rm -rf /)", false),
("X=$(rm -rf /)", false),
("export MYTMP=/tmp x=$(touch $MYTMP/f)", false),
("export TMPDIR=/tmp x=$(touch $TMPDIR/f)", true),
("unset TMPDIR", true),
("export TMPDIR=/tmp", true),
("declare x=1", true),
("export A=1 B=2", true),
];
run_cases(&cases);
}
#[test]
fn cd_owning_heredoc_state() {
let cases = [
("cd /tmp <<EOF\n$(rm -rf .)\nEOF", false),
("cd /tmp <<EOF\n$(touch rel)\nEOF", false),
("cd /tmp <<EOF\n$(mkdir x)\nEOF", false),
("cd /tmp <<EOF\n$(rm -rf x)\nEOF", false),
("cd /tmp <<EOF\n$(echo hi > wsfile)\nEOF", false),
("cd /tmp <<EOF > rel\nbody\nEOF", false),
("cd /tmp <<EOF | rm f\nbody\nEOF", false),
("cd /tmp <<EOF | touch /tmp/f\nbody\nEOF", true),
("TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF", false),
("TMPDIR=/tmp/xyzzy cat <<EOF\n$(touch $TMPDIR/x)\nEOF", true),
(
"cd /tmp && TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
false,
),
("a | TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF", false),
(
"TMPDIR=/tmp/xyzzy cat <<EOF > \"$TMPDIR/f\"\nbody\nEOF",
true,
),
("{ cd /tmp; cat; } <<EOF\n$(rm -rf .)\nEOF", false),
("{ cd /tmp; } <<EOF\n$(rm -rf .)\nEOF", false),
("( cd /tmp; cat; ) <<EOF\n$(rm -rf .)\nEOF", false),
("{ cd /tmp; cat; } <<EOF > rel\nbody\nEOF", false),
("{ cd /tmp; cat; } > f", false),
(
"if true; then cd /tmp; touch f; fi <<EOF\n$(rm -rf .)\nEOF",
false,
),
(
"for x in a; do cd /tmp; touch f; done <<EOF\n$(rm -rf .)\nEOF",
false,
),
("true && { cd /tmp; cat; } <<EOF\n$(rm -rf .)\nEOF", false),
("true && ( cd /tmp; cat; ) <<EOF\n$(rm -rf .)\nEOF", false),
(
"true && if cd /tmp; then cat; fi <<EOF\n$(rm -rf .)\nEOF",
false,
),
("true && { cd /tmp; cat; } > f", false),
("true && { cd /tmp; cat; } <<EOF > rel\nbody\nEOF", false),
("TMPDIR=/etc <<EOF\n$(touch $TMPDIR/x)\nEOF", false),
(
"cd /tmp && TMPDIR=/etc <<EOF\n$(touch $TMPDIR/x)\nEOF",
false,
),
("TMPDIR=/etc <<EOF > \"$TMPDIR/f\"\nbody\nEOF", false),
("TMPDIR=/tmp/xyzzy <<EOF > \"$TMPDIR/f\"\nbody\nEOF", true),
("TMPDIR=/tmp/xyzzy <<EOF\n$(touch $TMPDIR/x)\nEOF", true),
("A=1 B=2 <<EOF > /tmp/f\nbody\nEOF", true),
("cat $(cd /tmp && cat) <<EOF\n$(rm -rf .)\nEOF", false),
("export x=$(cd /tmp && cat) <<EOF\n$(rm -rf .)\nEOF", false),
("cat $(cd /tmp && cat) <<EOF > rel\nbody\nEOF", false),
("echo $(cd /tmp && cat) > f", false),
("cat $(cd /tmp && cat) <<EOF\n$(echo hi)\nEOF", true),
("cd /tmp <<EOF && touch f\nbody\nEOF", true),
("{ cat; } <<EOF\n$(echo hi > /tmp/out)\nEOF", true),
("( cat; ) <<EOF\n$(echo hi)\nEOF", true),
("true && { cat; } <<EOF\n$(echo hi > /tmp/out)\nEOF", true),
("cat <<EOF\n$(echo hi > /tmp/out)\nEOF", true),
];
run_cases(&cases);
}
#[test]
fn negated_time_heredoc_owners() {
let cases = [
("! TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF", false),
("! TMPDIR=/etc <<EOF\n$(touch $TMPDIR/x)\nEOF", false),
(
"cd /tmp && ! TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
false,
),
("! TMPDIR=/etc <<EOF > \"$TMPDIR/f\"\nbody\nEOF", false),
(
"! TMPDIR=/etc time cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
false,
),
("time TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF", false),
("time TMPDIR=/etc <<EOF\n$(touch $TMPDIR/x)\nEOF", false),
(
"time -p TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
false,
),
(
"time -p ! TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
false,
),
(
"time ! TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
false,
),
("time ! TMPDIR=/etc <<EOF\n$(touch $TMPDIR/x)\nEOF", false),
("time TMPDIR=/etc <<EOF > \"$TMPDIR/f\"\nbody\nEOF", false),
("! { cd /tmp; cat; } <<EOF\n$(rm -rf .)\nEOF", false),
(
"! TMPDIR=/tmp/xyzzy cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
true,
),
("! TMPDIR=/tmp/xyzzy <<EOF\n$(touch $TMPDIR/x)\nEOF", true),
(
"time TMPDIR=/tmp/xyzzy cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
true,
),
(
"time ! TMPDIR=/tmp/xyzzy <<EOF\n$(touch $TMPDIR/x)\nEOF",
true,
),
(
"TMPDIR=/tmp/xyzzy time cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
true,
),
];
run_cases(&cases);
}
#[test]
fn redirect_substitution_gaps() {
let cases = [
("cat < $(rm -rf /)", false),
("cat < \"$(rm -rf /)\"", false),
("cat < <(rm -rf /)", false),
("cat < `rm -rf /`", false),
("echo hi > /tmp/out $(rm -rf /)", false),
("cat <<EOF \"$(rm -rf /)\"\nbody\nEOF", false),
("cat <<EOF $(rm -rf /)\nbody\nEOF", false),
("cat > $(mktemp -d)/f", false),
("cat < /tmp/in", true),
];
run_cases(&cases);
}
#[test]
fn arithmetic_expansion_guard() {
let cases = [
("echo $((1+2))", true),
("echo $[1+2]", true),
("echo \"$((1+2))\"", true),
("echo $((1+$(touch f)))", false),
("(( 1 + 2 ))", true),
("for ((i=0; i>3; i++)); do echo hi; done", true),
];
run_cases(&cases);
}
#[test]
fn expansion_split_rejoin() {
let cases = [
("cd /tmp && touch $SNAP/$RANDOM/f", false), ("SNAP=/tmp && touch $SNAP/$RANDOM/f", true),
("SNAP=/tmp && touch pre$SNAP/f", false),
("SNAP=/tmp && touch /tmp/$SNAP/f", true),
("SNAP=$(mktemp -d) && touch $SNAP/$RANDOM/f", true),
("touch ${TMPDIR}/x", true), ("echo $SNAP/$RANDOM/f", true),
];
run_cases(&cases);
}
#[test]
fn declaration_unset_bindings() {
let cases = [
("declare TMPDIR=/etc && touch $TMPDIR/f", false),
("local TMPDIR=/etc && touch $TMPDIR/f", false),
("typeset TMPDIR=/etc && touch $TMPDIR/f", false),
("readonly TMPDIR=/etc && touch $TMPDIR/f", false),
("export TMPDIR=/tmp && touch $TMPDIR/f", true),
("unset TMPDIR; touch $TMPDIR/f", false),
("unset TMPDIR; touch /tmp/f", true),
("unset -f TMPDIR; touch /tmp/f", true),
(
"export TMPDIR=/tmp && unset TMPDIR && touch $TMPDIR/f",
false,
),
];
run_cases(&cases);
}
#[test]
fn mktemp_allowed() {
let cases = [("mktemp", true), ("mktemp -t mahbot.XXXXXX", true)];
run_cases(&cases);
}
#[test]
fn shell_prefixes_delegating() {
let cases = [
("rm file", false),
("git push", false),
("git status", true),
];
for prefix in SHELL_PREFIXES {
if NON_DELEGATING_PREFIXES.contains(prefix) {
continue;
}
for &(command, allowed) in &cases {
let cmd = format!("{prefix} {command}");
if allowed {
ok(&cmd);
} else {
assert_rejected(&cmd);
}
}
}
}
#[test]
fn prefix_bypass_and_env() {
let cases = [
("sudo -E rm file", false),
("sudo git status", true),
("sudo cargo check", true),
("sudo git push", false),
("env git push", false),
("GIT_DIR=/tmp sudo git push", false),
("sudo git stash list", true),
("cd", true),
("cd ..", true),
("FOO=bar rm file", false),
("VAR=val sudo rm -rf /", false),
("GIT_DIR=/tmp git status", false), ];
run_cases(&cases);
}
#[test]
fn git_exec_vectors_rejected() {
let cases = [
("GIT_DIR=/tmp git status", false),
("GIT_EXTERNAL_DIFF=/bin/echo git diff", false),
("GIT_SSH_COMMAND=/bin/echo git ls-remote origin", false),
("GIT_CONFIG_COUNT=1 git log", false),
("GIT_EXEC_PATH=/tmp/evil git request-pull", false),
("GIT_ASKPASS=/bin/echo git ls-remote origin", false),
("GIT_TRACE=/tmp/trace git status", false),
("export GIT_EXTERNAL_DIFF=/bin/echo && git diff", false),
(
"export GIT_SSH_COMMAND=/bin/echo; git ls-remote origin",
false,
),
("env GIT_EXTERNAL_DIFF=/bin/echo git diff", false),
("sudo GIT_DIR=/tmp git status", false),
("GIT_PAGER=/bin/echo git log", true), ("git -c diff.external=/bin/echo diff", false),
("git -c core.fsmonitor=/bin/echo status", false),
("git -c user.name=me log", false),
("git -cfoo=bar status", false), ("git -pc status", false), ("git -C /tmp/repo status", false),
("git -C/tmp/repo diff", false), ("git -pC /tmp/repo diff", false), ("git --git-dir=/tmp/evil/.git diff", false),
("git --git-dir /tmp/evil status", false),
("git --work-tree=/tmp/evil status", false),
("git --config-env=core.fsmonitor=F status", false),
("git --config-file=/tmp/evil status", false),
("git --exec-path=/tmp/evil request-pull", false),
("git diff --ext-diff", false),
("git diff --no-index --ext-diff /tmp/a /tmp/b", false),
("git log -p --textconv", false),
("git log --show-signature", false),
("git cat-file --filters HEAD:src/lib.rs", false),
("git cat-file --text HEAD:a.txt", false), ("git ls-remote --upload-pack=/bin/echo origin", false),
("git ls-remote --upload-pack /bin/echo origin", false),
("git push --dry-run --exec=/bin/echo origin", false), ("git stash show --textconv", false), (
"git push --dry-run --receive-pack=/bin/echo origin main",
false,
),
(
"git push --dry-run --receive-pack /bin/echo origin main",
false,
),
("git ls-remote --upload-p=/bin/echo origin", false),
("git ls-remote --upload-pa /bin/echo origin", false),
("git ls-remote --exe=/bin/echo origin", false),
("git push --dry-run --exe=/bin/echo origin", false),
("git push --dry-run --exe /bin/echo origin", false),
(
"git push --dry-run --receive-p=/bin/echo origin main",
false,
),
("git grep --open-files-in-p=/bin/echo pattern", false),
("git grep --open-files-in-p /bin/echo pattern", false),
("git diff --ext-d", false),
("git log --textc", false),
("git log --show-signat", false),
("git cat-file --filt HEAD:src/lib.rs", false), ("git grep -O /bin/echo pattern", false),
("git grep -O/bin/echo pattern", false),
("git grep -nO /bin/echo pattern", false),
("git grep -IO /bin/echo pattern", false),
("git grep --open-files-in-pager=/bin/echo pattern", false),
("git grep --open-files-in-pager pattern", false),
("git help --web", false),
("git help -w", false),
("git help -aw", false),
("git help -m git", false),
("git help -i git", false),
("git help --man git", false),
("git help --info git", false),
("git help --m git", false),
("/usr/bin/git push", false),
("/usr/bin/git -C /tmp status", false),
("git grep -e '--ext-diff' pattern", false), ("git grep -e '-Ofoo' pattern", false), ("git log --format=$'%h\\t%s'", false), ("git log -- --ext-diff", false), ];
run_cases(&cases);
}
#[test]
fn git_exec_vector_positives() {
let cases = [
("git status", true),
("git log", true),
("git diff", true),
("git show HEAD", true),
("git blame src/lib.rs", true),
("git log -- src/lib.rs", true),
("git log --oneline -- path", true),
("git diff -- file", true),
("git show HEAD -- file", true),
("git grep -- pattern", true),
("git grep --text pattern", true),
("git diff --text", true),
("git log --text", true),
("git blame --text src/lib.rs", true),
("git log -c", true),
("git diff -c", true),
("git log --oneline -c", true),
("git diff -w", true),
("git log -w", true),
("git log -Oorderfile", true),
("git grep -o pattern", true),
("git grep -n pattern", true),
("git grep -I pattern", true),
("git grep -c pattern", true),
("git grep -C3 pattern", true),
("git grep --regexp=-Ofoo pattern", true), ("git diff --no-ext-diff", true),
("git diff --no-textconv", true),
("git log --no-show-signature", true),
("git log --filter=blob:none", true),
("git rev-list --filter=blob:none HEAD", true),
("git cat-file --filter blob:none HEAD", true),
("git help", true),
("git help -a", true),
("git help --all", true),
("git help -g", true),
("git help -c core.pager", true),
("git help --config", true),
("git ls-remote --heads origin", true),
("git -p log", true),
("git -P log", true),
("GIT_PAGER=less git log", true),
("git log --format=$'%h'", true),
("git log --grep=--ext-diff", true),
];
run_cases(&cases);
}
#[test]
fn git_path_spelled_invocations() {
let cases = [
("/usr/bin/git init", false),
("/usr/bin/git status", true),
("./git init", false),
("./git log", true),
("'git' init", false),
("/usr/bin/'git' init", false),
("sudo /usr/bin/'git' reset --hard", false),
("env /usr/bin/'git' commit -m x", false),
("sudo /usr/bin/'git' status", true),
("sudo /usr/bin/git init", false),
("sudo /usr/bin/git status", true),
("env /usr/bin/git push", false),
("/usr/bin/git branch -df feature", false),
];
run_cases(&cases);
}
#[test]
#[expect(clippy::too_many_lines)] fn test_extract_git_subcommand() {
struct Case {
name: &'static str,
input: &'static str,
expected: &'static str,
}
let cases = [
Case {
name: "basic",
input: "git status",
expected: "status",
},
Case {
name: "with global flag",
input: "git -C /repo diff",
expected: "diff",
},
Case {
name: "with config",
input: "git -c user.name=me log",
expected: "log",
},
Case {
name: "with git dir",
input: "git --git-dir /repo status",
expected: "status",
},
Case {
name: "env assignment",
input: "GIT_DIR=/tmp git status",
expected: "status",
},
Case {
name: "no git",
input: "ls -la",
expected: "",
},
Case {
name: "git only",
input: "git",
expected: "",
},
Case {
name: "full subcommand",
input: "git branch -d feature",
expected: "branch -d feature",
},
Case {
name: "with double dash",
input: "git -- diff",
expected: "diff",
},
Case {
name: "stash list",
input: "git stash list",
expected: "stash list",
},
Case {
name: "stderr capture suffix skipped",
input: "git --version 2>&1",
expected: "",
},
Case {
name: "stderr capture after subcommand",
input: "git status 2>&1",
expected: "status 2>&1",
},
Case {
name: "multiple env",
input: "CC=gcc CXX=g++ git status",
expected: "status",
},
Case {
name: "multiple flags",
input: "git -C /repo --git-dir /other status",
expected: "status",
},
Case {
name: "with sudo skipped",
input: "sudo git status",
expected: "status",
},
Case {
name: "absolute path",
input: "/usr/bin/git status",
expected: "status",
},
Case {
name: "absolute path with global flag",
input: "/opt/homebrew/bin/git -C /repo diff",
expected: "diff",
},
Case {
name: "prefixed absolute path",
input: "sudo /usr/bin/git push",
expected: "push",
},
Case {
name: "relative path",
input: "./git log",
expected: "log",
},
Case {
name: "quoted git",
input: "'git' branch -d feature",
expected: "branch -d feature",
},
Case {
name: "quotes in final path component",
input: "/usr/bin/'git' status",
expected: "status",
},
Case {
name: "prefixed quotes in final path component",
input: "sudo /usr/bin/'git' push",
expected: "push",
},
Case {
name: "with env skipped",
input: "env git status",
expected: "status",
},
Case {
name: "env and sudo",
input: "GIT_DIR=/tmp sudo git status",
expected: "status",
},
Case {
name: "sudo push",
input: "sudo git push",
expected: "push",
},
Case {
name: "flag with multiple args",
input: "git branch --merged master",
expected: "branch --merged master",
},
];
for case in &cases {
assert_eq!(
extract_git_subcommand(case.input),
case.expected,
"case: {}",
case.name
);
}
}
#[test]
#[expect(clippy::too_many_lines)] fn temp_scratch_tests() {
let cases = [
(
"cat > /tmp/test_match.rs << 'EOF'\nfn test() { match x { \"a\" => 1, _ => 0 } }\nEOF",
true,
),
("echo hello > /private/tmp/mahbot_test_out.txt", true),
("tee /tmp/scratch.log", true),
("touch /tmp/scratch.txt", true),
("mkdir -p /tmp/scratch_dir", true),
("tee output.log", false),
("rm /tmp/scratch.txt", true),
("cp /tmp/a /tmp/b", true),
("mv /tmp/a /tmp/b", true),
("cp /tmp/a /etc/passwd", false),
("mv /tmp/a /etc/passwd", false),
("rm /etc/passwd", false),
("rmdir /tmp/scratch_dir", true),
("gzip /tmp/file.txt", true),
("gunzip /tmp/file.txt.gz", true),
("bzip2 /tmp/file.txt", true),
("xz /tmp/file.txt", true),
("zstd /tmp/file.txt", true),
("zip /tmp/out.zip /tmp/file1 /tmp/file2", true),
("cp /etc/passwd /tmp/out", true), ("curl -o /tmp/file URL", true),
("curl --output /tmp/file URL", true),
("curl -o /etc/passwd URL", false),
("curl --output /etc/passwd URL", false),
("curl -O URL", false),
("curl --remote-name URL", false),
("curl -o /tmp/file -O URL", false), ("wget -O /tmp/file URL", true),
("wget --output-document /tmp/file URL", true),
("wget -O /etc/passwd URL", false),
("wget --output-document /etc/passwd URL", false),
("wget -P /tmp/dir URL", true),
("wget --directory-prefix /tmp/dir URL", true),
("wget -P /etc/dir URL", false),
("wget URL", false),
("sed -i 's/a/b/' /tmp/file", true),
("sed -i.bak 's/a/b/' /tmp/file", true),
("sed -i 's/a/b/' /tmp/file1 /tmp/file2", true),
("sed -i 's/a/b/' /etc/passwd", false),
("sed -i 's/a/b/' /tmp/file /etc/passwd", false), ("sed -i '' /tmp/file", true), ("sed -i -e 's/a/b/' /tmp/file", true),
("sed -i -e 's/a/b/' /etc/passwd", false),
("sed -i .bak 's/a/b/' /tmp/file", true),
("sed -i .bak 's/a/b/' /etc/passwd", false),
("sed -ix 's/a/b/' /tmp/file", true),
("sed -ix 's/a/b/' /etc/passwd", false),
("sed -nix 's/a/b/' /etc/passwd", false),
("sed -I 's/a/b/' /tmp/file", true),
("sed -I 's/a/b/' /etc/passwd", false),
("sed -nIx 's/a/b/' /etc/passwd", false),
("sed --in-place 's/a/b/' /tmp/file", true),
("sed --in-place=bak 's/a/b/' /tmp/file", true),
("sed --in-place 's/a/b/' /etc/passwd", false),
("sed --in-place=bak 's/a/b/' /etc/passwd", false),
("sed --i 's/a/b/' /etc/passwd", false),
("sed '-i' 's/a/b/' /tmp/file", true),
("sed '-i' 's/a/b/' /etc/passwd", false),
("sed '-i'x 's/a/b/' /tmp/file", true), ("sed '-i'x 's/a/b/' /etc/passwd", false),
("sed '-nix' 's/a/b/' /etc/passwd", false),
("sed --'in-place' 's/a/b/' /tmp/file", true), ("sed --'in-place' 's/a/b/' /etc/passwd", false),
("sed \\-ix 's/a/b/' /tmp/file", true),
("sed \\-ix 's/a/b/' /etc/passwd", false),
("sed \\--in-place 's/a/b/' /etc/passwd", false),
("sed $'-ix' 's/a/b/' /etc/passwd", false),
("sed $'\\x2di' 's/a/b/' /tmp/file", true), ("sed $'\\x2di' 's/a/b/' /etc/passwd", false),
("sed $'\\055i' 's/a/b/' /etc/passwd", false), ("sed $'\\x2dix' 's/a/b/' /etc/passwd", false), ("sed $'\\u002di' 's/a/b/' /etc/passwd", false), ("sed $'\\\\x2di' 's/a/b/' /etc/passwd", false), ("sed $\"\\x2di\" 's/a/b/' /etc/passwd", false), ("sed -i 's/a/b/' $'\\x2fetc\\x2fpasswd' /tmp/file", false),
("sed -e's/x/i/' file", false), ("sed -- -info.txt", false), ("sed -i 's/a/b/' '/tmp/file'", true),
("sed -i 's/a/b/' '/etc/passwd' /tmp/file", false), ("dd if=/dev/zero of=/tmp/out bs=1024 count=1", true),
("dd of=/tmp/out", true),
("dd if=/dev/zero of=/etc/passwd bs=1024 count=1", false),
("dd of=/etc/passwd", false),
("base64 -d -o /tmp/out input.txt", true),
("base64 -d --output /tmp/out input.txt", true),
("base64 -d -o /etc/passwd input.txt", false),
("base64 -d --output /etc/passwd input.txt", false),
("base64 -d input.txt", true),
("base64 -o /tmp/out input.txt", true),
("tee /tmp/scratch.log /tmp/out.txt", true),
("touch /tmp/a.txt /tmp/b.txt", true),
("tee /tmp/scratch.log /etc/passwd", false),
("touch /tmp/scratch.txt /etc/cron.d/evil", false),
("mkdir -p /tmp/dir /etc/cron.d", false),
("tee /tmp/scratch.log /etc/passwd > /dev/null", false),
("tee /tmp/scratch.log /tmp/out.txt > /dev/null", true),
("tee /tmp/scratch.log /etc/passwd 2>/dev/null", false),
("tee /tmp/scratch.log /tmp/out.txt 2>&1", true),
("tee /tmp/scratch.log << 'EOF'\nbody\nEOF", true),
(
"tee /tmp/scratch.log /tmp/out.txt << 'EOF'\nbody\nEOF",
true,
),
("tee /tmp/scratch.log 1>/dev/null", true),
("tee /tmp/scratch.log &>/dev/null", true),
("tee /tmp/scratch.log &>>/dev/null", true),
("tee /tmp/scratch.log 1> /dev/null", true),
("tee /tmp/scratch.log 3> /dev/null", true),
("tee /tmp/scratch.log 3< /dev/null", true),
("tee /tmp/scratch.log 3<< 'EOF'\nbody\nEOF", true),
("tee /tmp/scratch.log 10> /dev/null", true),
("tee /tmp/scratch.log &> /dev/null", true),
("tee /tmp/scratch.log &>> /dev/null", true),
];
run_cases(&cases);
}
#[test]
fn all_temp_mutators_allowed_with_temp_paths() {
for &cmd in TEMP_MUTATORS {
let args = match cmd {
"cp" | "mv" => "/tmp/a /tmp/b",
"zip" => "/tmp/out.zip /tmp/file1",
_ => "/tmp/scratch.txt",
};
ok(&format!("{cmd} {args}"));
}
}
#[test]
fn all_temp_mutators_rejected_with_non_temp() {
for &cmd in TEMP_MUTATORS {
assert_rejected(&format!("{cmd} /etc/blocked_test"));
}
}
#[test]
fn mutator_table_rows_render() {
for check in MUTATOR_CHECKS {
for &verb in check.verbs {
let rendered = check.rejection.replace("{verb}", verb);
assert!(
!rendered.contains('{'),
"unsubstituted placeholder in rejection: {rendered}"
);
assert!(
rendered.contains(verb),
"verb missing from rendered rejection: {rendered}"
);
}
}
for &(_, why, suggestion) in GIT_ALWAYS_MUTATE {
assert!(!why.is_empty() && !suggestion.is_empty());
}
}
#[test]
fn heredoc_acceptance() {
let cases = [
("cat <<EOF\nbody\nEOF\ntouch workspace_file", false),
("cat <<EOF\nbody\nEOF\ntouch /tmp/scratch_file", true),
("cat <<EOF > workspace_file", false),
("cat <<EOF > /tmp/out", false), ("cat <<EOF\nrm -rf /tmp\nEOF", true),
("cat <<EOF\ncat > workspace_file\nEOF", true),
(
"cat <<EOF1 <<EOF2 > /tmp/out\nbody1 > x\nEOF1\nbody2 > y\nEOF2",
false, ),
("cat <<-EOF\n\tbody\n\tEOF", true),
("cat <<-EOF\n\tbody\n\t\tEOF", true),
("cat <<-EOF\n\tbody\n\t\tEOF\ntouch workspace_file", false),
("cat <<-EOF\n\tbody\n\t\t\tEOF\ntouch workspace_file", false),
("cat <<-EOF\n\tbody\n\t\tEOF\ntouch /tmp/scratch_file", true),
("cat <<EOF\r\nbody\r\nEOF\r\ntouch workspace_file", false),
("cat <<EOF\r\nbody\r\nEOF", true),
("cat <<EOF\nbody\nEOF", true),
("cat <<'EOF'\nbody\nEOF", true),
("cat <<EOF\nbody\nEOF\n&& touch workspace_file", false),
("cat <<EOF\n\"$(touch workspace_file)\"\nEOF", false),
(
"cat <<-EOF\n\t$(touch workspace_file)\n\t\tEOF\ntouch /tmp/ok",
false,
),
("cat <<EOF\nbody\nEOF && touch workspace_file", false), ("tee /tmp/x 3<< 'EOF'\nbody\nEOF", true),
("cat <<EOF\n$(touch workspace_file)\nEOF", false),
("cat <<EOF\n$(echo hi > workspace_file)\nEOF", false),
("cat <<EOF\n`touch workspace_file`\nEOF", false),
("cat <<EOF\n$(touch /tmp/x)\nEOF", true),
("cat <<EOF\n$(echo hi > /tmp/out)\nEOF", true),
("cat <<EOF\n$(echo $(touch workspace_file))\nEOF", false),
("cat <<EOF\n\\$(touch workspace_file)\nEOF", false),
("cat <<'EOF'\n$(touch workspace_file)\nEOF", true),
("cat <<\"EOF\"\n$(touch workspace_file)\nEOF", true),
("cat <<EOF\ntouch workspace_file\nEOF", true),
("cat <<A <<B\n$(touch workspace_file)\nA\nB", false),
("cat <<EOF\n$(echo hi)\nEOF\ntouch workspace_file", false),
("cd /tmp && cat <<EOF\n$(touch rel)\nEOF", true), ("cat <<", false),
("cat << ", false),
("cat <<-", false),
("cat <<- ", false),
("3<< ", false),
("3<<-", false),
];
run_cases(&cases);
}
#[test]
fn substitution_acceptance() {
let cases = [
("echo $(cat /etc/passwd 2>/dev/null)", true),
("echo `cat /etc/passwd 2>/dev/null`", false), ("echo $(touch workspace_file)", false),
("echo `touch workspace_file`", false),
("echo $(echo hi > /tmp/out)", true),
("echo $(echo hi > out.txt)", false),
("echo $(echo hi > /etc/passwd)", false),
("echo $(echo $(rm -rf /tmp/x))", true),
("echo $(echo $(touch ws_file))", false),
("echo $(rm -rf /tmp/scratch_dir)", true),
("echo $(tee /tmp/ok > ws_out)", false),
];
run_cases(&cases);
}
#[test]
fn double_quoted_substitution_acceptance() {
let cases = [
("echo \"$(touch workspace_file)\"", false),
("echo \"$(echo hi > workspace_file)\"", false),
("echo \"`touch workspace_file`\"", false),
("echo \"$(rm -f workspace_file)\"", false),
("echo \"$(rm -f /tmp/scratch_file)\"", true),
("echo \"$(echo $(touch nested))\"", false),
("echo \"$(echo hi > $(echo workspace_file))\"", false),
("echo \"$(echo hi > /tmp/out)\"", true),
("echo \"$(touch /tmp/scratch)\"", true),
("echo \"$(echo hi)\"", true),
("echo \"$(ls 2>&1)\"", true),
("echo \"a > b\"", true),
("echo \"$(echo hi)\" > /tmp/out", true),
("echo \"$(echo hi)\" > workspace_file", false),
("echo \"abc\\$(touch workspace_file)\"", true),
("echo \"abc\\`touch workspace_file\\`\"", true),
("echo '$(touch workspace_file)'", true),
("echo \"`echo hi; echo hi2`\"", false), ("echo \"$(cd /tmp; touch rel)\"", true),
("echo \"$(cd /etc; touch rel)\"", false),
("export TMPDIR=/etc\necho \"$(touch $TMPDIR/x)\"", false),
(
"export TMPDIR=/__mahbot_readonly_test_ws__\necho \"$(echo hi > $TMPDIR/out)\"",
false,
),
(
"export TMPDIR=/etc\nexport TMPDIR=/tmp\necho \"$(touch $TMPDIR/x)\"",
true,
),
("cat <<< \"$(touch workspace_file)\"", false),
("cat <<< \"$(echo hi)\"", true),
];
run_cases(&cases);
}
#[test]
fn substitution_state_acceptance() {
let cases = [
("export TMPDIR=/etc\necho $(touch $TMPDIR/x)", false),
("export TMPDIR=/etc\ncat $(echo hi > $TMPDIR/out)", false),
(
"export TMPDIR=/__mahbot_readonly_test_ws__\necho $(echo hi > $TMPDIR/out)",
false,
),
("TMPDIR=/etc true\necho $(touch $TMPDIR/x)", false),
(
"export TMPDIR=/etc\nexport TMPDIR=/tmp\necho $(touch $TMPDIR/x)",
true,
),
("cd /tmp && echo $(touch rel)", true),
("cd /tmp && echo $(echo hi > out.txt)", true),
("cd /etc && echo $(touch rel)", false),
("echo $(cd /tmp; touch rel)", true),
("echo $(cd /etc; touch rel)", false),
("echo $(export TMPDIR=/etc; touch $TMPDIR/x)", false),
(
"echo $(export TMPDIR=/etc; echo hi)\ntouch $TMPDIR/outer",
true,
),
("cd /tmp && echo $(touch ws_file)", true),
("cd /tmp && echo $(touch rel) && touch ws_file", true),
("echo $(echo hi) && touch ws_file", false),
];
run_cases(&cases);
}
#[test]
fn redirect_delimiter_acceptance() {
let cases = [
("echo $(cmd 2>/dev/null)", true),
("echo $(cmd 2>/dev/null) done", true),
("echo $(echo x > /etc/passwd)", false),
("echo $(echo x > /tmp/out)", true),
("echo $(echo x > out.txt)", false),
("echo $(cmd 2>/dev/null} done", false), ];
run_cases(&cases);
}
#[test]
fn newline_separator_acceptance() {
let cases = [
("touch /tmp/a\necho hi > /tmp/b\ncat /tmp/a", true),
("echo hi\ntouch workspace_file", false),
("touch workspace_file\necho hi", false),
("cat <<EOF\nrm -rf /tmp\nEOF\necho done", true),
("echo hello \\\nworld", true),
("touch /tmp/a \\\n/tmp/b", true),
("echo 'a\nb'", true),
];
run_cases(&cases);
}
#[test]
fn expansion_acceptance() {
let cases = [
("touch $TMPDIR/out.txt", true),
("touch \"$TMPDIR/out.txt\"", true),
("touch ${TMPDIR}/out.txt", true),
("echo hi > $TMPDIR/out", true),
("echo hi > \"$TMPDIR/out\"", true),
("tee $TMPDIR/scratch.log", true),
("mkdir -p $TMPDIR/scratch_dir", true),
("rm $TMPDIR/scratch.txt", true),
("sed -i 's/a/b/' /tmp/file", true),
("dd of=$TMPDIR/out bs=1 count=1", true),
("curl -o $TMPDIR/file URL", true),
("touch $PWD/out.txt", false),
("echo hi > $PWD/out", false),
("touch $FOO/out.txt", false),
("touch $TMP/out.txt", false), ("touch ~/out.txt", false),
("touch $HOME/out.txt", false),
("touch '$TMPDIR/out.txt'", false),
("touch \"$TMPDIR/out.txt", false),
];
run_cases(&cases);
}
#[test]
fn export_poisoning_acceptance() {
let cases = [
("export TMPDIR=/etc\necho hi > $TMPDIR/out", false),
("TMPDIR=/etc touch $TMPDIR/out", false),
("TMPDIR=/etc\ntouch $TMPDIR/out", false),
(
"export TMPDIR=/etc\nexport TMPDIR=/tmp\ntouch $TMPDIR/out",
true,
),
("TMPDIR=/etc\nTMPDIR=/tmp touch $TMPDIR/out", true),
("export TMP=/tmp\ntouch $TMP/out", true),
("export\ntouch $TMPDIR/out", true),
("export TMP\ntouch $TMPDIR/out", true),
("export TMPDIR=\"/tmp\"\ntouch $TMPDIR/out", true),
];
run_cases(&cases);
}
#[test]
fn copy_move_acceptance() {
let cases = [
("cp /etc/passwd /tmp/out", true),
("cp /tmp/a /tmp/b", true),
("cp -t /tmp/d s1 s2", true),
("cp --target-directory /tmp/d s1 s2", true),
("cp --target-directory=/tmp/d s1", true),
("cp /etc/passwd ws_file", false),
("cp /tmp/a /etc/passwd", false),
("cp -t /workspace/d s1", false),
("cp", false),
("mv /tmp/a /tmp/b", true),
("mv ws_file /tmp/out", false),
("rm /tmp/scratch.txt", true),
("rm ws_file", false),
];
run_cases(&cases);
}
#[test]
fn cwd_tracking_acceptance() {
let cases = [
("cd /tmp && touch f", true),
("cd /tmp && echo hi > out.txt", true),
("cd /tmp && tee scratch.log", true),
("cd /tmp && cd /etc && touch f", false),
("cd / && touch f", false),
("mkdir -p /tmp/src && cd /tmp && cd src && touch f", true),
(
"mkdir -p /tmp/a/b/c && cd /tmp && cd a/b/c && echo hi > out.txt",
true,
),
("cd /tmp && cd .. && touch f", false),
("cd /tmp && cd ../etc && touch f", false),
("cd /tmp && cd ../../../etc && touch f", false),
("cd /tmp && cd /etc && cd src && touch f", false),
("cd /tmp && cd a/b/c && touch ../x", false),
("cd /tmp ; cd a/b/c ; touch ../x", false),
("cd /tmp\ncd a/b/c\ntouch ../x", false),
(
"mkdir -p /tmp/a/b/c && cd /tmp && cd a/b/c && touch ../x",
true,
),
("cd .. && touch f", false),
("cd - && touch f", false),
("cd && touch f", false),
("cd $TMPDIR && touch f", false),
("cd ~ && touch f", false),
("cd /tmp && pushd /tmp && touch f", false),
("cd /tmp && popd && touch f", false),
("cd /nonexistent_dir_xyz_1059 && touch f", false),
("cd /tmp/nonexistent_x && touch f", false),
("cd /tmp/nonexistent_x ; touch f", false),
("cd /tmp/nonexistent_x\ntouch f", false),
("cd /tmp/nonexistent_x || touch f", false),
("mkdir -p /tmp/snap && cd /tmp/snap && touch f", true),
("mkdir -p /tmp/snap; cd /tmp/snap; touch f", true),
("mkdir -p /tmp/snap\ncd /tmp/snap\ntouch f", true),
("mkdir /tmp/snap; cd /tmp/snap; touch f", true),
("mkdir -p /tmp/a/b; cd /tmp/a; touch f", true),
(
"mkdir /tmp/absent_parent_x/dir_y; cd /tmp/absent_parent_x/dir_y; touch f",
false,
),
(
"mkdir /tmp/absent_parent_x/dir_y\ncd /tmp/absent_parent_x/dir_y\ntouch f",
false,
),
(
"mkdir /tmp/absent_parent_x/dir_y || cd /tmp/absent_parent_x/dir_y || touch f",
false,
),
(
"mkdir /tmp/absent_parent_x/dir_y; cd /tmp/absent_parent_x/dir_y; rm -rf x",
false,
),
(
"mkdir /tmp/absent_parent_x/dir_y; cd /tmp/absent_parent_x/dir_y; tee out",
false,
),
(
"mkdir -p /tmp/chain_a; mkdir /tmp/chain_a/chain_b; cd /tmp/chain_a/chain_b; touch f",
true,
),
("cd /tmp && cd $TMPDIR && touch f", true),
("cd /tmp && cd $HOME && touch f", false),
("cd /tmp && cd ~ && touch f", false),
("cd /tmp && cd - && touch f", false),
("cd /tmp && cd $OLDPWD && rm -rf x", false),
("cd /tmp && cd $OLDPWD && touch f", false),
("cd /tmp && cd $FOO && touch f", false),
("cd /tmp && touch $PWD/out", true),
("cd /tmp && touch $PWD/../../etc/passwd", false),
("cd /tmp ; cd src ; touch $PWD/../etc/passwd", false),
(
"mkdir -p /tmp/src && cd /tmp && cd src && touch $PWD/out",
true,
),
("cd / && rm f", false),
("touch f", false),
("cd /tmp && touch *.log", false),
("cd /tmp && echo hi > ../etc/passwd", false),
("cp /tmp/a .", false),
("cd /tmp && cp /tmp/a .", true),
("cd /tmp && cd -P /tmp && touch f", true),
("cd /tmp && cd -L /tmp && echo hi > out.txt", true),
("cd /tmp && cd -L -- /tmp && touch f", true),
("mkdir -p /tmp/snap && cd -P /tmp/snap && touch f", true),
("cd /tmp && cd -P /tmp/nonexistent_x && touch f", false),
("cd /tmp && command cd /tmp && touch f", true),
("cd /tmp && builtin cd /tmp && touch f", true),
(
"mkdir -p /tmp/snap && cd /tmp && time cd /tmp/snap && touch f",
true,
),
("cd /tmp && eval cd /tmp && echo hi > out.txt", true),
("cd /tmp && command cd -L -- /tmp && touch f", true),
];
run_cases(&cases);
}
#[test]
fn cd_flag_forms_fail_closed() {
let cases = [
("cd /tmp && cd -P /etc && touch f", false),
("cd /tmp && cd -L /etc && rm -rf x", false),
("cd /tmp && cd -PL /etc && touch f", false),
("cd /tmp && cd -P && touch f", false),
("cd /tmp && cd -L && touch f", false),
("cd /tmp && cd -PL && touch f", false),
("cd /tmp && cd -- $HOME && touch f", false),
("cd /tmp && cd -- - && touch f", false),
("cd /tmp && cd -- - && rm -rf x", false),
("cd /tmp && cd -P - && touch f", false),
("cd /tmp && cd -e /tmp && touch f", false),
("cd /tmp && cd -eP /tmp && touch f", false),
("cd /tmp && cd -Pe /tmp && touch f", false),
("cd /tmp && cd -@ /tmp && touch f", false),
("cd /tmp && cd -x /tmp && touch f", false),
("cd /tmp && cd -P-L /tmp && touch f", false),
];
run_cases(&cases);
}
#[test]
fn git_read_only_acceptance() {
let cases = [
("git stash show", true),
("git stash show stash@{0}", true),
("git show-ref", true),
("git ls-remote", true),
("git submodule status", true),
("git submodule status --recursive", true),
("git submodule", true),
("git config user.name", true),
("git config --global user.name", true),
("git config --list", true),
("git config -l", true),
("git config --get user.name", true),
("git config --get-all core.pager", true),
("git config --name-only --get-regexp '^core\\.'", true),
("git rebase --show-current", true),
("git push --dry-run", false), ("git push -n", false), ("git push -n origin main", false), ("git clean -n", false), ("git clean --dry-run", false), ("git clean -ndx", false), ("git --version 2>&1", true),
("git status 2>&1", true),
("git diff --output-indicator-new=+", true),
("git diff --output-indicator-old=-", true),
(
"git diff --output-indicator-new=+ --output-indicator-old=-",
true,
),
("git config user.name Egor", false),
("git config --global user.name Egor", false),
("git config --add core.pager less", false),
("git config --edit", false),
("git config --unset user.name", false),
("git rebase --continue", false),
("git rebase main", false),
("git rebase", false),
("git push origin main", false),
("git push", false),
("git push --dry-run -f", false),
("git push -fn", false),
("git clean -f", false),
("git clean -n -f", false),
("git clean -fd", false),
("git clean", false),
("git stash pop", false),
("git stash apply", false),
("git stash drop", false),
("git stash push", false),
("git stash", false),
("git stash push -m \"stash show\"", false),
("git stash push -m \"stash list\"", false),
("git stash store 0123abcd \"stash show\"", false),
("git stash show --stat stash@{0}", true),
("git stash list --oneline", true),
("git stash show --output=/tmp/x", false),
("git stash list --output=/tmp/x", false),
("git submodule foreach", false),
("git submodule update", false),
("git submodule add https://example.com/repo.git", false),
];
run_cases(&cases);
}
#[test]
fn cargo_read_only_acceptance() {
let cases = [
("cargo +nightly build", true),
("cargo +stable check", true),
("cargo +nightly test", true),
("cargo +nightly clippy", true),
("cargo +nightly doc", true),
("cargo +nightly run", true),
("cargo +nightly fix", true),
("cargo +nightly update", true),
("cargo --version 2>&1", true),
("cargo build 2>&1", true),
("git --version 2>&1", true),
("cargo fix --help", true),
("cargo run --help", true),
("cargo run -h", true),
("cargo nextest --version", true),
("cargo --version", true),
("cargo build -V", true),
("cargo update --dry-run", true),
("cargo update", true),
("cargo generate-lockfile", true),
("cargo fix", true),
("cargo run", true),
("cargo run -- --help", true),
("cargo clippy --fix", true),
("cargo clippy -- --fix", true),
("cargo fmt", true),
("cargo fmt --check", true),
("cargo fmt -- --check", true),
("cargo install foo", true),
("cargo run > out.txt", false),
];
run_cases(&cases);
}
#[test]
fn keep_blocked_battery() {
let cases = [
("kill 1234", false),
("kill -9 1234", false),
("pkill -f mahbot", false),
("killall chrome", false),
("ln -s /tmp/a /tmp/b", false),
("ln /tmp/a /tmp/b", false),
("unzip archive.zip", false),
("unzip -o archive.zip -d /tmp/out", false),
("rm ~/.mahbot/db/board.db-wal", false),
("rm $HOME/.mahbot/db/board.db-wal", false),
("rm -rf target", false),
("git init", false),
("git init /tmp/x", false),
("git commit -m test", false),
("git reset --hard", false),
("sed -i 's/a/b/' file", false),
("awk -i inplace '{print $1}' file", false),
("dd if=/dev/zero of=file bs=1 count=1", false),
("curl -o out.txt URL", false),
("wget -O out.txt URL", false),
];
run_cases(&cases);
}
#[test]
#[expect(clippy::too_many_lines)] fn temp_var_binding_acceptance() {
let cases = [
("SNAP=$(mktemp -d)\ntouch \"$SNAP/f\"", true),
("SNAP=$(mktemp -d)\necho hi > \"$SNAP/out\"", true),
("SNAP=$(mktemp -d)\ncp /etc/passwd \"$SNAP/out\"", true),
("SNAP=$(mktemp -d)\nmkdir -p \"$SNAP/a/b\"", true),
("SNAP=$(mktemp -d)\nrm -rf \"$SNAP\"", true),
("SNAP=$(mktemp -d)\nrm -rf \"$SNAP/\"", true),
("SNAP=`mktemp -d`\ntouch \"$SNAP/f\"", false), ("SNAP=\"$(mktemp -d)\"\ntouch \"$SNAP/f\"", true),
("export SNAP=$(mktemp -d)\ntouch \"$SNAP/f\"", true),
("echo $(SNAP=$(mktemp -d); touch \"$SNAP/f\")", true),
("SNAP=$(mktemp -d)\ntouch $SNAP/f", true),
("SNAP=$(mktemp -d)\ntouch ${SNAP}/f", true),
("SNAP=/tmp/snap\ntouch \"$SNAP/f\"", true),
("SNAP=$TMPDIR/snap\ntouch \"$SNAP/f\"", true),
(
"SNAP=/tmp/snap\ndir=$SNAP/.mahbot/db\nmkdir -p \"$dir\"",
true,
),
("SNAP=/etc\ntouch \"$SNAP/f\"", false),
(
"SNAP=/__mahbot_readonly_test_ws__/snap\nmkdir -p \"$SNAP/db\"",
false,
),
("SNAP=$FOO\ntouch \"$SNAP/f\"", false),
("SNAP=/etc\nSNAP=$(mktemp -d)\ntouch \"$SNAP/f\"", true),
("SNAP=$(mktemp -d)\nSNAP=/etc\ntouch \"$SNAP/f\"", false),
(
"SNAP=$(mktemp -d)\nSNAP=/tmp/other\ntouch \"$SNAP/f\"",
true,
),
("SNAP=/tmp/snap touch $SNAP/f", true),
("SNAP=/etc touch $SNAP/f", false),
("touch $FOO/f", false),
(
"SNAP=$(mktemp -d /tmp/mahbot.XXXXXX)\ntouch \"$SNAP/f\"",
true,
),
(
"SNAP=$(mktemp -d -- /tmp/mahbot.XXXXXX)\ntouch \"$SNAP/f\"",
true,
),
(
"SNAP=$(mktemp -d \"$TMPDIR/mahbot.XXXXXX\")\ntouch \"$SNAP/f\"",
true,
),
(
"SNAP=$(mktemp -d /etc/mahbot.XXXXXX)\ntouch \"$SNAP/f\"",
false,
),
(
"SNAP=$(mktemp -d /__mahbot_readonly_test_ws__/snap.XXXXXX)\ntouch \"$SNAP/f\"",
false,
),
(
"SNAP=$(mktemp -d $HOME/snap.XXXXXX)\ntouch \"$SNAP/f\"",
false,
),
("SNAP=$(mktemp -d snap.XXXXXX)\ntouch \"$SNAP/f\"", false),
("SNAP=$(mktemp -d ./snap.XXXXXX)\ntouch \"$SNAP/f\"", false),
(
"SNAP=$(mktemp -d -- /etc/mahbot.XXXXXX)\ntouch \"$SNAP/f\"",
false,
),
("SNAP=$(mktemp -d -- snap.XXXXXX)\ntouch \"$SNAP/f\"", false),
(
"SNAP=$(mktemp -d -- $HOME/snap.XXXXXX)\ntouch \"$SNAP/f\"",
false,
),
(
"SNAP=$(mktemp -d /tmp/a.XXXXXX /tmp/b.XXXXXX)\ntouch \"$SNAP/f\"",
false,
),
("SNAP=$(mktemp -d /tmp/foo)\ntouch \"$SNAP/f\"", false),
("SNAP=$(mktemp -d /tmp/foo.XX)\ntouch \"$SNAP/f\"", false),
("SNAP=$(mktemp -d -p /tmp)\ntouch \"$SNAP/f\"", true),
("SNAP=$(mktemp -d --tmpdir=/tmp)\ntouch \"$SNAP/f\"", true),
("SNAP=$(mktemp -d -p /etc)\ntouch \"$SNAP/f\"", false),
(
"SNAP=$(mktemp -d --tmpdir=/__mahbot_readonly_test_ws__)\ntouch \"$SNAP/f\"",
false,
),
(
"SNAP=$(mktemp -d -p /tmp/nonexistent_x)\ntouch \"$SNAP/f\"",
false,
),
(
"SNAP=$(mktemp -d -p /tmp/nonexistent_x) ; touch \"$SNAP/f\"",
false,
),
(
"SNAP=$(mktemp -d --tmpdir=/tmp/nonexistent_x)\ntouch \"$SNAP/f\"",
false,
),
(
"mkdir -p /tmp/snapdir; SNAP=$(mktemp -d -p /tmp/snapdir)\ntouch \"$SNAP/f\"",
true,
),
("SNAP=$(mktemp -d --tmpdir /tmp)\ntouch \"$SNAP/f\"", false),
("SNAP=$(mktemp -d --tmpdir /etc)\ntouch \"$SNAP/f\"", false),
(
"SNAP=$(mktemp -d --suffix=.foo /tmp/x.XXXXXX)\ntouch \"$SNAP/f\"",
false,
),
(
"cd /tmp; SNAP=$(mktemp -d -p /tmp/nonexistent_x); touch $SNAP/f",
false,
),
(
"cd /tmp\nSNAP=$(mktemp -d -p /tmp/nonexistent_x)\ntouch $SNAP/f",
false,
),
(
"cd /tmp && SNAP=$(mktemp -d -p /tmp/nonexistent_x) && touch $SNAP/f",
false,
),
("cd /tmp; SNAP=$(mktemp -d -p /etc); touch $SNAP/f", false),
(
"cd /tmp; SNAP=$(mktemp -d /etc/foo.XXXXXX); touch $SNAP/f",
false,
),
(
"cd /tmp; SNAP=$(mktemp -d --tmpdir /tmp); touch $SNAP/f",
false,
),
(
"cd /tmp; SNAP=$(mktemp -d --tmpdir=/tmp/nonexistent_x); touch $SNAP/f",
false,
),
(
"cd /tmp; SNAP=$(mktemp -d --suffix=.foo /tmp/x.XXXXXX); touch $SNAP/f",
false,
),
(
"cd /tmp; SNAP=$(mktemp -d -p /etc); rm -rf $SNAP/Users/egordezic/Desktop/mahbot",
false,
),
(
"cd /tmp; SNAP=$(mktemp -d -p /etc); echo pwn > $SNAP/Users/egordezic/Desktop/mahbot/x",
false,
),
(
"cd /tmp; SNAP=$(mktemp -d -p /etc); tee $SNAP/Users/egordezic/Desktop/mahbot/x",
false,
),
("cd /tmp; touch $(mktemp -d -p /etc)/f", false),
("cd /tmp; echo hi > $(mktemp -d -p /etc)/f", false),
(
"cd /tmp; rm -rf $(mktemp -d -p /etc)/Users/egordezic/Desktop/mahbot",
false,
),
("cd /tmp; touch `mktemp -d -p /etc`/f", false),
("cd /tmp; SNAP=$(mktemp -d); touch $SNAP/f", true),
("cd /tmp; SNAP=$(mktemp -d -p /tmp); touch $SNAP/f", true),
(
"cd /tmp; SNAP=$(mktemp -d --tmpdir=/tmp); touch $SNAP/f",
true,
),
("SNAP='$(mktemp -d)'\ntouch \"$SNAP/f\"", false),
(
"cd /tmp; SNAP=pre$(mktemp -d -p /etc); touch $SNAP/f",
false,
),
("cd /tmp; echo hi > /tmp/${FOO:-../etc}/f", false),
("echo hi > /tmp/${FOO:-../etc}/f", false),
("cd /tmp; touch /tmp/${FOO//x/../etc}/f", false),
("cd /tmp; echo hi > \"/tmp/${FOO:-../etc}/f\"", false),
];
run_cases(&cases);
}
#[test]
fn opaque_suffix_acceptance() {
let cases = [
("SNAP=$(mktemp -d)\ntouch \"$SNAP/$RANDOM/f\"", true),
("SNAP=$(mktemp -d)\ntouch \"$SNAP/$$/f\"", true),
("SNAP=$(mktemp -d)\ntouch \"$SNAP/$RANDOM\"", true),
("SNAP=$(mktemp -d)\necho hi > \"$SNAP/$RANDOM/out\"", true),
("touch \"/tmp/$RANDOM/f\"", true),
("touch \"$RANDOM/f\"", false),
("SNAP=$(mktemp -d)\ntouch \"$SNAP/$FOO/f\"", false),
("SNAP=$(mktemp -d)\ntouch \"$SNAP/$PATH/f\"", false),
("SNAP=$(mktemp -d)\ntouch \"$SNAP/$RANDOM/../f\"", false),
(
"SNAP=$(mktemp -d)\ntouch \"$SNAP/$RANDOM/../../etc/passwd\"",
false,
),
("SNAP=$(mktemp -d)\ntouch \"$SNAP/../$RANDOM/f\"", true),
];
run_cases(&cases);
}
#[test]
fn snapshot_query_procedure_acceptance() {
let cases = [
(
"SNAP=$(mktemp -d)\nmkdir -p \"$SNAP/.mahbot/db\"\ncp ~/.mahbot/db/board.db \"$SNAP/.mahbot/db/\"\nHOME=\"$SNAP\" mahbot debug --db board \"SELECT 1\"\nrm -rf \"$SNAP\"",
true,
),
(
"SNAP=$(mktemp -d)\nmkdir -p \"$SNAP/.mahbot/db\"\nfor db in ~/.mahbot/db/*.db; do\ncp \"$db\" \"$SNAP/.mahbot/db/\"\ncp \"$db-wal\" \"$SNAP/.mahbot/db/\" 2>/dev/null || true\ndone\nHOME=\"$SNAP\" mahbot debug --db sessions \"SELECT COUNT(*) FROM messages\"\nrm -rf \"$SNAP\"",
true,
),
(
"SNAP=/tmp/mahbot-snap\ndir=$SNAP/.mahbot/db\nmkdir -p \"$dir\"\ncp ~/.mahbot/db/board.db \"$dir/\"\nHOME=\"$SNAP\" mahbot debug --db board \"SELECT 1\"\nrm -rf \"$SNAP\"",
true,
),
(
"SNAP=/__mahbot_readonly_test_ws__/snap\nmkdir -p \"$SNAP/.mahbot/db\"",
false,
),
(
"SNAP=$(mktemp -d)\ncp ~/.mahbot/db/board.db /__mahbot_readonly_test_ws__/out",
false,
),
(
"SNAP=$(mktemp -d)\nrm -rf /__mahbot_readonly_test_ws__",
false,
),
];
run_cases(&cases);
}
#[test]
fn denial_message_education() {
let ctx = test_ctx();
let err = check_command("touch $FOO/out", &ctx).unwrap_err();
assert!(
err.contains("$(mktemp -d)"),
"scratch-mutator denial should teach the variable spelling: {err}"
);
let err = check_command("rm $FOO/out", &ctx).unwrap_err();
assert!(
err.contains("$(mktemp -d)"),
"temp-mutator denial should teach the variable spelling: {err}"
);
}
#[test]
fn symlink_escape_fails_closed() {
let target = std::path::PathBuf::from("/etc");
let link = std::env::temp_dir().join(format!("mahbot_ro_probe_{}", std::process::id()));
let _ = std::fs::remove_file(&link);
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let link_str = link.to_string_lossy().into_owned();
let cases = [
(format!("cd {link_str} && touch f"), false),
(format!("cd {link_str} ; touch f"), false),
(format!("touch {link_str}/f"), false),
(format!("rm -rf {link_str}/x"), false),
(format!("echo hi > {link_str}/out.txt"), false),
(format!("tee {link_str}/x"), false),
(format!("cp /tmp/a {link_str}/dest"), false),
];
for (command, allowed) in &cases {
if *allowed {
ok(command);
} else {
assert_rejected(command);
}
}
let _ = std::fs::remove_file(&link);
}
#[test]
fn prefixed_cd_and_dotdot_escapes_fail_closed() {
let cases = [
("cd /tmp && command cd /etc && touch f", false),
("cd /tmp && command cd -- /etc && touch f", false),
("cd /tmp && builtin cd $HOME && touch f", false),
("cd /tmp && builtin cd -P /etc && touch f", false),
("cd /tmp && time cd ~ && touch f", false),
("cd /tmp && eval cd \"$HOME\" && touch f", false),
(
"cd /tmp && command cd /__mahbot_readonly_test_ws__ && rm -rf x",
false,
),
(
"cd /tmp && builtin cd /__mahbot_readonly_test_ws__ && touch f",
false,
),
(
"mkdir -p /tmp/qa_b && cd /tmp/qa_a/../qa_b && touch ../x",
false,
),
(
"mkdir -p /tmp/qa_b && cd /tmp && cd qa_a/../qa_b && touch ../x",
false,
),
(
"mkdir -p /tmp/qa_b && cd /tmp && SNAP=$(mktemp -d -p qa_a/../qa_b) && touch $SNAP/f",
false,
),
(
"mkdir -p /tmp/qa_b && cd /tmp && SNAP=$(mktemp -d qa_a/../qa_b.XXXXXX) && touch $SNAP/f",
false,
),
(
"mkdir -p /tmp/qa_b && cd /tmp && cd -P qa_a/../qa_b && touch ../x",
false,
),
("cd /tmp && command cd /tmp && touch f", true),
("cd /tmp && builtin cd -P /tmp && touch f", true),
("cd /tmp && command -p cd /tmp && touch f", true),
("cd /tmp && time -p cd /tmp && touch f", true),
("cd /tmp && command \"cd\" /tmp && touch f", true),
("cd /tmp && eval 'cd /tmp' && touch f", true),
("cd /tmp && eval \"cd /tmp\" && touch f", true),
("cd /tmp && command -v cd /tmp && touch f", true),
("cd /tmp && builtin -p cd /tmp && touch f", true),
];
run_cases(&cases);
}
#[test]
fn quoted_eval_and_brace_escapes_fail_closed() {
let cases = [
("cd /tmp && eval 'cd /etc' && touch f", false),
("cd /tmp && eval \"cd /etc\" && touch f", false),
("cd /tmp && eval \"cd $HOME\" && touch f", false),
("cd /tmp && command eval 'cd /etc' && touch f", false),
("cd /tmp && time eval 'cd /etc' && touch f", false),
("cd /tmp && builtin eval 'cd /etc' && touch f", false),
("cd /tmp && eval 'cd /etc' && rm -rf x", false),
("cd /tmp && eval 'cd /etc; echo hi' && touch f", false),
("cd /tmp && eval 'cd /tmp && cd /etc' && touch f", false),
("cd /tmp && eval 'cd /tmp; touch f' && touch g", true),
("eval 'echo hi'", true),
("eval 'ls'", true),
("eval \"echo hi\"", true),
("eval 'touch /tmp/x'", true),
("eval 'rm -rf /tmp/x'", true),
("eval 'touch f'", false),
("eval 'echo hi && touch f'", false),
("eval 'cd /tmp; rm -rf /tmp/x'", true),
("eval echo hi", true),
("eval touch /tmp/x", true),
("eval touch f", false),
("eval rm -rf /__mahbot_readonly_test_ws__", false),
("cd /tmp && { cd /etc; } && touch f", false),
("cd /tmp && { cd /etc } && touch f", false),
("cd /tmp && { cd /etc;\n} && touch f", false),
("cd /tmp && { cd /etc; } && rm -rf x", false),
("{ touch f; }", false),
("cd /tmp && { cd /tmp; } && touch f", false),
];
run_cases(&cases);
}
#[test]
fn prefix_option_and_quoted_verb_fail_closed() {
let cases = [
("cd /tmp && command -p cd /etc && touch f", false),
("cd /tmp && command -p cd -P /etc && touch f", false),
("cd /tmp && time -p cd /etc && touch f", false),
("cd /tmp && time -p cd ~ && touch f", false),
(
"cd /tmp && command -p cd /__mahbot_readonly_test_ws__ && rm -rf x",
false,
),
("cd /tmp && command \"cd\" /etc && touch f", false),
("cd /tmp && builtin 'cd' $HOME && touch f", false),
("cd /tmp && time \"cd\" /etc && touch f", false),
("cd /tmp && command 'pushd' /etc && touch f", false),
("\"cd\" /etc && touch f", false),
(
"cd /tmp && \"cd\" /__mahbot_readonly_test_ws__ && rm -rf x",
false,
),
];
run_cases(&cases);
}
#[test]
fn composed_forwarding_prefixes_fail_closed() {
let cases = [
("cd /tmp && command builtin cd /etc && touch f", false),
("cd /tmp && command command cd /etc && touch f", false),
("cd /tmp && builtin command cd /etc && touch f", false),
("cd /tmp && command -p builtin cd /etc && touch f", false),
("cd /tmp && time command cd /etc && touch f", false),
("cd /tmp && time builtin cd /etc && touch f", false),
("cd /tmp && builtin -- command cd /etc && touch f", false),
(
"cd /tmp && command builtin command cd /etc && touch f",
false,
),
(
"cd /tmp && command builtin cd /__mahbot_readonly_test_ws__ && rm -rf x",
false,
),
("cd /tmp && command builtin cd /tmp && touch f", true),
("time command cd /tmp && touch f", true),
("cd /tmp && command -pp builtin cd /etc && touch f", false),
("cd /tmp && command -v builtin && touch f", true),
("cd /tmp && command builtin -v cd && touch f", true),
("cd /tmp && builtin command -v cd && touch f", true),
("cd /tmp && time command -v cd && touch f", true),
("command FOO=bar cd /tmp; touch f", false),
("builtin FOO=bar cd /tmp; touch f", false),
("command -- FOO=bar cd /tmp; touch f", false),
("command -p FOO=bar cd /tmp; touch f", false),
("command -pp FOO=bar cd /tmp; touch f", false),
("command builtin FOO=bar cd /tmp; touch f", false),
("command -pp builtin FOO=bar cd /tmp; rm -rf x", false),
("command -p FOO=bar cd /tmp; rm -rf x", false),
("time FOO=bar cd /tmp && touch f", true),
("time FOO=bar cd /etc && touch f", false),
("time -p FOO=bar cd /tmp && touch f", true),
];
run_cases(&cases);
}
#[test]
fn time_prefix_requires_head_position_and_unquoted() {
let cases = [
("\"time\" cd /tmp && touch f", false),
("'time' cd /tmp && touch f", false),
("\"time\" cd /tmp; rm -rf x", false),
("\"time\" cd /tmp", true),
("'time' cd /tmp", true),
("\"time\" cd /tmp && cd /tmp && touch f", true),
("\"time\" rm -rf /__mahbot_readonly_test_ws__", false),
("\"time\" git commit -m test", false),
("cd /tmp && \"time\" cd /etc && touch f", true),
("cd /tmp && 'time' cd /etc && touch f", true),
("command time cd /tmp && touch f", false),
("builtin time cd /tmp && touch f", false),
("command -- time cd /tmp && touch f", false),
("command time cd /etc && touch f", false),
("time time cd /tmp && touch f", false),
("command time export SNAP=/tmp; cd $SNAP; touch f", false),
("TMPDIR=/tmp time cd /tmp && touch f", false),
("TMPDIR=/tmp time cd /etc && touch f", false),
("TMPDIR=/tmp time export SNAP=/tmp; touch $SNAP/f", false),
(
"TMPDIR=/tmp time export SNAP=/tmp; cd $SNAP; touch f",
false,
),
("! time cd /tmp; touch f", false),
("! time cd /tmp; rm -rf x", false),
("! time cd /tmp && touch f", false),
("! time cd /tmp | cat; touch f", false),
("! cd /tmp; touch f", true),
("! cd /tmp && touch f", true),
("time cd /tmp && touch f", true),
("time cd /etc && touch f", false),
("time -p cd /tmp && touch f", true),
("time command cd /tmp && touch f", true),
("time builtin cd /tmp && touch f", true),
("time export SNAP=/tmp; touch $SNAP/f", true),
(
"time export SNAP=/tmp && cd /tmp && cd $SNAP && touch f",
true,
),
("time FOO=bar cd /tmp && touch f", true),
("time -p FOO=bar cd /tmp && touch f", true),
("time \"-p\" cd /tmp; touch f", false),
("time \"-p\" cd /tmp; rm -rf x", false),
("time '-p' cd /tmp; touch f", false),
("time \"-p\" cd /tmp && touch f", false),
("time \"-p\" cd /tmp || touch f", false),
("time \"-p\" cd /tmp", true),
("'time' '-p' cd /tmp; touch f", false),
("time -p \"-p\" cd /tmp; touch f", false),
("time -p \"cd\" /tmp && touch f", true),
];
run_cases(&cases);
}
#[test]
fn time_in_construct_conditions_is_external() {
let cases = [
("if time cd /tmp; touch f; then :; fi", false),
("if time cd /tmp && touch f; then :; fi", false),
("if time cd /tmp; rm -rf x; then :; fi", false),
("while time cd /tmp; touch f; do :; done", false),
("until time cd /tmp; touch f; do :; done", false),
(
"if false; then :; elif time cd /tmp; touch f; then :; fi",
false,
),
("case x in x) time cd /tmp; touch f;; esac", false),
("if ! time cd /tmp; touch f; then :; fi", false),
("if\n time cd /tmp; touch f; then :; fi", true),
("if true && time cd /tmp && touch f; then :; fi", true),
("if cd /tmp && time cd /etc && touch f; then :; fi", false),
("case x in x) true && time cd /tmp && touch f;; esac", true),
("if true; then time cd /tmp; touch f; fi", true),
("if false; then :; else time cd /tmp; touch f; fi", true),
("while true; do time cd /tmp; touch f; break; done", true),
];
run_cases(&cases);
}
#[test]
fn unmodeled_verb_forms_fail_closed() {
let cases = [
("cd /tmp && \"c\"d /etc && touch f", false),
("cd /tmp && \"c\"d /tmp && touch f", false),
("cd /tmp && c'd' /etc && touch f", false),
("cd /tmp && \"c\"'d' /etc && touch f", false),
("cd /tmp && $'cd' /etc && touch f", false),
("cd /tmp && $'cd' /tmp && touch f", false),
("cd /tmp && c\\144 /etc && touch f", false),
("cd /tmp && $(printf c)d /etc && touch f", false),
("cd /tmp && $(printf c)d /tmp && touch f", false),
("cd /tmp && \"p\"ushd /etc && touch f", false),
("\"r\"m -rf /__mahbot_readonly_test_ws__", false),
("\"t\"ouch /__mahbot_readonly_test_ws__/x", false),
("\"c\"p /etc/passwd /__mahbot_readonly_test_ws__/out", false),
("\"g\"it commit -m x", false),
("\"m\"kdir /__mahbot_readonly_test_ws__/x", false),
("$'rm' -rf /__mahbot_readonly_test_ws__", false),
("r\\155 -rf /__mahbot_readonly_test_ws__", false),
("$(printf r)m -rf /__mahbot_readonly_test_ws__", false),
("eval '\"t\"ouch /__mahbot_readonly_test_ws__/x'", false),
("eval '\"c\"d /etc' && touch f", false),
("\"rm\" -rf /__mahbot_readonly_test_ws__", false),
("\"touch\" /__mahbot_readonly_test_ws__/x", false),
("\"cp\" /etc/passwd /__mahbot_readonly_test_ws__/out", false),
("\"mkdir\" /__mahbot_readonly_test_ws__/x", false),
("\"sed\" -i s/a/b/ /__mahbot_readonly_test_ws__/f", false),
("\"git\" commit -m x", false),
("\"git\" status", true),
("sudo \"rm\" -rf /__mahbot_readonly_test_ws__", false),
("env \"touch\" /__mahbot_readonly_test_ws__/x", false),
(
"nice \"cp\" /etc/passwd /__mahbot_readonly_test_ws__/out",
false,
),
("npx \"git\" push", false),
("\"env\" \"t\"ouch /__mahbot_readonly_test_ws__/x", false),
("\"exec\" \"r\"m -rf /__mahbot_readonly_test_ws__", false),
("\"npx\" \"g\"it push", false),
("\"env\" \"git\" push", false),
("\"nice\" \"t\"ouch /__mahbot_readonly_test_ws__/x", false),
("\"nohup\" \"t\"ouch /__mahbot_readonly_test_ws__/x", false),
("\"sudo\" \"rm\" -rf /__mahbot_readonly_test_ws__", false),
("\"env\" \"ls\" /tmp", true),
("\"env\" \"git\" status", true),
("{touch,} f", false),
("{cp,} a b", false),
("{rm,} -rf /__mahbot_readonly_test_ws__", false),
("{touch,} /tmp/ok", false),
("cd /tmp && {touch,} f", false),
("{touch,} /tmp/x && echo hi", false),
("echo {a,b}", true),
("ls {a,b}", true),
("\"ls\" -la /tmp", true),
("\"echo\" hi", true),
("sudo \"ls\" /tmp", true),
("\"c\"at /etc/passwd", false),
("cd /tmp && command \"cd\" /tmp && touch f", true),
("\"cd\" /tmp && touch f", true),
];
run_cases(&cases);
}
#[test]
fn prefix_dashdash_and_informational_options() {
let cases = [
("cd /tmp && command -- cd /etc && touch f", false),
("cd /tmp && builtin -- cd /etc && touch f", false),
("cd /tmp && command -p -- cd /etc && touch f", false),
("cd /tmp && command -- 'cd' /etc && touch f", false),
("cd /tmp && command -- pushd /etc && touch f", false),
("cd /tmp && command -- cd /tmp && touch f", true),
("cd /tmp && builtin -- cd -P /tmp && touch f", true),
("cd /tmp && command -p -- cd /tmp && touch f", true),
("cd /tmp && command -p -p cd /etc && touch f", false),
("cd /tmp && command -p -p cd /tmp && touch f", true),
("cd /tmp && command -pp cd /etc && touch f", false),
("cd /tmp && command -pp cd /tmp && touch f", true),
("cd /tmp && command -ppp cd /tmp && touch f", true),
("command -pp rm -rf /__mahbot_readonly_test_ws__", false),
("cd /tmp && command -pp -v cd /etc && touch f", true),
("cd /tmp && command -v cd /tmp && touch f", true),
("cd /tmp && command -V cd && touch f", true),
("cd /tmp && command -p -v cd /etc && touch f", true),
("cd /tmp && command -pv cd /etc && touch f", true),
("cd /tmp && command -x cd /etc && touch f", true),
("cd /tmp && builtin -p cd /tmp && touch f", true),
("time -p -p cd /tmp && touch f", false),
("cd /tmp && time -p -p cd /etc && touch f", true),
("cd /tmp && time -p -p cd /tmp && touch f", true),
("cd /tmp && time -- cd /etc && touch f", true),
("cd /tmp && time -v cd /etc && touch f", true),
("cd /tmp && time -p cd /etc && touch f", false),
];
run_cases(&cases);
}
#[test]
fn pipeline_and_background_cd_fail_closed() {
let cases = [
("cd /tmp | true && touch f", false),
("cd /tmp | cat && touch f", false),
("cd /tmp | true\n touch f", false),
("cd /tmp && ls | grep x && touch f", true),
("cd /tmp && cd /etc | true && touch f", true),
("ls | { SNAP=/tmp/x; } && touch $SNAP/f", false),
("ls | { cd /etc; } && touch f", false),
("ls | ( SNAP=/tmp/x ) && touch $SNAP/f", false),
("ls | if true; then SNAP=/tmp/x; fi && touch $SNAP/f", false),
("cd /tmp && ls | { cd /tmp; } && touch f", true),
("cd /tmp & touch f", false),
("cd /tmp & rm -rf x", false),
("cd /tmp & touch /tmp/x", true),
("cd /tmp && touch a & touch f", false),
("cd /tmp & echo done", true),
("cd /tmp && touch f", true),
("echo hi 2>&1", true),
("echo hi &> /tmp/out", true),
("echo hi >& /tmp/out", true),
("echo hi <&1", true),
("cat /tmp/x >& /tmp/out", true),
("echo a\\> & touch f", false),
("echo a\\< & touch f", false),
("echo a\\> & touch /tmp/ok", true),
("echo a\\> | rm -rf /__mahbot_readonly_test_ws__", false),
("echo a\\> | grep x && touch /tmp/ok", true),
("echo a\\> 2>&1 & touch f", false),
("echo \">\" & touch f", false),
("echo '>' & touch f", false),
("echo \">\" | rm -rf /__mahbot_readonly_test_ws__", false),
("echo '<' | rm -rf /__mahbot_readonly_test_ws__", false),
("echo \"a>b\" & touch f", false),
("echo hi \"2>&1\" & touch f", false),
("echo x > \"/tmp/out\" & touch f", false),
("echo \"a\" > /tmp/out & touch f", false),
("echo hi >| /tmp/out", true),
("cd /tmp || touch f", true),
("cd /tmp || rm -rf x", true),
("cd /tmp || true && touch f", true),
("cd /etc || true && touch f", false),
("false || rm -rf /__mahbot_readonly_test_ws__", false),
("false || touch /__mahbot_readonly_test_ws__/x", false),
("cd /tmp && (cd /etc || true) && touch f", true),
("cd /tmp/nonexistent_x || touch f", false),
];
run_cases(&cases);
}
#[test]
fn explicit_pwd_assignment_fails_closed() {
let cases = [
("export PWD=/etc && touch $PWD/f", false),
("PWD=/etc touch $PWD/f", false),
("cd /tmp && export PWD=/etc && touch $PWD/f", false),
("cd /tmp && PWD=/etc touch $PWD/f", false),
("cd /tmp && export PWD=/etc && touch /tmp/ok", true),
("cd /tmp && PWD=/tmp && touch $PWD/f", true),
("touch $PWD/f", false),
("cd /tmp && touch $PWD/out", true),
];
run_cases(&cases);
}
#[test]
fn brace_eval_body_fails_closed() {
let cases = [
("cd /tmp && { eval 'cd /etc'; } && touch f", false),
("cd /tmp && { eval \"cd /etc\"; } && touch f", false),
("cd /tmp && { eval 'cd /tmp'; } && touch f", false),
("cd /tmp && { eval 'cd /etc' ; } && rm -rf x", false),
("cd /tmp && { ls; } && touch f", true),
("{ echo hi; } && touch /tmp/x", true),
("cd /tmp && { echo cd; } && touch f", true),
("cd /tmp && { echo \"hello cd\"; } && touch f", true),
("cd /tmp && { grep cd file; } && touch f", true),
];
run_cases(&cases);
}
#[test]
fn cd_extra_operands_fail_closed() {
let cases = [
("cd /tmp extra && touch f", false),
("cd /tmp && cd /etc extra && touch f", false),
("cd /tmp && cd -P /tmp extra && touch f", false),
("cd /tmp && command cd /tmp extra && touch f", false),
("eval 'cd /tmp extra' && touch f", false),
];
run_cases(&cases);
}
#[test]
fn control_construct_bodies_and_conditions() {
let cases = [
("if true; then touch f; fi", false),
("if true; then touch /tmp/f; fi", true),
("if false; then echo hi; fi", true),
("if true; then echo hi > f; fi", false),
("if true; then echo hi > /tmp/out; fi", true),
("if cd /etc; then echo hi; fi", true),
("if true; then rm -rf f; else echo hi; fi", false),
("if false; then echo a; elif true; then touch f; fi", false),
("if false; then echo a; else touch /tmp/f; fi", true),
("if true\nthen touch f; fi", false),
("while true; do touch f; done", false),
("while true; do touch /tmp/f; done", true),
("while grep -q x file; do echo hi; done", true),
("until false; do rm -rf x; done", false),
("until false; do touch /tmp/f; done", true),
("for x in a b; do touch f; done", false),
("for x in a b; do touch /tmp/f; done", true),
("for x in /tmp/*.db; do rm \"$x\"; done", true),
(
"for db in ~/.mahbot/db/*.db; do cp \"$db\" /tmp/x/; done",
true,
),
("for x in a; do rm \"$x\"; done", false),
("for ((i=0; i>3; i++)); do echo hi; done", true),
("for ((i=0; i>3; i++)); do touch f; done", false),
("for x; do echo hi; done", true),
("for x in do; do echo hi; done", true),
("for x in a b do echo hi; done", false),
("select x in a b; do touch f; done", false),
("select x in a b; do echo hi; done", true),
("case x in a|b) touch f;; esac", false),
("case x in a|b) echo hi;; esac", true),
("case x in a) touch /tmp/f;; b) touch /tmp/g;; esac", true),
("case x in a) touch f;; esac", false),
("case x in\na) echo hi;;\nesac", true),
("case x in\nx) touch f;;\nesac", false),
("( touch f )", false),
("( touch /tmp/f )", true),
("( cd /tmp && touch f )", true),
("( cd /etc && touch f )", false),
("{ touch f; }", false),
("{ touch /tmp/f; }", true),
("cd /tmp && { rm -rf x; }", true),
("f() { touch f; }", false),
("f() { touch /tmp/f; }", true),
("function f { rm -rf x; }", false),
("function f() { touch /tmp/f; }", true),
("function f () { touch /tmp/f; }", true),
("function f() { touch f; }", false),
("f () ( touch /tmp/f )", true),
("if true; then for x in a; do touch f; done; fi", false),
("if true; then for x in a; do touch /tmp/f; done; fi", true),
("while true; do if false; then touch f; fi; done", false),
(
"if true; then while false; do cd /tmp; done; fi; touch f",
false,
),
("echo $(if true; then touch f; fi)", false),
("echo $(if true; then touch /tmp/f; fi)", true),
("echo $(for x in /tmp/*; do echo $x; done)", true),
("echo $(while true; do touch f; done)", false),
("! cd /tmp && touch f", true),
("! cd /etc && touch f", false),
("! ls && touch /tmp/x", true),
("cd /tmp & touch f", false),
("cd /tmp & touch /tmp/x", true),
("cd /tmp |& true && touch f", false),
("ls |& grep x && touch /tmp/x", true),
("if true; then cat <<EOF\nbody\nEOF\nfi", true),
("if true; then cat <<EOF > f\nbody\nEOF\nfi", false),
("while true; do cat <<EOF > /tmp/out\nbody\nEOF\ndone", true),
("while true; do echo hi", false),
("for x in a b; do echo hi", false),
("{ echo hi; ", false),
("( echo hi ", false),
("for x in $(touch f); do echo hi; done", false),
("for x in $(touch /tmp/x); do echo hi; done", true),
("for ((i=$(touch f); i<3; i++)); do echo hi; done", false),
("case $(touch f) in a) echo hi;; esac", false),
("case $(touch /tmp/x) in a) echo hi;; esac", true),
("if $(touch f); then echo hi; fi", false),
("if $(touch /tmp/x); then echo hi; fi", true),
];
run_cases(&cases);
}
#[test]
fn construct_state_does_not_leak() {
let cases = [
("if false; then cd /tmp; fi; touch f", false),
("if false; then cd /tmp; fi\ntouch f", false),
("if true; then cd /tmp; fi; touch f", false),
("while false; do cd /tmp; done; touch f", false),
("for x in a; do cd /tmp; done; touch f", false),
("case x in a) cd /tmp;; esac; touch f", false),
("( cd /tmp ); touch f", false),
("f() { cd /tmp; }; touch f", false),
("cd /tmp && if true; then touch f; fi", true),
("cd /tmp && while true; do touch f; done", true),
("cd /tmp && { touch f; }", true),
(
"SNAP=$(mktemp -d)\nif true; then touch \"$SNAP/f\"; fi",
true,
),
("if false; then cd /tmp; fi ; touch f", false),
("cd /tmp && if true; then cd /etc; fi && touch f", false),
("cd /tmp && if true; then cd /etc; fi\ntouch f", false),
("cd /tmp && if cd /etc; then :; fi && touch f", false),
(
"cd /tmp && if false; then :; elif true; then cd /etc; fi && touch f",
false,
),
(
"cd /tmp && if false; then :; else cd /etc; fi && touch f",
false,
),
("cd /tmp && for x in 1; do cd /etc; done && touch f", false),
(
"cd /tmp && while true; do cd /etc; break; done && touch f",
false,
),
(
"cd /tmp && until false; do cd /etc; break; done && touch f",
false,
),
("cd /tmp && case a in a) cd /etc;; esac && touch f", false),
(
"cd /tmp && if true; then if true; then cd /etc; fi; fi && touch f",
false,
),
("cd /tmp && if true; then echo hi; fi && touch f", true),
("cd /tmp && for x in 1; do echo hi; done && touch f", true),
("cd /tmp && (cd /etc) && touch f", true),
("cd /tmp && (cd /tmp) && touch f", true),
(
"if true; then export TMPDIR=/etc; fi; touch $TMPDIR/f",
false,
),
(
"if true; then export TMPDIR=/etc; fi\ntouch $TMPDIR/f",
false,
),
(
"cd /tmp && if true; then export TMPDIR=/etc; fi && touch $TMPDIR/f",
false,
),
(
"for x in 1; do export TMPDIR=/etc; done; touch $TMPDIR/f",
false,
),
(
"while true; do export TMPDIR=/etc; break; done; touch $TMPDIR/f",
false,
),
(
"case a in a) export TMPDIR=/etc;; esac; touch $TMPDIR/f",
false,
),
(
"if true; then export TMPDIR=/etc; else export TMPDIR=/tmp; fi; touch $TMPDIR/f",
false,
),
(
"if true; then export TMPDIR=/tmp; fi; touch $TMPDIR/f",
true,
),
("( export TMPDIR=/etc ); touch $TMPDIR/f", true),
("if false; then D=/tmp; fi; touch $D/f", false),
("if true; then D=/tmp; fi; touch $D/f", false),
("if false; then D=/tmp; fi\ntouch $D/f", false),
(
"cd /tmp && if false; then D=/tmp; fi; cd $D && touch f",
false,
),
("while false; do D=/tmp; done; touch $D/f", false),
("for x in 1; do D=/tmp; done; touch $D/f", false),
("case a in a) D=/tmp;; esac; touch $D/f", false),
(
"if true; then if false; then D=/tmp; fi; fi; touch $D/f",
false,
),
("( D=/tmp ); touch $D/f", false),
];
run_cases(&cases);
}
#[test]
fn quoted_and_prefixed_export_bindings() {
let cases = [
(
"\"export\" TMPDIR=/__mahbot_readonly_test_ws__; touch $TMPDIR/f",
false,
),
("'export' TMPDIR=/etc; touch $TMPDIR/f", false),
(
"export \"TMPDIR=/__mahbot_readonly_test_ws__\"; touch $TMPDIR/f",
false,
),
("export TMPDIR=\"/etc\"; touch $TMPDIR/f", false),
("export \"TMPDIR\"=/etc; touch $TMPDIR/f", false),
("command export TMPDIR=/etc; touch $TMPDIR/f", false),
("builtin export TMPDIR=/etc; touch $TMPDIR/f", false),
("time export TMPDIR=/etc; touch $TMPDIR/f", false),
("command -p export TMPDIR=/etc; touch $TMPDIR/f", false),
("builtin -- export TMPDIR=/etc; touch $TMPDIR/f", false),
("command builtin export TMPDIR=/etc; touch $TMPDIR/f", false),
("time -p export TMPDIR=/etc; touch $TMPDIR/f", false),
("env export TMPDIR=/etc; touch $TMPDIR/f", true),
("sudo export TMPDIR=/etc; touch $TMPDIR/f", true),
("\"export\" TMPDIR=/tmp; touch $TMPDIR/f", true),
("export \"TMPDIR=/tmp\"; touch $TMPDIR/f", true),
("command export TMPDIR=/tmp; touch $TMPDIR/f", true),
("\"export\" SNAP=/tmp/x; touch \"$SNAP/f\"", true),
("command -v export; touch $TMPDIR/f", true),
("export TMPDIR=/etc; touch $TMPDIR/f", false),
("export TMPDIR=/tmp; touch $TMPDIR/f", true),
];
run_cases(&cases);
}
#[test]
fn pinned_temp_idiot_shapes_allowed() {
let cases = [
("cd /tmp && { rm -rf x; }", true),
("cd /tmp && { rm -rf x; } && touch /tmp/ok", true),
("SNAP=$(mktemp -d) && { cp /etc/passwd \"$SNAP/\"; }", true),
(
"SNAP=$(mktemp -d)\nmkdir -p \"$SNAP/d\"\n{ cp /etc/passwd \"$SNAP/d/\"; }",
true,
),
("cd /tmp && { rm -rf /__mahbot_readonly_test_ws__; }", false),
(
"SNAP=$(mktemp -d) && { cp /etc/passwd /__mahbot_readonly_test_ws__/; }",
false,
),
];
run_cases(&cases);
}
#[test]
fn time_prefixed_non_simple_operands() {
let cases = [
("time ! rm -rf ./x", false),
("time ! touch f", false),
("time -p ! rm -rf ./x", false),
("time ! ! rm -rf ./x", false),
("VAR=val time ! rm -rf ./x", false),
("time ! echo hi", true),
("time ! cd /tmp && cat > f", true),
("time ( rm -rf ./x )", false),
("time ( git commit -m test )", false),
("time ( ( rm -rf ./x ) )", false),
("time ( rm -rf ./x ) | cat", false),
("time ( rm -rf ./x ) <<EOF\nbody\nEOF", false),
("time (( i = $(rm -rf ./x) ))", false),
("time ( echo hi )", true),
("time ( cd /tmp && touch f )", true),
("time -p ( rm f )", false),
("time ! ( rm f )", false),
("time ! ( echo hi )", false),
("time ! { rm f; }", false),
("time ( rm f ) extra", false),
("command { rm -rf ./x; }", false),
("command ( rm -rf ./x )", false),
("builtin ( rm -rf ./x )", false),
(
"\"time\" TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
true,
),
(
"! time TMPDIR=/etc cat <<EOF\n$(touch $TMPDIR/x)\nEOF",
true,
),
("TMPDIR=/etc time cat <<EOF\n$(touch $TMPDIR/x)\nEOF", false),
("time FOO=/etc ! touch f <<EOF\n$(touch $FOO/x)\nEOF", false),
("time { rm -rf ./x; }", false),
("time { cat > /tmp/out; }", false),
];
run_cases(&cases);
}
}