Skip to content

fix(bash): classify commands by their arguments, not their name - #495

Merged
emal-avala merged 1 commit into
mainfrom
fix/plan-mode-readonly-escape
Jul 25, 2026
Merged

fix(bash): classify commands by their arguments, not their name#495
emal-avala merged 1 commit into
mainfrom
fix/plan-mode-readonly-escape

Conversation

@emal-avala

Copy link
Copy Markdown
Member

Summary

The shell classifier decided a command's effects from the binary name alone. Any invocation of a name on the reader list counted as read-only regardless of what it actually did. This has one live consequence and one latent one — I want to be precise about which is which.

Live today: the dangerous-command guard is bypassable

BashTool::validate_inputcheck_parsed_security matches its blocklist against command names, so wrapping or quoting hides the real command. Driving the real validation path:

rm -rf /tmp/x        -> REJECTED
env rm -rf /tmp/x    -> REJECTED   (caught by a raw "rm -rf" substring check, not the AST check)
'rm' -rf /tmp/x      -> ACCEPTED   ← quoting defeats it
env rm /tmp/x        -> ACCEPTED   ← wrapping defeats it
nohup rm /tmp/x      -> ACCEPTED   ← wrapping defeats it

This applies in every mode. The substring fallback is what made it look healthy.

Latent: the read-only classification was wrong

git push, git commit, git reset --hard, git clean -fd, env rm -rf /, awk 'BEGIN{system("…")}', sort -o /tmp/pwned, less (!cmd spawns a shell), ip link set eth0 down and dig all classified as read-only.

Nothing shipped consumed that yet — validate_read_only and sandbox_decision::decide had no production callers, and plan mode's actual gate blocks Bash wholesale at the executor. So this was not an exploitable plan-mode escape. But Auto mode (#494) consumes the classifier now, and plan mode is supposed to use this check, so it was a loaded gun.

What changed

Classification is argument-aware for the names where arguments decide:

