Skip to content

Commit 8689e80

Browse files
committed
chore(hooks): match guarded verbs in command position, not only at line start
Every blocking gate decided "is this the command I guard?" with a LINE-START-anchored regex tolerating exactly one chained shape, an optional leading `cd <path> &&`. So `git push` chained into a PR create -- the natural way to push a branch and open its PR in one step -- did not match and the gate never fired. Not hypothetical: that is how PR 1451's own PR-create slipped past verify-pr-gate. The hole was in all six merge-time gates too, which is strictly worse: those stand between an unverified destroy path and main. Chaining a merge after any other command skipped integ-destroy, integ-broad, integ-local, integ-schema-migration, pr-review and ci-green at once. The anchoring was deliberate, not an oversight -- it kept a mention of the verb inside a quoted argument from false-positiving into a hard block. So this does not simply drop the anchor. It separates the two concerns: strip the DATA spans (heredoc bodies, then quoted spans), removing the false-positive source directly rather than dodging it by position; then match the verb in command position -- line start OR immediately after a `&&` / `||` / `;` / `|` operator. Stripping heredoc bodies turned out to be required, not a refinement. The first attempt at this commit was blocked by integ-broad-gate, because the message describes the bug by naming the commands that trigger it, and a heredoc body is not shell-quoted. Commit messages and PR bodies routinely quote the commands they are about, so prose-as-invocation is the common case rather than the exotic one. A second, independent blind spot surfaced the same way: this commit first landed as `fix(hooks):` with no src/** staged, which commit-prefix-scope-gate exists to stop. It did not fire because it parses `-F <path>` but not `-F -`, so a heredoc message resolved to a nonexistent "<dir>/-", left the subject empty, and fell through. That form is what commit-msg-heredoc-gate steers people toward, so it is the common shape here. The gate now reads the subject out of the heredoc body, and three cases pin it. Fourteen matchers across thirteen hooks now share .claude/hooks/lib/command-match.sh; restore-backup.sh uses the same command-position prefix inline because it builds its regex from a shared variable. The `cd <path> &&` special case disappears from the patterns -- it is just a verb after an operator. Hooks needing the cd TARGET still parse it from the raw command themselves. Two existing smoke-test cases asserted the chained shape was an "ACCEPTED FALSE-NEGATIVE" (branch-gate, pr-review-gate); both now assert it is caught. verify-pr-gate gains five command-position cases, including one proving a quoted mention after a chain operator still passes -- the case where quote-stripping does the work, since position alone no longer saves us. The shared matcher gets its own 22-case test so a regression is reported once and precisely. Verified: 32/32 hook smoke tests green (31 baseline + the new lib test); reverting the helper to the line-start form fails 8 assertions. Remaining non-goals, unchanged because the old anchor missed them too: escaped quotes inside a quoted span, and an inner shell, which would need real parsing. Closes #1455
1 parent d4d0280 commit 8689e80

22 files changed

Lines changed: 401 additions & 41 deletions

.claude/hooks/branch-gate.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222
# 3. The hook input's `cwd` field (the Bash tool's persisted cwd).
2323
# 4. The hook process's own $PWD (fallback, almost never reached).
2424

25+
# Shared command-position matcher (issue #1455): catches the guarded verb
26+
# after ANY chained command (`git push && gh pr create`), not just after an
27+
# optional leading `cd`. See .claude/hooks/lib/command-match.sh.
28+
# shellcheck source=lib/command-match.sh
29+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/command-match.sh"
30+
2531
set -u
2632

2733
# Read the entire stdin payload once; we need both .tool_input.command
@@ -75,7 +81,7 @@ hook_cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || echo "")
7581
# forms are an accepted false-negative
7682
# of the line-start tightening (per the
7783
# memory rule's trade-off).
78-
if ! printf '%s' "$cmd" | grep -qE '^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?git([[:space:]]+(-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?))*[[:space:]]+(commit|push)([[:space:]]|$|[|;&`)])'; then
84+
if ! cmd_matches_verb "$cmd" 'git([[:space:]]+(-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?))*[[:space:]]+(commit|push)([[:space:]]|$|[|;&`)])'; then
7985
exit 0
8086
fi
8187

