Skip to content

fix(execpolicy): fail closed on unresolved shell parsing and tighten prefix matching - #6675

Merged
2 commits merged into
mainfrom
fix/shell-policy-parsing
Sep 28, 2026
Merged

2 commits merged into
mainfrom
fix/shell-policy-parsing

Conversation

@Hmbown

@Hmbown Hmbown commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Summary

This hardens shell command policy in crates/execpolicy, plus the shell-policy entry points in the TUI.

  • Words known only at run time. The shell expander now reports when a command word is resolved only when the command runs. That covers a variable, a substitution, a glob or brace list, ANSI-C escapes, a shell reading its script from a pipe, here-string or process substitution, and a replacement string from xargs -I/find -exec used as the command or as shell code. It also reports when the text does not parse cleanly: an unterminated quote, substitution or heredoc, or a case inside $( … ). It reports when a parse budget runs out (output lines, wrapper states) too. While any deny rule is configured, such a command is refused. Under OnRequest/UnlessTrusted it asks instead. OnFailure also serves auto-approving sessions, so it keeps refusing. With no deny rules, behaviour is unchanged.

  • Reserved words and bodies. Commands are scanned like any other command when they:

    • follow if, then, while, until, do, ! and similar words
    • sit in a function f { ... } body or a find -exec payload
    • sit in a (( … )) body, where << is a shift and not a heredoc

    Bodies of $( … ), <( … ) and ${ … } are read with quotes taken into account.

  • Wrapper option grammar. Wrappers are unwrapped by a per-wrapper option table. The table lists value-taking options and switches, and any other option is read both ways. BSD and macOS options are covered (env -P, xargs -J, chroot -u, doas -a). Recognized wrappers now include caffeinate, arch, sandbox-exec, nsenter, unshare, runuser, flock, watch, wsl, noglob and nocorrect, and .exe spellings fold. Code passed as a string is checked as a command line: trap, su -c, flock -c, script -c, watch, cmd /c and PowerShell -Command. -EncodedCommand is treated as unresolved. command -v NAME is still a lookup.

  • Prefix allow rules. A trusted or allow prefix such as git status must appear at the front of the command. So it no longer covers git -c key=value status or git --exec-path=... status. Allow and trusted prefixes also stop covering nested code and run-time command words. Typed deny rules match a path-qualified command word (/bin/rm).

  • Session grants. An "approve for session" grant for a shell command covers only flag variants of the command as written. A command with options before the subcommand, a chain, or nested or unresolved code gets an exact key.

  • Task shell tools. task_shell_start and tasks gate_run now go through the shell deny and ask rules. A shell allow rule does not waive their own approval.

  • Read-only classifiers. Commands with ( or ) no longer count as parallel read-only. The sub-agent read-only shell rejects a word that starts with an unquoted *.

Known limits are documented in shell_expand.rs:

  • Script files are opaque.
  • Arithmetic contexts that evaluate variable values are not followed.
  • cmd/PowerShell payloads are scanned with the POSIX grammar.

Testing

All cargo runs used CARGO_BUILD_JOBS=4.

  • cargo test -p codewhale-execpolicy:
    • unit: 217 passed; 0 failed
    • shell_parse_policy: 7 passed; 0 failed
    • other targets: 1 passed, 5 passed, 1 passed
  • Against the previous source, shell_parse_policy gives test result: FAILED. 3 passed; 4 failed, and every new case is listed as not denied.
  • cargo test -p codewhale-tui --lib -- approval_cache task_shell_tools_answer grouping_key: 26 passed; 0 failed. With the production change reverted, the two new tests fail: 0 passed; 2 failed.
  • cargo test -p codewhale-tui --lib -- readonly read_only: 138 passed; 0 failed.
  • cargo clippy -p codewhale-execpolicy --tests: clean.

🤖 Generated with Claude Code

…efix matching

