Skip to content

fix(gates): a wrapper written as a path is the same wrapper (GH #320) - #342

Merged
wasikarn merged 2 commits into
developfrom
claude/gate-320-wrapper-path
Oct 2, 2026
Merged

wasikarn merged 2 commits into
developfrom
claude/gate-320-wrapper-path

Conversation

@wasikarn

@wasikarn wasikarn commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Closes #320.

What

A wrapper written as a path (/usr/bin/env git stash, /usr/bin/nice claude -p x) runs the same program as the bare word, and both gates allowed it.

  • subagent-git-guard.py: every wrapper word takes an optional directory prefix (_PATH), in the walk, in the walk's negative lookahead, in the lead-chain lookahead (_LEAD_WRAPPER_ALT) in its command/exec step (_LEAD_STEP), and in the chain's command/exec (_SHELL_PASS, found by the fuzz: eval /usr/bin/command git stash). rtk takes it too (_RTK_PREFIX, so /opt/homebrew/bin/rtk proxy git stash denies; _CHAIN_RUN_RE charges it the same way). eval and builtin are shell builtins with no path form and are left alone.
  • irrecoverable.py: its rule loop already compares basenames (/usr/bin/env git checkout . was denied). Only the nested-spawn anchor's _WRAPPER_ALT matched the bare word; it takes the same prefix.

Deviation from the issue

The issue suggested (?:\S*/)?, the git word's prefix. I measured that and it made the guard exponential. A token could then be read two ways (a -flag or a path command, a redirection or a path wrapper), so the chain loops tried every split of a run: eval command + -/command x 40 + ls, rtk + -x/rtk x 40 + ls and 2>/x/env x 3000 + ls each ran past 15 s, and a timed-out hook allows. The prefix used here is one plain word that never starts with - and holds no =, < or > ((?:(?!-)[^\s;&|()<>=]*/)?). Each token then has only one reading. All three shapes take 0.03-0.08 s, and each is a timing row in the test.

Tests

  • tests/hooks/test-subagent-git-guard.sh: 655 pass (58 new). The drift loop now also checks /usr/bin/<word> git stash for every PREFIX_WRAPPERS word. Section 25 has 25 deny rows and 8 allow controls, each checked against sh, bash 3.2, bash 5, dash, zsh and ksh with a logging git stub. It also has 11 timing rows, including the three ambiguity shapes above.
  • tests/hooks/test-gates.sh: 1024 pass (10 new): 7 spawn denies behind a path wrapper and 3 controls.
  • Mutation check, 9 of 9 killed. Each mutant drops the prefix from one piece (walk, lead lookahead, lead step, rtk, chain command/exec, spawn anchor) or widens it (\S*/, a leading - allowed, </> allowed). The widening mutants are killed by the timing rows.
  • harness-audit: 0 CRIT. run-gauntlet.sh: all layers pass.

Differential fuzz (real shells as the oracle)

My own generator (4000 cases): bare, path and #317-escaped spellings of every wrapper, with flags, assignments, chain words (eval, builtin, rtk proxy, eval command), decoy tokens, separators, brace/paren groups, $(...), bash -c and loops. The targets are git (stash/reset/clean plus read-only controls) and claude. The oracle runs each case in /bin/sh, bash 3.2, bash 5.3, dash, zsh and ksh with logging stubs, and any shell running a target makes the case deny-worthy.

  • develop denies and branch allows: 0. Exits other than 0/2: 0. Worst time 0.11 s on both.
  • False-allows: 678 on develop, 34 on the branch. None of the 34 is in scope here:
  • False-denies: 265 on develop, 486 on the branch (221 new). The safe direction; none blocks:
    • 83 have a decoy token that is not a real wrapper (x/env, /usr/bin/envy) before a real one.
    • 41 have builtin before a path (builtin rejects a path).
    • 26 are xargs rows, where BSD xargs runs nothing on empty input (an oracle gap).
    • 27 are rtk proxy -v or a nested rtk, where rtk itself fails.
    • 12 are exec rows.
    • The rest are mostly my sudo stub misreading -n as taking a value (an oracle-stub artifact), plus a glued x=$(cat /usr/bin/time -p claude ...). That last one is develop's existing over-read of an assignment value, which now applies to a path too.

Transcript replay

111,443 distinct Bash commands from local transcripts, run as subagent payloads through both gates on develop and on the branch:

/usr/bin/env git stash and /usr/bin/env claude -p x ran the target while both
gates matched only the bare wrapper word. Every wrapper word in the guard (walk,
lead lookahead, lead step, chain command/exec) and rtk, plus irrecoverable.py's
spawn anchor, now take an optional directory prefix. The prefix never starts
with - and holds no =, < or >, so a token has one reading; the git word's \S*/
made the chain loops exponential (eval command + -/command x40 ran past 15 s).
@wasikarn
wasikarn merged commit 5498dfa into develop Oct 2, 2026
3 checks passed
@wasikarn
wasikarn deleted the claude/gate-320-wrapper-path branch October 2, 2026 05:03
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.

gate: subagent guard does not see a wrapper written as a path (/usr/bin/env git stash)

1 participant