From cf874bfda9d33946421d2b8211608894c7820172 Mon Sep 17 00:00:00 2001 From: Dennis Vorobyov Date: Mon, 7 Sep 2026 16:15:49 +0100 Subject: [PATCH 1/3] fix(hooks): count a reserved word on a continuation line, and route a fully \u-encoded command to the walker Two bypasses that v1.0.7 shipped, both verified end-to-end against a git argv shim under bash 4+. A backslash-newline joins the next line onto the previous word, so a `case` (or a split `ca`+`se`) at column 1 of a continuation line inside `$(...)` is the reserved word `case`. The walker skipped scanning a word at column 1 of a continuation line, so `case` was never counted, its pattern `)` was read as the substitution's closing paren, and everything after it, a force-push, a `--no-verify`, or a `git config user.name`, was discarded from the stripped text. The walker now scans that word and carries a word split across the backslash-newline, so the reserved word is seen and the command after the pattern stays visible. The fast path routed a command to the walker on a JSON `\u` escape only when a literal git/commit/push word still remained after dequoting, so a command whose every such word was `\u`-encoded slipped through unread. jq decodes a `\u` escape, so the presence of the escape alone now routes the command to the walker. An ANSI-C numeric escape is decoded by the shell at run time, not jq, so it still routes on a residual word; a command whose every such word is ANSI-C-encoded stays the documented splice limit. Guard tests 585 to 594. --- hooks/guard-commit.sh | 26 +++++++++++++++++++------- tests/hooks/test-guard-commit.sh | 15 +++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/hooks/guard-commit.sh b/hooks/guard-commit.sh index eac370a..72bdb03 100644 --- a/hooks/guard-commit.sh +++ b/hooks/guard-commit.sh @@ -69,9 +69,13 @@ if [ "$RC" -eq 1 ]; then fi grep -qiE 'git.*(commit|push)' <<<"$J" RC=$? - # Text no dequoting can reveal goes to the walker: an ANSI-C numeric escape (`$'\x70ush'`, the - # walker refuses it) or a JSON \u escape for an ASCII letter (jq decodes it; the walker sees it). - if [ "$RC" -eq 1 ] && has 'git|commit|push' "$J" && { hasc "\\\$'" "$FLAT" && hasc '\\\\[xuU0-7]' "$FLAT" || hasc '\\u00[0-9a-fA-F]{2}' "$FLAT"; }; then RC=0; fi + # Text no dequoting can reveal goes to the walker. A JSON \u escape for an ASCII byte is decoded by + # jq, so a command whose git/commit/push is fully \u-encoded still reaches the walker: route on the + # escape alone, not on a residual literal word. An ANSI-C numeric escape (`$'\x70ush'`) is decoded by + # the shell at run time, not jq, so it routes only when the command still mentions git/commit/push; + # a command whose every such word is ANSI-C-encoded is the documented splice limit. + if [ "$RC" -eq 1 ] && hasc '\\u00[0-9a-fA-F]{2}' "$FLAT"; then RC=0; fi + if [ "$RC" -eq 1 ] && has 'git|commit|push' "$J" && hasc "\\\$'" "$FLAT" && hasc '\\\\[xuU0-7]' "$FLAT"; then RC=0; fi fi case $RC in 0) ;; @@ -164,7 +168,7 @@ strip_data() { function inbrace( k) { for (k = depth; k > 0; k--) if (kind[k] == "${") return 1; return 0 } # inside a `${...}` word function fresh() { return (o == "" || o ~ /[ \t]$/ || substr(o, length(o), 1) == "\001") } # a quote at the start of a word function heredocbeforesubst( k) { for (k = depth; k > 0; k--) if ((kind[k] == "$(" || kind[k] == "`" || kind[k] == "<(") && pendat[k] > 0) return 1; return 0 } - BEGIN { q = 0; depth = 0; npend = 0; body = 0; cont = 0; casec[0] = 0; poppos = -1; poppedkind = ""; cmdpos = 1; kwlead = 0 } + BEGIN { q = 0; depth = 0; npend = 0; body = 0; cont = 0; casec[0] = 0; poppos = -1; poppedkind = ""; cmdpos = 1; kwlead = 0; contword = "" } { line = $0 if (index(line, "\001")) refuse("a control byte in the command") # \001 is the walk'"'"'s own boundary mark @@ -314,21 +318,29 @@ strip_data() { } o = o "<<"; i = j; cmdpos = 0; continue } - if (ch ~ /[A-Za-z_]/ && ((i == 1 && !joined) || (i > 1 && c[i - 1] !~ /[A-Za-z0-9_]/))) { # a word: track case ... esac in command position - j = i; w = "" + # a word: track case ... esac in command position. A continuation line (`joined`) is scanned at + # column 1 too, because a `< 0) casec[depth]-- if (w == "if" || w == "then" || w == "else" || w == "elif" || w == "while" || w == "until" || w == "do") { cmdpos = 1; kwlead = 0 } else if (w == "time" || w == "coproc") { cmdpos = 1; kwlead = 2 } # `time -p cmd`, `coproc NAME cmd`: the command may be two words on else if (kwlead > 0) kwlead-- else cmdpos = 0 - o = o w; i = j; continue + continue } if (ch != " " && ch != "\t" && kwlead == 0) cmdpos = 0 o = o ch; i++ } if (q == 1 || q == 2 || q == 3) qbad = 1 # a quoted span crossing a line is never glued + if (!cont) contword = "" # a carried word fragment lives only across a backslash-newline if (q == 0 && !cont) droptests() # a body starts at the newline that ends the COMMAND (code state, no continuation), as in bash; # a `${...}` still open, or a `< Date: Mon, 7 Sep 2026 16:34:51 +0100 Subject: [PATCH 2/3] fix(hooks): resolve a carried word fragment when a continuation line does not continue it The first cut of the continuation fix carried a word fragment across a backslash-newline but cleared it only at end of line. A fragment could survive an intervening continuation that neither continued nor ended it (`foo\` then `; \` then `case`), and was then wrongly glued onto a later column-1 `case`, making `foocase`, so the reserved word was not counted and the case-pattern `)` popped the substitution frame again. A `git push --force`, `--no-verify`, or committer write after the pattern was discarded and ran under bash 4+. A carried fragment now continues only when the next joined line begins with a word character. Otherwise the deferred word was complete, so it is classified and dropped rather than glued onto a later word. The keyword and command-position logic is factored into one `classify` helper used by both the inline word scan and this resolve. A bare `\` intermediate line (`foo\` then `\` then `case`) joins with no gap into `foocase`, so bash never runs the command after it; that stays allowed, confirmed against bash 5. --- hooks/guard-commit.sh | 25 ++++++++++++++++++------- tests/hooks/test-guard-commit.sh | 8 ++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/hooks/guard-commit.sh b/hooks/guard-commit.sh index 72bdb03..12963d5 100644 --- a/hooks/guard-commit.sh +++ b/hooks/guard-commit.sh @@ -52,7 +52,10 @@ has() { grep -qiE -e "$1" <<<"$2"; local rc=$?; [ "$rc" -gt 1 ] && { echo "Bloc hasc() { grep -qE -e "$1" <<<"$2"; local rc=$?; [ "$rc" -gt 1 ] && { echo "Blocked: guard-commit.sh grep failed. Failing closed." >&2; exit 2; }; return "$rc"; } # Fast path: this guard only concerns `git commit` / `git push`. If the payload mentions -# neither, allow immediately — so a missing jq (below) never blocks unrelated Bash (ls/cat/grep). +# neither, allow immediately — so a missing jq (below) does not block unrelated plain Bash +# (ls/cat/grep). A command carrying a JSON `\u00XX` escape is the exception: it routes to jq +# regardless (a fully `\u`-encoded git command has no literal word to match), so such a command +# needs jq even when it is not a git command. jq is a documented hard dependency (§19.2). # Match loosely (no quote-class): a quoted arg before the subcommand (`git -C "x" commit`) # must NOT slip past into a silent allow. grep exit 1 = no match; anything else = error. FLAT=$(tr '\n' ' ' <<<"$INPUT") || FLAT=$INPUT # a pretty-printed payload must not split the match across lines @@ -168,6 +171,15 @@ strip_data() { function inbrace( k) { for (k = depth; k > 0; k--) if (kind[k] == "${") return 1; return 0 } # inside a `${...}` word function fresh() { return (o == "" || o ~ /[ \t]$/ || substr(o, length(o), 1) == "\001") } # a quote at the start of a word function heredocbeforesubst( k) { for (k = depth; k > 0; k--) if ((kind[k] == "$(" || kind[k] == "`" || kind[k] == "<(") && pendat[k] > 0) return 1; return 0 } + # a complete word in command position: track case/esac depth and the command-position flag + function classify(w) { + if (cmdpos && w == "case") casec[depth]++ + else if (cmdpos && w == "esac" && casec[depth] > 0) casec[depth]-- + if (w == "if" || w == "then" || w == "else" || w == "elif" || w == "while" || w == "until" || w == "do") { cmdpos = 1; kwlead = 0 } + else if (w == "time" || w == "coproc") { cmdpos = 1; kwlead = 2 } # `time -p cmd`, `coproc NAME cmd`: the command may be two words on + else if (kwlead > 0) kwlead-- + else cmdpos = 0 + } BEGIN { q = 0; depth = 0; npend = 0; body = 0; cont = 0; casec[0] = 0; poppos = -1; poppedkind = ""; cmdpos = 1; kwlead = 0; contword = "" } { line = $0 @@ -193,6 +205,10 @@ strip_data() { poppos = -1 joined = cont; cont = 0 # this line continues the previous one: no word starts at column 1 if (q == 0 && !joined) cmdpos = 1 + # a carried word fragment continues only if this joined line begins with a word character; if not, + # the deferred word was complete (a backslash-newline joined it onto a non-word char), so classify + # it now and drop it, rather than gluing it onto a later column-1 word + if (joined && q == 0 && contword != "" && (n < 1 || c[1] !~ /[A-Za-z0-9_]/)) { classify(contword); contword = "" } while (i <= n) { ch = c[i] if (q == 1) { if (ch == "\047") { q = 0; closeq() } else qb = qb ch; i++; continue } @@ -328,12 +344,7 @@ strip_data() { if (wi == 1 && contword != "") { w = contword w; contword = "" } # this word continues the previous line i = j if (j == n && c[j] == "\\") { contword = w; continue } # runs into a line-ending backslash: decide once the whole word is seen - if (cmdpos && w == "case") casec[depth]++ - else if (cmdpos && w == "esac" && casec[depth] > 0) casec[depth]-- - if (w == "if" || w == "then" || w == "else" || w == "elif" || w == "while" || w == "until" || w == "do") { cmdpos = 1; kwlead = 0 } - else if (w == "time" || w == "coproc") { cmdpos = 1; kwlead = 2 } # `time -p cmd`, `coproc NAME cmd`: the command may be two words on - else if (kwlead > 0) kwlead-- - else cmdpos = 0 + classify(w) continue } if (ch != " " && ch != "\t" && kwlead == 0) cmdpos = 0 diff --git a/tests/hooks/test-guard-commit.sh b/tests/hooks/test-guard-commit.sh index 9d1af06..5f1522c 100755 --- a/tests/hooks/test-guard-commit.sh +++ b/tests/hooks/test-guard-commit.sh @@ -276,6 +276,14 @@ run "word case split across a continuation" 2 'x="$(ca\ se a in a) git push --force origin main;; esac)"' run "continuation with a leading space is a fresh case" 2 'x="$( \ case a in a) git push --force origin main;; esac)"' +run "stale fragment across a ; continuation then case hides a push" 2 'x="$(foo\ +; \ +case a in a) git push --force origin main;; esac)"' +run "stale fragment across a ; continuation then case hides --no-verify" 2 'x="$(foo\ +; \ +case a in a) git commit --no-verify -m p;; esac)"' +run "fresh case after a word then separator on a continuation" 2 'x="$(foo\ +; case a in a) git push --force origin main;; esac)"' run "case pattern then a chained push" 2 'git commit -m "$(case a in a) echo x;; esac)" && git push -f origin main' run "case statement in plain code" 2 'case x in x) echo hi;; esac git push -f origin main' From caaf244bffb5c9045ecdbc15970ebdcb32f1bc97 Mon Sep 17 00:00:00 2001 From: Dennis Vorobyov Date: Mon, 7 Sep 2026 16:48:11 +0100 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20finish=20the=20time/coproc=20commen?= =?UTF-8?q?t=20and=20set=20=C2=A719.4=20live=20version=20to=20v1.0.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- hooks/guard-commit.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3af97ed..c74e836 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,7 @@ Requires `shellcheck`, `jq`, and `git` (the hooks need `jq` at runtime too) — ### 19.4 Current release pointers -- **Live version:** v1.0.7 (annotated tag, latest on `main`). +- **Live version:** v1.0.8 (annotated tag, latest on `main`). - **In flight:** none (set per session). - **CHANGELOG:** none — release notes are the GitHub Release body, generated from `git log` between tags. - **Spec / PRD:** `README.md` + `STRUCTURE.md` are canonical. diff --git a/hooks/guard-commit.sh b/hooks/guard-commit.sh index 12963d5..ed31c62 100644 --- a/hooks/guard-commit.sh +++ b/hooks/guard-commit.sh @@ -176,7 +176,7 @@ strip_data() { if (cmdpos && w == "case") casec[depth]++ else if (cmdpos && w == "esac" && casec[depth] > 0) casec[depth]-- if (w == "if" || w == "then" || w == "else" || w == "elif" || w == "while" || w == "until" || w == "do") { cmdpos = 1; kwlead = 0 } - else if (w == "time" || w == "coproc") { cmdpos = 1; kwlead = 2 } # `time -p cmd`, `coproc NAME cmd`: the command may be two words on + else if (w == "time" || w == "coproc") { cmdpos = 1; kwlead = 2 } # `time -p cmd`, `coproc NAME cmd`: the command may be two words on the same line else if (kwlead > 0) kwlead-- else cmdpos = 0 }