Cause: deny rules were matched against the command text as written, so a
command word the shell only resolves when it runs (a variable, substitution,
glob or brace list, ANSI-C escapes, a shell reading its script from a pipe or
here-string) never met the rule. Commands after reserved words (`if`, `then`,
`while`, `!`), `function f { ... }` bodies and `find -exec` payloads were not
scanned as commands. Wrapper words were stripped without their option grammar,
so `chroot DIR cmd`, `sudo --user NAME cmd` and `timeout -s SIG N cmd` hid
`cmd`. A trusted prefix such as `git status` also covered options placed
before the subcommand (`git -c key=value status`, `git --exec-path=... status`),
and the parallel read-only classifier accepted parentheses.

Fix: the shell expander now reports whether a command word is dynamic and
whether code runs nested inside another command. While any deny rule is
configured, a dynamic command word is refused; allow and trusted prefixes no
longer cover dynamic or nested code. Reserved words are dropped before the
command word, `{`/`}` split commands, `find -exec` payloads are recorded, and
wrappers are unwrapped by a per-wrapper option table (ambiguous options are
read both ways). Prefix allow matching requires the matched words to be
spelled at the front of the command. Typed deny rules also match a
path-qualified command word. `command -v NAME` is treated as a lookup, not a
run. Parentheses disqualify a command from parallel read-only.

Tests: new crates/execpolicy/tests/shell_parse_policy.rs fails on the pre-fix
source (test result: FAILED. 0 passed; 5 failed) and passes with it.
cargo test -p codewhale-execpolicy: 217 passed; 0 failed (unit), 1 passed,
5 passed (redirection_policy), 5 passed (shell_parse_policy), 1 passed (doc).
cargo clippy -p codewhale-execpolicy --all-targets -D warnings: clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 27, 2026 06:11
@Hmbown Hmbown added this to the v0.10.1 milestone Sep 27, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-27T06:22:14.890193Z b7abbe2 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: b7abbe26c8

