Skip to content

fix(gates): subagent guard reads a zsh glued brace group as a command start (GH #318) - #321

Merged
wasikarn merged 2 commits into
developfrom
claude/gate-318-zsh-glued-brace
Oct 2, 2026
Merged

wasikarn merged 2 commits into
developfrom
claude/gate-318-zsh-glued-brace

Conversation

@wasikarn

@wasikarn wasikarn commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Closes #318.

What

zsh runs a brace group with no blank after the open brace ({git stash;}); bash, dash and ksh read {git as a word. The Bash tool runs zsh, so this was a live subagent-guard bypass for plain stash, reset and clean. The guard's command-start anchor took { only when a blank followed.

_cmd_start now also takes a glued { that starts a word: line start, a blank, or one of ;&|(){ before it. The old { + blank alternative is kept as is, so every command develop denies is still denied. The lookbehind set was read off real zsh: it runs (){git stash;}, f(){git stash;}, {{git stash;};}, 2>&1 {git stash;}, for i in 1; {git stash;}, and does not run x{git stash;}, ${git stash;}, \{git stash;} or {true;}{git stash;}.

Tests

tests/hooks/test-subagent-git-guard.sh section (23): 25 deny rows (each checked to run git stash/reset/clean in zsh with a logging git stub), 9 allow controls (each runs nothing in sh, bash 3.2, bash 5, dash, zsh, ksh), and 2 timing rows (3000 glued braces, deny and allow shapes both decided inside 8 s). All 25 deny rows were red before the fix.

Mutation check of the new rows: 13 gate mutants, 13 killed (revert to develop, drop the brace start, no lookbehind, lookbehind without ), without {, with }, without blanks, plus 6 on neighbouring logic). Every one of the 34 new rows flips under at least one mutant.

Differential fuzz (real shells as oracle, any deny wins)

Own generator, 4000 cases, oracle = sh, bash 3.2, bash 5, dash, zsh, ksh with logging stubs:

develop branch
false-allows 218 43
false-denies 415 731
develop denies, branch allows 0
exits other than 0/2 0 0
  • The 43 remaining false-allows are pre-existing (develop allows them too): 40 are a wrapper written as a path (/usr/bin/env git stash, runs in every shell), filed as gate: subagent guard does not see a wrapper written as a path (/usr/bin/env git stash) #320; 3 are ksh93's ${ cmd;} substitution with a glued redirect (${</dev/null git stash }), ksh only.
  • The new false-denies are generator shapes no shell runs, mostly unbalanced braces (! {{eval git stash pop;}, a zsh parse error). Safe direction.
  • Replay of real transcripts: 119,321 Bash calls, 2,849 distinct commands with git and {: 0 newly denied, 0 newly allowed, worst case 0.28 s.

Validation

test-subagent-git-guard.sh 558/558, test-gates.sh 1014/1014, harness-audit 0 CRIT, run-gauntlet.sh all layers passed.

#318)

zsh runs a brace group with no blank after the open brace, so `{git stash;}`
ran git stash under the Bash tool's zsh while the guard's anchor only took `{`
followed by a blank. A `{` that starts a word (line start, a blank, or one of
`;&|(){}` before it) now opens a command position too; `{` + blank keeps
develop's reading everywhere. `x{git stash;}` and `${git stash;}`, which no
shell runs, stay allowed.

Closes #318
)

zsh does not run `{true;}{git stash;}`, so `}` leaves the lookbehind set; the
row moves to the controls. Adds timing rows for a 3000-brace run (deny and
allow shapes both decide inside the 8 s hook timeout).
@wasikarn
wasikarn merged commit 645c8eb into develop Oct 2, 2026
3 checks passed
@wasikarn
wasikarn deleted the claude/gate-318-zsh-glued-brace branch October 2, 2026 03:13
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: zsh runs a brace group with no space after the open brace, which the subagent guard does not see as a command start

1 participant