From decd062331c3f4e0ba78fce8a1777f4b24221a39 Mon Sep 17 00:00:00 2001 From: KOBIG Date: Fri, 2 Oct 2026 09:36:10 +0700 Subject: [PATCH 1/2] fix(gates): subagent-git-guard reads a glued `{` as a command start (GH #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 --- docs/reference/repo-gotchas.md | 3 +- hooks/gates/subagent-git-guard.py | 6 +++- tests/hooks/test-subagent-git-guard.sh | 46 ++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/docs/reference/repo-gotchas.md b/docs/reference/repo-gotchas.md index 95a5a97c4..626c5bc5b 100644 --- a/docs/reference/repo-gotchas.md +++ b/docs/reference/repo-gotchas.md @@ -135,7 +135,8 @@ builder's self-check ("0 bypasses in 4032") was wrong each time. bound to a shared budget and denies past it (GH #246: 30 KB of `env ; ` before a `git stash` timed out into allow). Only 3 of 2,581 replayed real commands (20-28 KB scripts) hit it. GH #248 added a third, lazy-walk pass (`*?` finds the FIRST target after a wrapper, where the - greedy walk lands on the last), `{` + blank as a command start, and `xargs` in the spawn + greedy walk lands on the last), `{` + blank as a command start (GH #318: also a glued `{` that + starts a word, since zsh runs `{git stash;}`), and `xargs` in the spawn anchor's wrapper list only. Each pass charges the budget, so the guard's rose to 60M; a padded benign command of ~3,000 blank lines (spawn scan) or ~2,100-2,200 `{ ` units (both gates) is now refused. GH #276: the lazy pass can land on a wrapper argument spelled `git` (`sudo -u git env git stash`), diff --git a/hooks/gates/subagent-git-guard.py b/hooks/gates/subagent-git-guard.py index af8ae6bdd..7cdf9af91 100755 --- a/hooks/gates/subagent-git-guard.py +++ b/hooks/gates/subagent-git-guard.py @@ -210,8 +210,12 @@ def _mask_quotes(s): # GH #285: a redirection (operator + its word) may sit before the command word and hid it # (`>?|[<>]&|<>|>\||[<>]>?)[ \t]*[^\s;&|()<>]+[ \t]+)" +# GH #318: zsh also runs a brace group with no blank after `{` (`{git stash;}`); bash, dash and ksh +# read `{git` as a word. The Bash tool runs zsh, so a glued `{` opens a command position too (any +# deny wins) when it starts a word: zsh runs `(){git stash;}` and `{true;}{git stash;}`, but not +# `x{git stash;}` or `${git stash;}`. `{` + blank keeps develop's reading everywhere. def _cmd_start(wrapper_prefix): - return (r"(?:^|[|;&(]|&&|\|\||\{(?=\s))\s*" + _KEYWORD_PREFIX + + return (r"(?:^|[|;&(]|&&|\|\||\{(?=\s)|(?&1' \ + 'time {git stash;}' \ + '{git stash;}; git status' \ + '(){git stash;}' \ + 'f(){git stash;}; f' \ + '{true;}{git stash;}' \ + 'for i in 1; {git stash;}' \ + '2>&1 {git stash;}' \ + 'echo $({git stash;})' ; do + rc=$(sgg_rc "$_c"); ok=1; [ "$rc" = "2" ] && ok=0 + check "GH #318 denied (zsh glued brace group): ${_c//$'\n'/}" "$ok" +done +for _c in \ + '{git status;}' \ + '{git stash list;}' \ + 'echo {git,x} stash' \ + '{}git stash' \ + 'x{git stash;}' \ + '${git stash;}' \ + 'echo {a,b} git stash' \ + 'git commit -m "fix {git stash;}"' ; do + rc=$(sgg_rc "$_c"); ok=1; [ "$rc" = "0" ] && ok=0 + check "GH #318 control allowed: $_c" "$ok" +done + echo "" echo "=== $pass passed, $fail failed ===" [ "$fail" -eq 0 ] From ebad0279496b82461b6e9cb5df291ad29f1a0544 Mon Sep 17 00:00:00 2001 From: KOBIG Date: Fri, 2 Oct 2026 09:57:30 +0700 Subject: [PATCH 2/2] fix(gates): a `}` before a glued `{` is not a zsh command start (GH #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). --- hooks/gates/subagent-git-guard.py | 6 +++--- tests/hooks/test-subagent-git-guard.sh | 9 ++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/hooks/gates/subagent-git-guard.py b/hooks/gates/subagent-git-guard.py index 7cdf9af91..2179b529c 100755 --- a/hooks/gates/subagent-git-guard.py +++ b/hooks/gates/subagent-git-guard.py @@ -212,10 +212,10 @@ def _mask_quotes(s): _REDIR = r"(?:(?:\d+|\{\w+\})?(?:<<>?|[<>]&|<>|>\||[<>]>?)[ \t]*[^\s;&|()<>]+[ \t]+)" # GH #318: zsh also runs a brace group with no blank after `{` (`{git stash;}`); bash, dash and ksh # read `{git` as a word. The Bash tool runs zsh, so a glued `{` opens a command position too (any -# deny wins) when it starts a word: zsh runs `(){git stash;}` and `{true;}{git stash;}`, but not -# `x{git stash;}` or `${git stash;}`. `{` + blank keeps develop's reading everywhere. +# deny wins) when it starts a word: zsh runs `(){git stash;}` and `{{git stash;};}`, but not +# `x{git stash;}`, `${git stash;}` or `{true;}{git stash;}`. `{` + blank keeps develop's reading. def _cmd_start(wrapper_prefix): - return (r"(?:^|[|;&(]|&&|\|\||\{(?=\s)|(?&1 {git stash;}' \ 'echo $({git stash;})' ; do @@ -1073,11 +1072,19 @@ for _c in \ '{}git stash' \ 'x{git stash;}' \ '${git stash;}' \ + '{true;}{git stash;}' \ 'echo {a,b} git stash' \ 'git commit -m "fix {git stash;}"' ; do rc=$(sgg_rc "$_c"); ok=1; [ "$rc" = "0" ] && ok=0 check "GH #318 control allowed: $_c" "$ok" done +# Every glued `{` is now a command start; a long run of them must still be decided inside 8 s. +_c="$(_pad '{' 3000)git stash;}" +rc=$(sgg_rc8 "$_c"); ok=1; [ "$rc" = "2" ] && ok=0 +check "GH #318 padded glued braces decided inside 8 s as deny (rc $rc, ${#_c} bytes)" "$ok" +_c="$(_pad '{' 3000)ls" +rc=$(sgg_rc8 "$_c"); ok=1; [ "$rc" != "124" ] && ok=0 +check "GH #318 padded glued braces allow shape finishes inside 8 s (rc $rc, ${#_c} bytes)" "$ok" echo "" echo "=== $pass passed, $fail failed ==="