ℹ️ 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 +914 to +916
if matches!(word.as_str(), "{" | "}") {
push_command(commands, &mut piece);
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve quoting before treating braces as command boundaries

When a literal brace is quoted or escaped, it is an ordinary argument rather than shell grouping, but Line no longer retains that distinction and this branch splits every token equal to { or }. With an unrelated rm deny rule, a valid data-producing command such as printf '%s\n' '{' rm is therefore expanded into a separate rm target and forbidden even though the shell only passes rm to printf; only unquoted reserved braces should create a boundary.

Useful? React with 👍 / 👎.

Comment on lines +766 to +769
if tokens[..=head]
.iter()
.zip(&line.dynamic)
.any(|(token, dynamic)| *dynamic && !is_env_assignment(token))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Limit dynamic wrapper checks to command-bearing words

When a wrapper has a quoted dynamic option value, this scans every token through the eventual command head and treats the option value as an unresolved command word. For example, sudo -u "$USER" ls has a statically known ls command and the quotes guarantee one username operand, yet any unrelated deny prefix such as rm now makes expansion.dynamic true and causes the engine to forbid it. Track dynamic wrapper names/command positions separately from quoted option operands instead of rejecting the whole prefix slice.

Useful? React with 👍 / 👎.

Comment on lines +421 to +422
if depth > 0 && !input.trim().is_empty() {
self.nested = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not mark plain parameter expansions as nested code

Every recursive parse is marked as executable nested code, but ${...} bodies are also recursively parsed even when they contain only a parameter name. Consequently a trusted command such as ls "${DIR}" sets nested = true and unexpectedly requires approval, while the equivalent ls "$DIR" remains trusted; only actual command/process substitutions found inside the parameter expression should set this flag.

Useful? React with 👍 / 👎.

Comment on lines +1045 to +1047
while let Some((index, state)) = stack.pop() {
if index >= tokens.len() || seen.len() >= MAX_HEAD_STATES || !seen.insert((index, state)) {
continue;

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 Fail closed when the wrapper-state budget is exhausted

The state limit silently stops traversal instead of marking the expansion unresolved, so a sufficiently long wrapper chain bypasses a literal deny rule. Each wrapper consumes both a Command and a Wrapper state; therefore 32 repetitions in env env ... env rm x exhaust the 64-state budget before rm becomes a head, leaving only env-prefixed targets that do not match a deny prefix of rm, even though the shell eventually executes it. Budget exhaustion must set dynamic or emit a failure rather than continuing with incomplete deny targets.

AGENTS.md reference: AGENTS.md:L28-L29

Useful? React with 👍 / 👎.

Comment on lines +540 to +544
|| rulesets.iter().any(|ruleset| {
ruleset
.ask_rules
.iter()
.any(|rule| rule.action == PermissionAction::Deny && rule.tool == tool)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Respect typed deny scope before enabling fail-closed mode

This treats every typed deny for the same tool as active without applying the workspace and path predicates used by matching_ask_rule. For example, a deny rule for command rm scoped to /repo/a causes a genuinely dynamic but otherwise permitted command such as v=ls; $v in /repo/b to be forbidden, even though that deny rule cannot match in the current workspace. Filter self-contained scope predicates before deciding that a relevant deny rule is configured.

Useful? React with 👍 / 👎.

Comment on lines +1197 to +1200
match script {
Some(index) if !from_stdin => ShellInput::Script(index),
_ => ShellInput::Stdin,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recognize shell options that exit without reading stdin

A shell invocation with no script operand is always classified as reading a runtime script from stdin, including exit-only invocations such as bash --version and bash --help. With any deny rule configured, these harmless commands now become dynamically unresolved and are forbidden. bash --help lists both as GNU long options, and bash --version exits successfully without consuming stdin, so terminal long options need a non-stdin outcome.

Useful? React with 👍 / 👎.

Comment on lines +203 to +204
"eof",
"replace",

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 Keep optional xargs arguments from swallowing the command

The wrapper table marks xargs --replace and --eof as consuming the following word, so xargs --replace rm -f file skips rm as an option value and never emits it as a command head, allowing a deny prefix of rm to be bypassed. xargs --help documents these as --replace[=R] and --eof[=END]: when no = value is present, the next word is the command, not the option argument. Model these optional arguments separately so the deny scan reaches rm.

AGENTS.md reference: AGENTS.md:L28-L29

Useful? React with 👍 / 👎.

Cause: the expander could stop looking without saying so. Output past 256
command lines and wrapper walks past the state budget were dropped silently.
Wrapper short options missing from the tables were assumed to take no value
(`env -P DIR cmd`, `xargs -J % cmd`, `chroot -u USER`). `$( … )` bodies were
closed by paren counting that ignored quotes and `case` patterns. `<<` inside
`(( … ))` was read as a heredoc that swallowed the following lines. `xargs`
and `find -exec` replacement strings were trusted as static command words
and shell code. Several wrappers and code-as-string commands were not known
(`trap`, `su -c`, `watch`, `cmd /c`, PowerShell `-Command`, `caffeinate`,
`arch`, `.exe` spellings). Outside the expander: `task_shell_start` and task
gate commands skipped the shell deny rules, session grants keyed on a
flag-stripped prefix covered interposed options, chains and nested code, and
the sub-agent read-only shell admitted a leading unquoted `*` whose matches
can read as options.

Fix: a truncated parse, an unterminated quote/substitution/heredoc, or a
`case` inside `$( … )` marks the command unresolved, so it fails closed while
deny rules exist. Substitution bodies are read quote-aware. Wrapper tables
list known switches, and anything else is read both ways; the wrapper's flags
are also kept in a target so deny matching can skip them. `(( … ))` bodies
are scanned on their own. Replacement strings mark their words as run-time
values. New wrappers and payload handlers are added, and names fold `.exe`.
Unresolved commands prompt under OnRequest/UnlessTrusted and stay refused in
other modes (OnFailure also serves auto-approving sessions). Task shell tools
route through the shell policy (an allow rule does not waive their own
approval). The session grouping key falls back to an exact key for
interposed options, chains, nested or unresolved code. Leading unquoted `*`
is rejected on the sub-agent read-only surface.

Tests (CARGO_BUILD_JOBS=4):
- cargo test -p codewhale-execpolicy: lib 217 passed; 0 failed,
  shell_parse_policy 7 passed; 0 failed (plus 1, 5, 1 passed in other
  targets). The new cases on the previous source:
  test result: FAILED. 3 passed; 4 failed (every new case listed not denied).
- cargo test -p codewhale-tui --lib -- approval_cache task_shell_tools_answer
  grouping_key: 26 passed; 0 failed. Without the fix: 0 passed; 2 failed.
- cargo test -p codewhale-tui --lib -- readonly read_only: 138 passed; 0 failed.
- cargo clippy -p codewhale-execpolicy --tests: clean.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Hmbown Hmbown changed the title fix(execpolicy): fail closed on run-time command words and tighten prefix matching fix(execpolicy): fail closed on unresolved shell parsing and tighten prefix matching Sep 27, 2026
Hmbown pushed a commit that referenced this pull request Sep 27, 2026
crates/execpolicy/src/command_safety.rs: #6637 (already merged) replaced
the agent read-only classifier with a quote-aware lexer
(lex_readonly_command / agent_readonly_verdict) that admits chains and
three redirects; #6675 hardened the old classifier by rejecting a word
that starts with an unquoted `*` (has_leading_glob), because its matches
can begin with `-` and become options after the option allowlist ran.
Kept #6637's lexer as the single read-only authority and ported #6675's
rule into it: an unquoted `*` whose word has no literal prefix yet (empty
quotes count as no prefix, so `''*` is refused as #6675's test requires)
is an `operator` refusal. has_leading_glob had no remaining caller and is
removed. Doc bullet takes #6675's `*` wording within #6637's list.
Test updates: `echo *` is now refused by the glob rule before the echo
literal rule (row updated, `echo a*` added for the literal rule); a new
unit test covers the glob rule on the lexer incl. chains.

crates/tui/src/tools/approval_cache.rs: #6670 (already merged) replaced
command_prefix with shell_command_grant_scope (family key only for simple,
non-wrapper, inert-option, known families; else shell:cmd:<command>);
#6675 edited the old command_prefix to fall back to an exact key for
dynamic/nested/multi-command expansions or when the canonical prefix is
not leading. Folded #6675's conditions into #6670's family branch, so
either rule failing gives the full-command key; kept #6670's key format.

Verified: cargo test -p codewhale-execpolicy: 221+1+5+7+1 passed, 0 failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Hmbown pushed a commit that referenced this pull request Sep 27, 2026
Each fix below reconciles two PRs that merged textually but not
semantically.

- snapshot/repo.rs, receipts.rs: #6645/#6682 added
  SnapshotRepo::changed_paths_between(from, to) -> Vec<PathBuf> (undo,
  turn artifacts) and #6591 added a different
  changed_paths_between(from, to, limit) -> (Vec<SnapshotPathChange>, bool)
  (receipts). Duplicate definition; #6591's is renamed
  path_changes_between and its one caller (receipts) updated.
- core/engine/turn_loop.rs: #6673's repl-fence approval match did not
  cover #6601's ApprovalResult::TimedOut. A timed-out card now refunds the
  tool-call budget slot and reports a timeout, like direct and code-mode
  calls; the audit line records "timeout" instead of "denied".
- runtime_api/sessions.rs: #6640's session-owner 409 predates #6645's
  ApiError.code field; code: None.
- tools/verifier.rs: #6671's env-scrub test called run_gate(gate) without
  the session_id argument run_gate takes on main (#6508).
- skills/install.rs + integration harness: #6679 made install.rs read
  downloads through crate::utils::read_response_body_capped, but the
  integration harness #[path]-includes install.rs and has no utils
  module, so the integration test target did not compile (also on the
  #6679 branch). The capped reader moves to utils/response_body.rs
  (re-exported from utils, unchanged API) and the harness includes just
  that file as crate::utils.
- Test files where an add-only conflict was auto-resolved by
  concatenating both sides lost the shared closing lines of the first
  test: commands/groups/debug/tests.rs (#6682 + #6591), tui/ui/tests.rs
  (#6635 + main), tools/shell/tests.rs (#6674 + #6679), and
  runtime_api/tests.rs (#6645 merge in round 1). Restored the missing
  `}` / `);` so each test is whole again; no assertions were dropped.
- tools/shell/tests.rs: #6637's executor test expected `sort * | cat`
  to be admitted and then fail at run time; with #6675's rule ported into
  the #6637 lexer (see the #6675 merge), a word-leading unquoted `*` is
  refused before anything runs. The test now asserts that refusal and
  still checks the sentinel and option-named files are untouched.
- core/engine/tests.rs -> tui/history/tests.rs: #6601's engine test
  asserted crate::tui::history on the trust warning, raising the
  runtime->UI test reference ratchet 40 -> 41 (check-command-crate-
  boundaries FAIL). That assertion moved to a tui::history test on
  workspace_trust_runtime_message, so the ratchet is back at 40.
- scripts/check-blocking-calls-budget.json: runtime_api/git.rs 5 -> 6.
  #6648 justified this budget in its PR body (working-tree fingerprint
  reads in sync fns reached only from spawn_blocking); its own branch
  already has six such sites (the File::open used for hashing), so the
  recorded 5 was stale. subagent/worktree.rs tightened 7 -> 6.

Checks: cargo check --workspace --tests clean (no warnings);
cargo test -p codewhale-execpolicy 221+1+5+7+1 passed;
cargo test -p codewhale-tui --test integration 187 passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Hmbown pushed a commit that referenced this pull request Sep 27, 2026
Codex (read-only) reviewed every hand-resolved merge of this round. Five
findings, all verified in the code and fixed:

- tools/shell.rs readonly_git_dirs (#6637 x #6679, silent merge): the
  git-filter hardening found git segments by splitting on `|` only, while
  #6637's classifier and executor also run `&&`, `||` and `;` chains. In
  `pwd && git diff` no override was installed, so a repository clean
  filter could run. It now splits with agent_readonly_verdict's segments
  (falling back to `|` only if the verdict fails); test added.
- compaction.rs (#6680 merge): starts_with on the bare legacy marker missed
  the real pre-v0.9.6 checkpoint text, which opened with
  "## 📋 Conversation Summary (Auto-Generated)", optionally after the
  "## Pinned Facts (User Anchors)" section. Those openings are recognised
  again (so restore replaces them instead of stacking), while a message
  that only quotes the marker mid-text still is not; test added.
- core/engine/turn_loop.rs (#6673 x #6601): the inline REPL fence refunded
  the tool-call budget only on timeout; Denied, RetryWithPolicy and
  approval errors kept the slot although nothing ran. Every refusal now
  refunds, as direct and code-mode calls do.
- command_safety.rs (#6675 port): the leading-glob rule treated any
  quote-only raw prefix as empty, so `cat '"'*` (literal `"` prefix) was
  refused. It now decodes the prefix with shlex and refuses only when the
  decoded prefix is empty (`''*` still refused); tests added.
- tools/git.rs (#6671 x #6679, silent merge): two hardened git runners
  (run_git_command via read_only_git_command, and run_git_review_command)
  both wrapped Git::review_command with different NotFound handling.
  run_git_review_command is removed and its callers use run_git_command,
  keeping their REVIEW_DIFF_ARGS.

Checks: cargo check --workspace --tests clean; cargo test -p
codewhale-execpolicy 221+1+5+7+1 passed; cargo test -p codewhale-tui
--lib -- compaction tools::shell tools::git turn_loop repl approval
readonly checkpoint: 1133 passed, 1 failed
(approval::tests::required_tool_execution_uses_typed_host_decisions_not_approval_claims,
a 5s event deadline under load; core::engine::approval:: rerun twice: 15
passed, 0 failed). Ratchets: boundaries, blocking budget, dead-code
budget, module graph, cargo fmt --check all pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Hmbown Hmbown closed this pull request by merging all changes into main in 0bfe04e Sep 28, 2026
@Hmbown
Hmbown deleted the fix/shell-policy-parsing branch September 28, 2026 08:40
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.

2 participants