.claude/hooks/branch-gate.test.sh

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,18 @@ run_case "git -C <main> commit blocked" 2 \
157157
# shape where the second `git` is NOT at line-start. With the
158158
# line-start anchored matcher (per memory rule
159159
# feedback_hook_command_match_line_start.md, issue #563), the
160-
# matcher fires on the FIRST `git -C <feature> status` token
161-
# (the line-start one), which is not a commit/push subcommand,
162-
# so the hook short-circuits at the matcher (exit 0). This is
163-
# an ACCEPTED FALSE-NEGATIVE of the line-start tightening — the
164-
# trade-off we make to eliminate quoted-body false-positives
165-
# (see Part C false-positive cases below). For the agent
166-
# workflow this is fine: chained-on-one-line commits to main
167-
# are rare; the dominant shape is `cd <repo> && git commit ...`,
168-
# which IS line-start matched.
169-
run_case "single-line chained git -C status; git -C commit (accepted false-negative)" 0 \
160+
# Formerly an ACCEPTED FALSE-NEGATIVE: the line-start matcher saw
161+
# only the first `git -C <feature> status` token, which is not a
162+
# commit/push subcommand, so the hook short-circuited (exit 0) and
163+
# a commit to main chained after a `;` went through.
164+
#
165+
# Issue #1455 closed that. The matcher now recognises the verb in
166+
# COMMAND POSITION (line start OR after `&&` / `||` / `;` / `|`),
167+
# so the second `git -C <main> commit` is seen and blocked. The
168+
# quoted-body false-positives this anchoring originally existed to
169+
# prevent are handled directly now, by stripping quoted spans
170+
# before matching — see the Part C cases below, which still pass.
171+
run_case "single-line chained git -C status; git -C commit is now CAUGHT (#1455)" 2 \
170172
"$(printf '{"cwd":"%s","tool_input":{"command":"git -C %s status; git -C %s commit -m oops"}}' "$feature_repo" "$feature_repo" "$main_repo")"
171173

172174
# 11b. `git -c <key>=<val> commit` — global `-c` flag before commit

.claude/hooks/bughunt-clean-gate.sh

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
# so a fix committed from a feature worktree still sees a sentinel armed from
1919
# the main tree.
2020

21+
# Shared command-position matcher (issue #1455): catches the guarded verb
22+
# after ANY chained command (`git push && gh pr create`), not just after an
23+
# optional leading `cd`. See .claude/hooks/lib/command-match.sh.
24+
# shellcheck source=lib/command-match.sh
25+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/command-match.sh"
26+
2127
set -u
2228

2329
input=$(cat 2>/dev/null || true)
@@ -28,11 +34,11 @@ hook_cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || echo "")
2834
# Gate only `git commit`, `gh pr create`, and `gh pr merge`. Line-start anchored
2935
# (tolerating an optional `cd <path> &&` prefix and `gh -C <path>`) so the
3036
# command words inside a quoted argument body do NOT false-positive.
31-
git_commit_re='^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+commit([[:space:]]|$)'
32-
gh_pr_re='^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+(create|merge)([[:space:]]|$|[|;&`)])'
37+
git_commit_re='git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+commit([[:space:]]|$)'
38+
gh_pr_re='gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+(create|merge)([[:space:]]|$|[|;&`)])'
3339

34-
if ! printf '%s' "$cmd" | grep -qE "$git_commit_re" \
35-
&& ! printf '%s' "$cmd" | grep -qE "$gh_pr_re"; then
40+
if ! cmd_matches_verb "$cmd" "$git_commit_re" \
41+
&& ! cmd_matches_verb "$cmd" "$gh_pr_re"; then
3642
exit 0
3743
fi
3844