binary rule
git subcommand allowlist; push/pull/fetch/clone → Mutating+Network; unsafe globals (-c, -C, --git-dir, --exec-path) and program-invoking options (--ext-diff, --pager, --textconv, --upload-pack, -O) → Mutating; branch/tag read-only only with pure listing flags
env parses its own options, skips assignments, recurses into the wrapped command (depth-capped)
awk/gawk/mawk system(, close(, print >, |getline, -f progfile → Mutating
less/more/pg always Mutating (shell escapes)
find -exec, -execdir, -ok, -delete, -fprintf, -fls → Mutating
sort -o/--output → Mutating
uniq, hostname a positional argument means write → Mutating
ip, ifconfig configure verbs → Mutating (fail closed when unclear)
dig/host/nslookup Network
date -s, ss -K, rg --pre, top (non-batch) Mutating

Names and options are unquoted before matching, so -'delete' and -dele\te no longer slip past a denylist. Anything undecidable classifies as Mutating.

Also:

  • Redirection detection moved into classify — the sandbox decision helper previously returned "run freely" for echo evil > ~/.bashrc.
  • Plan mode now enforces the read-only profile on the live Bash path, turning a check nothing called into real enforcement. ⚠️ This hunk is a behaviour change and is self-contained — flag it if you'd rather land the classifier fix alone.
  • Auto mode's per-segment double-check now classifies the invocation, not the name. That reconciliation is strictly stronger: a name lookup cleared git status and git push identically; the invocation check separates them.

Verification

  • Exploit table: every command above is refused; ~40 ordinary invocations still allowed (git status/diff/log/show/blame, find . -name '*.rs', awk '{print $1}', sort f, du -sh * | sort -h, …) — a fix that made plan mode safe but useless would be its own failure.
  • Quote/backslash evasion variants tested for multiple denylisted options.
  • One existing test documented the bug (test_detect_quoted_dangerous_command asserted only that parsing "at least works"); it now asserts the violation is reported. No test was weakened.
  • clippy --all-targets -- -D warnings and fmt --check clean. Full suite green except the 3 pre-existing bwrap_* tests that fail on this host with setting up uid map: Permission denied.

Not fixed, flagging

RIPGREP_CONFIG_PATH can inject --pre into rg from a config file; env-var-driven config for other tools has the same shape. Out of scope for a classifier — worth its own issue.

The shell classifier decided a command's effects from the binary name alone,
so any invocation of a name on the reader list was treated as read-only
regardless of what it actually did.

Two consequences, one live and one latent.

Live: the dangerous-command guard on the Bash tool matched its blocklist
against command names, so wrapping or quoting hid the real command. `'rm'
-rf /`, `env rm file` and `nohup rm file` were all accepted, in every mode.
Only a separate raw substring match on "rm -rf" caught the `-rf` spellings,
which is why this looked like it worked.

Latent: `git push`, `git reset --hard`, `env rm -rf /`, `awk
'BEGIN{system(...)}'`, `sort -o`, `less` (shell escape), `ip link set` and
`dig` all classified as read-only. Nothing shipped consumed that yet —
`validate_read_only` and the sandbox decision helper had no production
callers — but Auto mode now does, and plan mode is meant to.

Classification is now argument-aware for the names where arguments decide:
git (subcommand allowlist, global-option and program-invoking-option
refusal), env (recurses into the wrapped command), awk (system/redirect/
getline), find (-exec/-delete/…), sort (-o), uniq and hostname (positional
means write), ip and ifconfig (configure verbs), date (-s), ss (-K), rg
(--pre), and the pagers (always mutating — they spawn shells). Names are
unquoted before matching, so `-'delete'` and `-dele\te` no longer slip past
an option denylist. Anything undecidable classifies as mutating.

Redirection detection moves into the classifier, so the sandbox decision
helper is covered too — it previously returned "run freely" for
`echo evil > ~/.bashrc`.

Plan mode now enforces the read-only profile on the live Bash path rather
than relying on a check that nothing called.

Auto mode's per-segment double-check now classifies the invocation instead
of the binary name, which is strictly stronger: it distinguishes `git
status` from `git push` where a name lookup cleared both.
@emal-avala
emal-avala merged commit b13f8e8 into main Jul 25, 2026
13 of 14 checks passed
@emal-avala
emal-avala deleted the fix/plan-mode-readonly-escape branch July 25, 2026 21:14
@emal-avala emal-avala mentioned this pull request Jul 25, 2026
8 tasks

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66b1083778

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +205 to +207
fn args_contain(args: &[String], names: &[&str]) -> bool {
args.iter().any(|a| names.contains(&opt_name(a)))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recognize attached git pager arguments

When Auto or plan mode evaluates git grep -O/tmp/pager pattern, opt_name leaves the attached short-option token intact, so it does not match -O and the invocation is classified read-only; Auto's secondary option check has the same exact-or-= limitation. The local git grep -h describes -O as opening matching files in a pager, and an execution probe confirmed this attached form runs the supplied executable, allowing arbitrary writes without a prompt in Auto and arbitrary execution in plan mode. Handle attached values for short program-invoking options rather than matching only the standalone spelling.

AGENTS.md reference: AGENTS.md:L93-L98

Useful? React with 👍 / 👎.

Comment on lines +633 to +637
if let Some(&next) = chars.peek()
&& next == '&'
{
// `>&` (file descriptor duplication, e.g. `2>&1`).
// Not a write to a path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat >&word as a file write

When Bash is reachable in plan mode, echo owned >& plan-output creates or truncates plan-output, but this branch skips every >& sequence as though it were an FD duplication. Only forms such as 2>&1 duplicate descriptors; Bash also treats >&word as redirecting stdout and stderr to a file, so this command retains only ReadOnly effects and passes the newly live plan-mode gate. Check whether the operand is an FD before suppressing the write effect.

AGENTS.md reference: AGENTS.md:L93-L98

Useful? React with 👍 / 👎.

Comment on lines +261 to +263
let idx = tokens[1..]
.iter()
.position(|tok| !tok.starts_with('-') && !is_env_assignment(tok))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Skip wrapper option operands before unwrapping

For wrappers with separated option operands, this selects the operand rather than the wrapped command: env -u HOME rm /tmp/victim resolves to HOME. env --help confirms that -u consumes a variable name, so the added AST guard never adds rm; the older raw checks also miss a non-recursive rm whose pipeline head is env, allowing the deletion through the destructive-command validation. Parse each supported wrapper's options and their operands before identifying the executable.

AGENTS.md reference: AGENTS.md:L100-L102

Useful? React with 👍 / 👎.

emal-avala added a commit that referenced this pull request Jul 26, 2026
`find_destructive` matched its patterns against the raw command text, so
re-spelling a command without changing what it runs walked past it.
Verified end to end through `BashTool::validate_input`:

  chmod 777 /etc          -> rejected
  ch\\mod 777 /etc         -> ACCEPTED
  git push --force        -> rejected
  'git' push --force      -> ACCEPTED

Each invocation is now also scanned in normalized form — its tokens with
shell quoting and backslash escapes removed, joined by single spaces. The
pipeline-head check unquotes and de-paths too, so `/bin/rm` and `'rm'`
are both recognised as `rm`. Both scans are additive: nothing the raw
scan caught stops being caught.

This is the same evasion class fixed in #495 for the name-based
classifier. That fix unquoted at comparison time; this scan is a
substring match over raw text and was never covered by it.

Also pins a pre-existing limitation that this change does not address:
the scan cannot distinguish a command from a string that merely mentions
one, so `grep -r 'rm -rf' docs/` is refused. That is over-blocking, and
separating arguments from commands is a design change of its own — the
test documents the behaviour so a later fix has something to flip.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant