Skip to content

fix(gates): subagent guard reads an escaped letter the way the shell does (GH #317) - #335

Merged
wasikarn merged 1 commit into
developfrom
claude/gate-317-escaped-letter
Oct 2, 2026
Merged

wasikarn merged 1 commit into
developfrom
claude/gate-317-escaped-letter

Conversation

@wasikarn

@wasikarn wasikarn commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Closes #317.

What

Outside quotes a shell drops a backslash before an ordinary character, so g\it stash, git re\set, git -\C . stash, e\nv git stash, ev\al 'git stash' and ba\sh -c 'git stash' all run git in sh, bash 3.2, bash 5, dash, zsh and ksh. The quote mask keeps the backslash, so the subagent guard's anchor scans missed them.

subagent-git-guard.py gets _mask: the quote mask, then one pass per word that drops a backslash before an ordinary character. The freed blanks go to the word's front, so each word still ends at its raw offset (the shell-body lookup reads the raw command there). A backslash pair stays (\\ is a literal backslash: g\\it stash runs nothing), and so does a backslash before a character it makes literal (\{, A\=1, \*, \$, quotes). _mask is used for the command, shell bodies and substitution bodies.

Deviation from the issue's wording: the issue points at the quote mask. The drop lives in the guard instead of _quotemask.py, because the heredoc scans need the plain mask's offsets: a padded <<\EOF shifts the << and the heredoc parser refuses the command (the heredoc-scan-sees-padded-words mutant below shows that deny). It also leaves test-integrity.py unchanged.

irrecoverable.py: its git, rm and push rules tokenize with shlex (posix), which already drops these backslashes (g\it checkout ., git che\ckout ., git reset --ha\rd, git pu\sh -f are denied). Its nested-spawn regex anchor has the gap (cl\aude -p x, claude -\p x, e\nv claude -p x) and the #318 one ({claude -p x;}): filed as #322, not widened here.

Tests

tests/hooks/test-subagent-git-guard.sh section (24): 28 deny rows (each runs git stash/reset/clean in all six shells, {g\it stash;} in zsh), 11 allow controls (each runs nothing in any shell). All deny rows were red before the fix; one control (git stash l\ist) was a develop false deny and is now allowed.

Mutation check: 16 gate mutants, 16 killed (no drop, drop whole runs, empty keep set, keep set without = or {, no pad, pad at the end, word token taking <> or blanks, bodies / shell bodies / substitution bodies not unescaped, value globals ignored, stash list carve-out gone, blank as a command start, heredoc scan on the padded mask). Every new row flips under at least one mutant.

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

Own generator (escaped letters in wrapper, git, global flags and subcommand words, 0 to 3 backslashes per letter, inside bodies, substitutions and compounds), 3993 cases against current develop (after #321):

develop branch
false-allows 213 108
false-denies 52 125
develop denies, branch allows 8
exits other than 0/2 0 0
  • The 8 develop-deny/branch-allow cases are the intended category: an escaped read-only verb (git stash l\ist, stash sh\ow) that develop denied by mistake. None runs a destructive git in any shell.
  • The 108 remaining false-allows are pre-existing (develop allows them too): 33 are a pathed wrapper (/usr/bin/env git ..., gate: subagent guard does not see a wrapper written as a path (/usr/bin/env git stash) #320), 75 are a second parse (eval g\\\it stash, bash -c "g\\it stash", eval nice\ git reset), where a backslash pair becomes an escape on the second parse: filed as gate: subagent guard misses an escape that only appears after eval or a double-quoted shell body parses the text again #327.
  • The new false-denies are words whose escape the guard now reads but whose command is not run for another reason (an escaped blank glues two words, an escaped = makes a command word). Safe direction.
  • Replay of real transcripts: 119,711 Bash calls, 6,295 distinct commands with git and a backslash: 0 newly denied, 0 newly allowed, worst case 0.24 s.
  • Timing: 100 KB of escaped letters, 10,000 escaped git words and an 8,000-statement escaped shape all decide in 0.07-0.24 s (develop 0.06-0.20 s).

Validation

test-subagent-git-guard.sh 597/597, test-gates.sh 1014/1014, test-quotemask.sh 39/39, test-test-integrity.sh 30/30, harness-audit 0 CRIT, run-gauntlet.sh all layers passed.

…ell does (GH #317)

Outside quotes a shell drops a backslash before an ordinary character, so
`g\it stash`, `git re\set`, `git -\C . stash`, `e\nv git stash` and
`ba\sh -c 'git stash'` all run git, while the quote mask kept the backslash
and the anchor scans missed them. A new `_mask` (the quote mask, then one pass
per word) drops that backslash for the anchor scans of the command, shell
bodies and substitution bodies. The freed blanks go to the word's front, so
each word still ends at its raw offset. A pair stays (`\\` is a literal
backslash), and so does a backslash before a character it makes literal
(`\{`, `A\=1`, `\*`). The heredoc scans keep the plain mask, whose offsets
they need.

Closes #317
@wasikarn
wasikarn merged commit 9a6bc3e into develop Oct 2, 2026
3 checks passed
@wasikarn
wasikarn deleted the claude/gate-317-escaped-letter branch October 2, 2026 03: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.

gate: subagent guard allows a backslash-escaped letter inside git or its subcommand (g\it stash, git re\set)

1 participant