.claude/hooks/check-gate.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@
2020
# branch-gate.sh / integ-local-gate.sh, so the markgate verify runs
2121
# against the worktree the commit will actually land in.
2222

23+
# Shared command-position matcher (issue #1455): catches the guarded verb
24+
# after ANY chained command (`git push && gh pr create`), not just after an
25+
# optional leading `cd`. See .claude/hooks/lib/command-match.sh.
26+
# shellcheck source=lib/command-match.sh
27+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/command-match.sh"
28+
2329
set -u
2430

2531
# Read the entire stdin payload once; we need both .tool_input.command
@@ -47,7 +53,7 @@ hook_cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || echo "")
4753
# SUBCOMMAND POSITION — not as the prefix of `commit-tree` /
4854
# `commit-graph`. Single-line `git status && git commit` shapes are
4955
# an accepted false-negative (per the memory rule's trade-off).
50-
if ! printf '%s' "$cmd" | grep -qE '^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?git([[:space:]]+(-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?))*[[:space:]]+commit([[:space:]]|$|[|;&`)])'; then
56+
if ! cmd_matches_verb "$cmd" 'git([[:space:]]+(-[^[:space:]]+([[:space:]]+[^[:space:]-][^[:space:]]*)?))*[[:space:]]+commit([[:space:]]|$|[|;&`)])'; then
5157
exit 0
5258
fi
5359

.claude/hooks/ci-green-gate.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
# outage should not block merges); a successful `gh pr checks` answer
3030
# is enforced strictly.
3131

32+
# Shared command-position matcher (issue #1455): catches the guarded verb
33+
# after ANY chained command (`git push && gh pr create`), not just after an
34+
# optional leading `cd`. See .claude/hooks/lib/command-match.sh.
35+
# shellcheck source=lib/command-match.sh
36+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/command-match.sh"
37+
3238
set -u
3339

3440
input=$(cat 2>/dev/null || true)
@@ -38,7 +44,7 @@ hook_cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || echo "")
3844
# Only gate `gh pr merge`. Line-start anchored (memory rule
3945
# feedback_hook_command_match_line_start.md); tolerates an optional
4046
# leading `cd <path> &&` and `gh -C <path>` (mirrors pr-review-gate.sh).
41-
if ! printf '%s' "$cmd" | grep -qE '^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?(CDKD_SKIP_CI_GREEN_GATE=1[[:space:]]+)?gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+merge([[:space:]]|$|[|;&`)])'; then
47+
if ! cmd_matches_verb "$cmd" '(CDKD_SKIP_CI_GREEN_GATE=1[[:space:]]+)?gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+merge([[:space:]]|$|[|;&`)])'; then
4248
exit 0
4349
fi
4450

.claude/hooks/commit-prefix-scope-gate.sh

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,25 @@ if [[ -z "$subject" ]]; then
134134
elif [[ "$cmd" =~ [[:space:]]--file[[:space:]]+([^[:space:]\"\'\;\&\|]+) ]]; then
135135
msg_file="${BASH_REMATCH[1]}"
136136
fi
137-
if [[ -n "$msg_file" ]]; then
137+
if [[ "$msg_file" == "-" ]]; then
138+
# `git commit -F -` reads the message from STDIN, which in practice is a
139+
# heredoc whose body is part of this very command string — so the subject
140+
# IS available at PreToolUse time, it just is not on disk.
141+
#
142+
# This was a silent blind spot: `-F -` matched the path parser, resolved
143+
# to a nonexistent "<dir>/-", left $subject empty, and fell through to the
144+
# pass-through below. It let a `fix(hooks):` commit touching only
145+
# `.claude/**` through — exactly the mislabelled-release shape this gate
146+
# exists to stop — and it did so on the commit that fixed issue #1455.
147+
# The `-F -` heredoc form is also what `commit-msg-heredoc-gate.sh` steers
148+
# people toward, so it is the COMMON shape here, not a rare one.
149+
#
150+
# Take the first non-empty line after the heredoc opener as the subject.
151+
subject=$(printf '%s' "$cmd" | awk '
152+
seen { if ($0 != "") { print; exit } next }
153+
/<<-?[ \t]*("[^"]+"|\047[^\047]+\047|[A-Za-z_][A-Za-z0-9_]*)/ { seen = 1 }
154+
')
155+
elif [[ -n "$msg_file" ]]; then
138156
# Resolve relative path against target_dir.
139157
if [[ "$msg_file" != /* ]]; then
140158
msg_file="$target_dir/$msg_file"

.claude/hooks/commit-prefix-scope-gate.test.sh

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ run_case() {
5151
echo "$subject" > "$msgfile"
5252
cmdstr=$(printf 'git -C %q commit -F %q' "$tmpdir" "$msgfile")
5353
;;
54+
Fdash)
55+
# `git commit -F -` + heredoc: the message never touches disk, so the
56+
# subject has to be read out of the command string itself. This shape
57+
# used to fall through the path parser (resolving to a nonexistent
58+
# "<dir>/-") and silently skip the whole gate.
59+
cmdstr=$(printf "git -C %q commit -q -F - <<'MSGEOF'\n%s\n\nbody\nMSGEOF" "$tmpdir" "$subject")
60+
;;
5461
amend)
5562
cmdstr=$(printf 'git -C %q commit --amend -m "%s"' "$tmpdir" "$subject")
5663
;;
@@ -198,6 +205,23 @@ run_case "feat!: breaking, no src/** BLOCKED" 2 \
198205
run_case "feat: via -F file, .claude/** only BLOCKED" 2 \
199206
"feat(review-pr): add bucket entry" ".claude/skills/review-pr/SKILL.md" "F"
200207

208+
# --- BLOCK / PASS: via `-F -` heredoc shape (the #1455 blind spot) ---
209+
#
210+
# `-F -` matched the path parser, resolved to a nonexistent "<dir>/-", left
211+
# the subject empty and fell through to the pass-through. It let a
212+
# `fix(hooks):` commit touching only `.claude/**` land -- on the very commit
213+
# that fixed #1455. The heredoc form is what commit-msg-heredoc-gate.sh
214+
# steers people toward, so it is the common shape here, not a rare one.
215+
216+
run_case "fix: via -F - heredoc, .claude/** only BLOCKED" 2 \
217+
"fix(hooks): match verbs in command position" ".claude/hooks/verify-pr-gate.sh" "Fdash"
218+
219+
run_case "chore: via -F - heredoc, .claude/** only passes" 0 \
220+
"chore(hooks): match verbs in command position" ".claude/hooks/verify-pr-gate.sh" "Fdash"
221+
222+
run_case "feat: via -F - heredoc with src/** passes" 0 \
223+
"feat(cli): add flag" "src/cli/options.ts" "Fdash"
224+
201225
# --- BLOCK: variant subject formats ---
202226

203227
run_case "feat: with --message= form BLOCKED" 2 \

.claude/hooks/integ-broad-gate.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@
3838
# Set ONLY by /run-integ; never call `markgate set integ-broad` by hand
3939
# (same rule as the other AWS-coupled gates).
4040

41+
# Shared command-position matcher (issue #1455): catches the guarded verb
42+
# after ANY chained command (`git push && gh pr create`), not just after an
43+
# optional leading `cd`. See .claude/hooks/lib/command-match.sh.
44+
# shellcheck source=lib/command-match.sh
45+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/command-match.sh"
46+
4147
set -u
4248

4349
# Read the PreToolUse payload (command + cwd).
@@ -55,7 +61,7 @@ hook_cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || echo "")
5561
# into a hard block. The optional leading `cd <path> &&` prefix
5662
# preserves the worktree-aware `cd <side> && gh pr merge` chain
5763
# shape, mirroring check-gate.sh (PR #562 fix pattern).
58-
if ! printf '%s' "$cmd" | grep -qE '^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+merge([[:space:]]|$|[|;&`)])'; then
64+
if ! cmd_matches_verb "$cmd" 'gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+merge([[:space:]]|$|[|;&`)])'; then
5965
exit 0
6066
fi
6167

.claude/hooks/integ-destroy-gate.sh

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323
# target working tree from the PreToolUse payload's `cwd` field +
2424
# leading `cd <path>` + last `gh -C <path>` flag.
2525

26+
# Shared command-position matcher (issue #1455): catches the guarded verb
27+
# after ANY chained command (`git push && gh pr create`), not just after an
28+
# optional leading `cd`. See .claude/hooks/lib/command-match.sh.
29+
# shellcheck source=lib/command-match.sh
30+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/command-match.sh"
31+
2632
set -u
2733

2834
# Read the entire stdin payload once; we need both .tool_input.command
@@ -41,7 +47,7 @@ hook_cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || echo "")
4147
# into a hard block. The optional leading `cd <path> &&` prefix
4248
# preserves the worktree-aware `cd <side> && gh pr merge` chain
4349
# shape, mirroring check-gate.sh (PR #562 fix pattern).
44-
if ! printf '%s' "$cmd" | grep -qE '^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+merge([[:space:]]|$|[|;&`)])'; then
50+
if ! cmd_matches_verb "$cmd" 'gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+merge([[:space:]]|$|[|;&`)])'; then
4551
exit 0
4652
fi
4753

.claude/hooks/integ-local-gate.sh

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@
4141
# 3. The hook input's `cwd` field.
4242
# 4. The hook process's own $PWD.
4343

44+
# Shared command-position matcher (issue #1455): catches the guarded verb
45+
# after ANY chained command (`git push && gh pr create`), not just after an
46+
# optional leading `cd`. See .claude/hooks/lib/command-match.sh.
47+
# shellcheck source=lib/command-match.sh
48+
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/command-match.sh"
49+
4450
set -u
4551

4652
# Read the entire stdin payload once; we need both .tool_input.command
@@ -66,7 +72,8 @@ hook_cwd=$(printf '%s' "$input" | jq -r '.cwd // ""' 2>/dev/null || echo "")
6672
# (PR #562 fix pattern). `[^|;&]*` matches flags / values between
6773
# `gh`/`git` and the subcommand without crossing pipeline separators.
6874
# Tolerate an optional `gh -C <path>` between `gh` and `pr`.
69-
if ! printf '%s' "$cmd" | grep -qE '^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+merge([[:space:]]|$|[|;&`)])|^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?git[^|;&]*[[:space:]]merge([[:space:]]|$|[|;&`)])'; then
75+
if ! { cmd_matches_verb "$cmd" 'gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+merge([[:space:]]|$|[|;&`)])' \
76+
|| cmd_matches_verb "$cmd" 'git[^|;&]*[[:space:]]merge([[:space:]]|$|[|;&`)])'; }; then
7077
exit 0
7178
fi
7279

@@ -199,7 +206,7 @@ fi
199206
# `gh pr merge <N>` path above. Bail conservatively (fall through to
200207
# the unconditional verify) on `--abort` / `--continue` / `--quit`,
201208
# octopus (2+ refs), a ref we cannot resolve, or an unparsable shape.
202-
if printf '%s' "$cmd" | grep -qE '^[[:space:]]*(cd[[:space:]]+[^[:space:]]+[[:space:]]*&&[[:space:]]*)?git[^|;&]*[[:space:]]merge([[:space:]]|$|[|;&`)])' \
209+
if cmd_matches_verb "$cmd" 'git[^|;&]*[[:space:]]merge([[:space:]]|$|[|;&`)])' \
203210
&& ! printf '%s' "$cmd" | grep -qE 'gh([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+pr[[:space:]]+merge'; then
204211
merge_ref=""
205212
parse_ok=1

0 commit comments

Comments
 (0)