fix(hook-utils): stop a NUL in a payload value from voiding two blocking guards - #2135
Conversation
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
232899b to
d6cd5e9
Compare
|
Claude finished @kyle-sexton's task in 2m 38s —— View job Security review complete
Scope: reviewed only the files this PR touches — No security issues found. Specifically checked for and ruled out:
One thing worth naming since it's easy to mistake for a gap but isn't: the flag only causes a hard block in the two guards this PR modifies. The ten hooks converted in the separate, still-open #2120 don't consult the flag at all — but that's explicitly out of scope for this diff (no files from that set are touched here), and the PR body already discloses it at length rather than leaving it implicit. Not treating it as a finding against this PR. |
|
Claude finished @kyle-sexton's task in 6m 14s —— View job Review completeScope reviewed: Findings
What I independently verified (not just author-claimed)
Not independently verified (author-claimed only)
|
|
Claude has reviewed this PR 5 times. The lane skips further automatic reviews after 5; deleting this comment resets the count. |
…ing guards `hook::jq_fields` separates its fields with a NUL byte, drawn from the same byte space as the values it separates. A JSON NUL escape inside a value split that value in two, the cardinality check failed, and the helper returned 1 — which both real callers spell `|| exit 0`, a PreToolUse ALLOW with no diagnostic. Measured at the hook boundary on the shipped hooks: every NUL-bearing form of `git push --no-verify` and `git reset --hard` exited 0 where the clean forms exited 2. jq now truncates each value at its first NUL, so the separator cannot occur inside a value and the record count no longer depends on what a parseable payload holds. The NUL itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, assigned in the same unconditional block that resets `HOOK_JQ_FIELDS` so no early return can leak a stale value. Both guardrails guards fail CLOSED on the flag, ahead of their empty-command skip so a leading NUL — which leaves an empty value — cannot pass as "no command". No claim is made about how a NUL would execute, in either direction. Two behaviours were measured and they disagree: bash DISCARDS a NUL while parsing a command it reads, and Node's child_process REFUSES a NUL-bearing string outright. Which of them, if either, a hook payload reaches has not been traced. That is exactly why the verdict is fail-closed on the flag rather than a match against the value — blocking is correct under deletion, truncation and refusal alike, so it needs no such trace and cannot be invalidated by one later. Truncation over deletion is then chosen on grounds that appeal to no shell: it never fabricates a token the payload did not carry contiguously, and it decides which caller class degrades if a hook forgets the flag. For these two callers it is immaterial — they refuse before reading a value. Policy stays with the caller: the library is sourced by 15 other plugins, formatters among them, for which exiting 2 would be wrong, and a sourced library calling `exit` on its caller's behalf is hidden control flow. `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so no regex pattern and no string literal in the jq program text carries a NUL: a construct whose behaviour varied across jq builds would fail every payload, which is worse than the payload-dependent bug being fixed. Still one jq spawn. Scope, stated rather than left to the diff: `hook::jq_field` (singular) is a separate function and is untouched; its call sites in three other plugins are out of scope. A payload jq cannot parse still returns 1 and is still allowed, unchanged here and deliberately not addressed — the header comment says so rather than claiming the path is unreachable. Closes #2122 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d6cd5e9 to
187a0e0
Compare
#2120 landed on main and fixed the same function with the opposite value disposition: it STRIPS every NUL out of a value, where this branch TRUNCATED each value at its first NUL. Resolved by keeping main's strip and this branch's flag plus fail-closed guards, which is additive over main rather than a choice between the two sides. Why strip wins the disposition. main now carries ten scanner-class callers that #2120 converted, none of which consults the flag; truncation would hide a credential placed after a NUL from secret-pattern-detection and hardcoded-path-check. This branch's own body already conceded the disposition is immaterial for its two callers, which refuse on the flag before reading a value. Why the flag and the guards are still needed after #2120. Stripping SPLICES the bytes either side of the NUL into a token the payload never carried contiguously, and the command guards then match against it. Measured at the hook boundary, origin/main at fd075c2 versus this tree, on fixtures whose NUL is a real byte decoded from a JSON \u0000 escape: git commit --no-verify<NUL>x main 0 ALLOWED -> here 2 blocked git push --force<NUL>x main 0 ALLOWED -> here 2 blocked lone NUL / trailing NUL main 0 ALLOWED -> here 2 blocked git commit --no-veri<NUL>fy main 2 -> here 2 (same, evidences nothing) clean --no-verify / --force / harmless 2 / 2 / 0 both trees The textual merge git produced was silently fatal and was NOT taken: it kept main's per-filter split/join AND this branch's array-level truncate, which put the strip BEFORE the flag computation, so index(0) saw a value with no NUL left and the flag read 0 on every payload — the guards would never have fired. The flag is now computed from the untouched values and the strip applied after, with a comment saying so, because that ordering is exactly what a future textual merge will get wrong again. Conflicts: lib/hook-utils.sh header comment and jq program, resolved by hand; the 16 vendored copies regenerated with scripts/sync-hook-utils.sh rather than hand-resolved (16/16 byte-identical); 16 CHANGELOGs where both sides claimed the same version, this branch's entry moved up one patch above main's and rewritten for the resolved design; 16 plugin.json bumps, all of which had auto-merged to main's number leaving no bump at all. Two guard comments justified the flag check's position by truncation ("a leading NUL leaves an empty command"). Under strip a leading NUL keeps its text and only an all-NUL command arrives empty, so the check's position is still right and the comments now say why for the real reason. Verified, not reasoned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the splice case The merge resolution kept #2120's stripping disposition, so every assertion this branch wrote against truncation was measuring a value the helper no longer produces. Fixed rather than deleted, and the labels now match what is asserted. lib/hook-utils.test.sh - the framing case expects the stripped values (`git push --no-verify`, `s1`, `pq`) instead of the truncated prefixes; - the leading-NUL case asserts the text is PRESERVED and the flag still rises, which is the real behaviour under strip; - new: `--no-verify<NUL>x` arrives as the single token `--no-verifyx`. This is the case the whole fix exists for — a token the payload never carried contiguously, which no matcher recognizes, so a caller reading only the value allows it. Verified red against origin/main's guards (exit 0) and green here (exit 2); - new: an ALL-NUL value strips to empty and still raises the flag. That case, not a leading NUL, is why both guards consult the flag ahead of their empty-command skip. Both guard suites keep every NUL row at exit 2 — the verdict never depended on the disposition, only the justification did — with one mislabelled row corrected ("leading NUL truncates to no command" does not truncate under strip) and the all-NUL row added alongside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 6m 12s —— View job Security review complete
Scope: Note: the code on this branch has moved past what the PR description narrates. The description argues for a truncate design ( Findings1. IMPORTANT / confidence: CONFIRMED (fact) + PLAUSIBLE (security impact) — the PR's blast-radius claim is stale, and the gap it describes as hypothetical is already live on The PR body states (and a prior review round trusted): " That's no longer accurate.
(the remaining five — This isn't a defect this diff introduces — the strip behavior and the unflagged callers both predate it, arriving via Suggested fix: either (a) add the same What I checked and found clean
|
|
Claude finished @kyle-sexton's task in 7m 7s —— View job Code review complete
Scope: this review is scoped to correctness, architecture, error handling, test coverage, and maintainability per Finding
The sentence "...is a real byte decoded from a JSON
Fix: replace the raw NUL byte with the literal escaped text (e.g. ``) so the line reads as prose again, then re-run What I verified and found correct
Not independently verified
|
main moved again while this PR sat DIRTY: #2147 landed the payload-cwd and env -S fixes, taking guardrails to 0.24.0 and touching block-dangerous-git.sh, which this branch also edits. Resolutions: - plugins/guardrails/hooks/block-dangerous-git.sh — main's three-field hook::jq_fields call ('.tool_input.command' '.cwd' '.tool_name') is kept verbatim, with this branch's NUL fail-closed block appended after it. The branch's clarification that the allow-on-unparsable path is NOT what the NUL check covers is spliced back into main's comment, since a NUL check now sits directly beneath it. - lib/hook-utils.sh auto-merged this time. Verified by hand that the flag is still computed BEFORE the strip; #2147 touched hook::git_resolve_index, a different function. - 16 CHANGELOGs and 16 manifests re-resolved one patch above main's new numbers. Every manifest had again auto-merged to main's number with no bump. Twice on this branch. A NUL byte this branch had accidentally written into plugins/guardrails/CHANGELOG.md is removed here, and it is the reason this merge needed a second pass. git classifies a file containing NUL as BINARY, so the textual three-way merge never ran on that changelog: it kept ours wholesale and silently discarded main's entire 0.24.0 section, with no conflict marker to show for it. Caught by a byte count, not by review. The escape is now spelt as the literal text it was meant to be. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 2m 2s —— View job Security review complete
Scope: all 55 changed files at HEAD No security issues foundThis round re-verifies the code as it now sits after two merges from
This is the fifth automated review pass on this PR; the substantive findings from the first four rounds (stale blast-radius count relative to #2120, the ShellCheck |
|
Claude finished @kyle-sexton's task in 3m 58s —— View job Code review complete
Scope: correctness, architecture, error handling, test coverage, maintainability — per the lane split, security implications (e.g. the blast-radius/unflagged-caller question already raised in the security lane's latest round) are left to Traced correctness of the core changeWalked the rewritten
This matches the design the PR body argues for, and I didn't find a logic error in it. Two items from prior review rounds are now resolved on this tip, confirmed directly (not re-flagged)
Finding (inline, minor)Posted on What I did not independently verifyThe PR body's test-suite pass/fail counts and boundary-measurement tables — no Everything else checked and found consistent
|
main moved twice more (#2140, #2149) while this PR sat DIRTY. One conflict: plugins/source-control/CHANGELOG.md, where #2140 took 0.51.4 — the slot this branch's entry held after the second merge. Same resolution as before: this branch's entry moves to 0.51.5, main's 0.51.4 is kept below it, manifest bumped to match. lib/hook-utils.sh and the 16 vendored copies were untouched by both commits; sync --check still reports 16/16. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 1m 30s —— View job Security review complete
Scope: all 55 files this PR changes relative to No security issues foundThis is a
Re-affirming what the prior rounds already established and which still holds on this tip:
|
|
Claude finished @kyle-sexton's task in 2m 26s —— View job Code review complete
Scope: correctness, architecture, error handling, test coverage, maintainability — security is left to the No new findingsThis sync ( Independently re-verified that the two CI-blocking issues from earlier rounds are still resolved at this tip:
Still outstanding (already flagged, unchanged, non-blocking)The minor test-coverage-parity gap from the previous round's inline comment on Not independently verified
|
Suite and boundary results after the three mergesScoped honestly: this is what was re-run on this host after the merges, and what was not. The host is Re-run and green
Not re-run — stated rather than implied
Boundary, re-measured against CURRENT
|
| row | main @ 04822fc4 |
this branch @ 043d60ce |
|---|---|---|
git commit --no-verify<NUL>x (block-no-verify) |
0 ALLOWED | 2 blocked |
git push --force<NUL>x (block-dangerous-git) |
0 ALLOWED | 2 blocked |
clean --no-verify |
2 | 2 |
clean --force |
2 | 2 |
harmless git status |
0 | 0 |
Both bypasses are still live on today's main and still closed here. No clean command changed
verdict in either direction.
Fixture integrity was checked rather than assumed: each payload is built with jq -n carrying a
JSON \u0000 escape, and every fixture was confirmed to decode to exactly one real NUL byte
(jq -j .tool_input.command | tr -dc '\0' | wc -c = 1) before any exit code was read. A NUL fixture
that silently fails to construct reads as a clean pass and would mean nothing.
git commit --no-veri<NUL>fy is deliberately excluded from the live rows: stripping reassembles
a real --no-verify there, so main already blocks it and it evidences nothing about this change.
…t too Review finding: both guards emit the same three BLOCKED / What a guard can / Fix: reissue lines by design, but only block-no-verify.test.sh asserted on the message content — this file's run_nul rows checked the exit code alone. A message edited in one guard and not the other would pass both suites, because the verdict is identical either way and nothing read the text. Mirrors the two assert_contains checks from block-no-verify.test.sh against this guard's own dangerous form. The duplication is deliberate and the comment says so, since the shared wording is exactly what makes the drift invisible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 47s —— View job Security review complete
Scope: all 55 files this PR changes relative to No security issues foundThis is a It adds two Re-affirming what prior rounds on this thread already established and which still holds at
|
|
Claude finished @kyle-sexton's task in 1m 29s —— View job Code review complete
Scope: this sync adds exactly one commit, touching exactly one file: The change resolves the parity gap flagged in the prior roundThe previous code-review pass on this PR left an inline comment: This commit adds a
No correctness issues found in the addition. The new comment block explaining why the duplication is intentional (both guards share wording by design; exit-code-only coverage can't see a message-only drift) is a good call — it preempts a future reader from "deduplicating" the assertion into a shared helper and losing the parity check the duplication exists to provide. Not independently executed
|
|
Claude finished @kyle-sexton's task in 35s —— View job Security review complete
Scope: all 55 files this PR changes relative to No security issues foundThis is a It adds one paired test case to each guard's suite — an all-NUL command ( Re-affirming what prior rounds on this thread already established and which still holds at
|
faff1a4 to
ceedf32
Compare
|
Claude finished @kyle-sexton's task in 47s —— View job Security review complete
Scope: all 55 files this PR changes relative to No security issues foundThis is a It adds one pinning pair to each guard suite: an all-NUL command asserted to be blocked with the NUL reason on stderr, and an empty command with no NUL asserted to still take the existing empty-command skip. This is the same content already reviewed and cleared in the immediately preceding security-review round on this thread (posted against the equivalent commit) — verified by diffing the two, byte-for-byte identical addition, just carried forward. No change to Re-affirming what prior rounds on this thread already established and which still holds at
|
…th guard suites The NUL-flag check running BEFORE the empty-command skip is the load- bearing line of the #2122 fix, and nothing in either suite failed if a future edit inverted it: the all-NUL row asserted only the exit code, which a guard that refuses every empty command would also produce. Each guard suite now pins the pair that makes the ordering observable at the boundary: the all-NUL payload's block must carry the NUL reason on stderr, and an empty command with no NUL must still take the skip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ceedf32 to
4aff456
Compare
|
Claude finished @kyle-sexton's task in 56s —— View job Security review complete
Scope: all 55 files this PR changes relative to No security issues found
The two commits since then (
No change to Re-affirming what prior rounds on this thread already established and which still holds at
|
…y gate (#2159) Closes #2158 ## Problem `changelog-parity-gate` failed PR #2130 twice with `UNDOCUMENTED BUMP: markdown-format went 0.11.3 -> 0.11.4 ...` even though `plugins/markdown-format/CHANGELOG.md` carries `## [0.11.4]` at line 6, column one — a required merge gate confidently asserting the opposite of the truth, while the same command passed locally. PR #2135 then failed the same gate on **every one of its sixteen bumped plugins** (smallest flagged changelog: 15 KB). The regression landed on `main` at 15:19:19Z in #2154 and blocks **every PR that bumps a plugin whose changelog exceeds roughly one stdio buffer (~4 KB)** — which newest-first ordering makes essentially all of them. ## Blast radius — precisely Confined to the `--check-bump` path: `has_heading` is defined inside that branch and called in exactly two places (the head-side check and the base-side `git show "$base:$changelog" | has_heading`). `--check` and `--check-order` read changelogs through `changelog_versions`, whose `grep -oE` stages drain stdin with no early exit and cannot take SIGPIPE. So the failure class is exactly "PRs that bump a manifest version"; both failing call sites go through the one function this PR fixes. ## Root cause `has_heading` runs a pipeline under `set -o pipefail` whose reader `exit`s on first match: ```bash rendered_lines - | awk -v h="$heading" 'index($0, h) == 1 { found = 1; exit } END { exit !found }' ``` The newest heading sits near the top, so the reader exits while `rendered_lines` is still writing; the writer dies of SIGPIPE (141) and pipefail reports the pipeline — the FOUND heading — as a failure. Reproduced deterministically in an `ubuntu:24.04` container at the exact CI merge commit `ba4b72fb`: `PIPESTATUS=141 0` and the byte-identical CI error under **gawk** (what the `ubuntu-24.04` runner resolves `/usr/bin/awk` to — gawk outranks mawk in the alternatives system, and only the gawk mechanism explains CI failing 15 KB files). mawk survives the closed pipe and passes at every size tested, and Windows/MSYS process timing lets the writer finish first — which is why the failure existed only in CI. The suite's 55 fixtures all fit in one buffer — hence `PASS=55` in the very job that then failed on the real file. ## Fix The reader consumes to EOF; `END { exit !found }` decides. Correct **by construction**: no reader exits early, so no writer can ever take SIGPIPE, under any awk — the failure is impossible, not rarer. Chosen over restoring the pre-#2154 single-awk form because it preserves the one-tracker-three-modes property (`rendered_lines` shared by all modes, so they cannot drift). Both `has_heading` call sites are covered (same function); the script has no other early-exiting reader downstream of a pipe (`changelog_versions` greps drain stdin; the `grep -m1` format probe reads a file directly, not a pipe). ## Regression fixture — with its fails-against-unfixed proof, per engine New `--check-bump` case: ~260 KB changelog, new `## [1.1.0]` entry near the top, expected pass. 260 KB deliberately exceeds the 64 KB pipe **capacity**, so against the unfixed script the writer blocks mid-write and the SIGPIPE is deterministic, not a winnable race. | script | gawk | mawk | |---|---|---| | unfixed | **FAIL=1** (exact #2130 error text) | PASS (mawk survives the closed pipe) | | fixed | 56/56 | 56/56 | The fixture's guard is therefore **engine-conditional — it discriminates only where `awk` resolves to gawk**, which is what the runner resolves; this is recorded in the fixture comment. The fix itself is engine-independent. Windows local: 56/56 fixed. ## Related - #2158 — the defect issue this closes - #2154 — shipped the early-exit reader; this is its first contact with a production-size changelog - #2130, #2135, #2155 — blocked by this regression; once this lands, their recomputed merge refs carry the fixed gate and need no branch-side changes - Known adjacent gap, tracked separately and deliberately NOT fixed here: the gate cannot see a **deleted** predecessor heading — a change set that renames the top heading in place (rather than adding a new one above it) destroys a shipped release section while `--check` and `--check-order` both pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @kyle-sexton's task in 53s —— View job Security review complete
Scope: all 55 files this PR changes relative to No security issues foundThis is a The tip commit, Re-affirming what prior rounds on this thread already established and which still holds at
|
Resolves the markdown-format version collision: #2135 landed the vendored hook-utils NUL-reporting bump as 0.11.4 on main while this branch also claimed 0.11.4. main's entry keeps 0.11.4; this branch's two no-git fixes move to 0.11.5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#2135 bumped source-control to 0.51.5 on main — the number this branch already claimed — so the two 0.51.5 sections collided in the changelog. Resolved by renumbering this branch's entry to 0.51.6 and keeping main's 0.51.5 under its own heading with its own content. Neither section is relabelled, absorbed, or dropped: the heading list of all three touched changelogs differs from main by exactly one addition, and each manifest is exactly one increment above main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflict: plugins/source-control/CHANGELOG.md. Main's #2135 landed a 0.51.5 entry for the shared hook-utils NUL fix, which is the version this branch had claimed. Both entries are kept -- main's stays at 0.51.5 and this change moves up to 0.51.6, with the manifest bumped to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflicts and how they were resolved: * block-no-verify.sh -- main's #2122/#2135 NUL-byte work rewrote the same comment this change rewrote. Both survive: the comment now says that rc 1 from hook::jq_fields means ONLY an unparsable payload, because a missing jq can no longer reach that line, and keeps main's note that the remaining allow-on-unparsable path is not what the NUL check covers. * every CHANGELOG.md and the two conflicting plugin.json files -- main's #2135 landed the same 16-plugin lib bump this change needs, so main's side was taken wholesale and this change's entry and bump were re-applied on top. Every carrying plugin is therefore strictly above what main now carries. * guardrails takes a MINOR bump (0.24.1 -> 0.25.0), not a patch: it now denies calls it previously allowed. * source-control goes to 0.51.7 rather than 0.51.6. Main's #2135 took 0.51.5, and 0.51.6 is reserved for PR #2167, which touches the same plugin and should merge first. lib/hook-utils.sh auto-merged (main's NUL handling plus this change's posture block); the 16 plugin copies were re-synced from it afterwards rather than trusted to the merge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sted files reach the root config without git (#2130) Follow-up to #2121. **Both gaps are live on `main` right now** — not stale review findings. Reproduced independently: the two new tests, run against `main` own unmodified hook, give **PASS=136 FAIL=2**. With the change, **138/0**. ## The two defects **1. `markdown-format.sh:119` calls `hook::repo_root` raw.** With `git` and `jq` both absent, a nested file makes the opt-in pre-check read an opted-in repo as opted-out, and the `jq` notice is swallowed. A repository that did opt in is treated as if it had not, silently. **2. The `REPO_ROOT` guard at `229-237` covers only the `CLAUDE_PROJECT_DIR`-set case.** The membership scope it exists to fix is gated on that variable being **unset**, and the no-git fixture runs unset — so the configuration the fix was written for is still broken for nested files. `hook::repo_root` falls back to the file own directory, the root markdownlint config is never discovered, and the edit is skipped with no diagnostic. The second is the one #2121 review comment described as "leaving the normal nested-docs case unfixed". That reading was correct and remains correct at `main`. ## The change Resolve the repository root from the **filesystem** rather than from a variable: walk up for a `.git` entry, accepting a directory **or** a file so linked worktrees and submodules resolve. Git own answer is returned untouched whenever git produced one, and `CLAUDE_PROJECT_DIR` is kept as a further fallback, so the case `main` already handles is subsumed rather than replaced. Four commits, ordered so the defect is demonstrated before it is fixed: ``` 9cbb3c2 tests (red against main) 4d2cd84 fix b42a935 coverage 66a100d changelog + version ``` ## Verification - Baseline `main` **135/0**; with the change **138/0**; the two new tests **red** against `main` own hook (independently reproduced at `e47964ca`). - `main` newest positive override test passes unchanged under the replacement — verified rather than assumed, after confirming no `.git` sits on the temp-dir ancestor chain that would have made the walk answer differently on this host. - `shellcheck -x -S warning`, shell-portability, silent-skips, markdownlint, and changelog-parity all clean. ## Stated rather than glossed — three things not confirmed - **The POSIX-host spawn count was simulated**, by addressing the repo in git own path spelling on a Windows host. It was never observed on a real POSIX host. - **A perf claim was wrong on first pass and is corrected here.** An unconditional ~140ms Git Bash cost was expected; measurement showed **zero** extra spawns on Git Bash, because `rev-parse --show-toplevel` and `dirname` never produce the same path spelling there. The extra probe fires only where the spellings agree — 2 to 3 spawns, root-level files only. - **One `PASS=133 FAIL=1` intermittent** was seen at an abandoned intermediate commit. It was unnamed, did not reproduce in five runs at the successor commit, and never recurred in any run backing these numbers. Unconfirmed rather than dismissed. ## Provenance Prepared as a cherry-pickable offer while #2121 was open; #2121 merged at `5f92d946` without taking it, leaving no branch to cherry-pick onto, so this is cut from `main` instead. The offer comment on #2121 remains accurate for what it offered at the time. Fixes #2134 ## Conflict resolution against a moving `main` `main` moved under this branch twice and the PR went `DIRTY`. The version collision was resolved twice, and the branch now carries the second resolution's numbers. - **Conflict, both times: `plugins/markdown-format/CHANGELOG.md`.** `main` took `0.11.2` (#2120's shared `hook-utils.sh` NUL fix), then `0.11.3` (#2147). This branch's entry moved up each time and now sits at **`0.11.4`**, with `main`'s `0.11.3` and `0.11.2` kept below it, order strictly descending. - **`plugin.json` auto-merged to `main`'s number on both passes, silently leaving no bump at all** — no conflict marker, and only `check-changelog-parity.sh --check-bump` catches it. Bumped to `0.11.4` to match the changelog. This is the trap worth carrying forward: a manifest version collision does not conflict, it resolves to whichever side git saw last. - `check-changelog-parity.sh --check-bump origin/main` clean at the resolved tree. **History note, stated rather than glossed.** This resolution was first delivered as two merge commits (`git merge origin/main`, never a rebase, since force-push is blocked here). The branch was subsequently **force-pushed** to a rebased, linear history carrying the same resolved content and the same `0.11.4` numbers, which discarded those merge commits. The shipped branch is therefore a rebase, not the merge described above; the resolution it carries is the same one. **Version coordination with #2135:** that PR also bumps `markdown-format`, and after its own merges of `main` it currently takes `0.11.4` as well. Whichever of the two merges second must re-bump — the manifests will auto-merge to the same number without conflicting, exactly as described above. ## Related - Fixes #2134 — the two no-git root-resolution defects this PR closes. - Refs #2121 — the predecessor whose review comment identified the nested-docs case; merged at `5f92d946` without taking the offered follow-up, which is why this is cut from `main`. - Refs #2120 — merged into `main` mid-flight; its shared `hook-utils.sh` change took the `0.11.2` slot this branch's changelog entry originally occupied. - Refs #2135 — concurrent `markdown-format` version bump; see the coordination note above. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… last (#2171) No linked issue ## Summary `babysit_merge.branch_rules` reads the right endpoint — `repos/{repo}/rules/branches/{branch}` — but folds it as if each rule type appeared at most once. That endpoint returns one rule of a given type **per ruleset** governing the branch, and the fold is a plain assignment inside the loop, so each ruleset overwrote the previous one and only the last survived. Measured live on this repository. `main` is governed by two rulesets carrying required contexts, both org-sourced: | ruleset id | contexts | | --- | --- | | 17989001 | `pr-title / pr-title`, `do-not-merge / do-not-merge`, `ci-status` | | 19388547 | `security-review / security-review` | 19388547 is returned last, so the helper reported `effectiveRules.requiredContexts` as **only** `["security-review / security-review"]` — three of four required contexts silently dropped. The single-rule assumption held under classic branch protection, which has exactly one such rule. It does not hold under rulesets. **Impact: a reporting and defence-in-depth defect, not a merge-safety hole.** The gate refuses independently on `mergeStateStatus not in READY_MERGE_STATES` (`{CLEAN, HAS_HOOKS}`), and GitHub integrates required checks into that field — live `MergeStateStatus` introspection gives `CLEAN: "Mergeable and passing commit status"`, `UNSTABLE: "Mergeable with non-passing commit status"`, `BLOCKED: "The merge is blocked"` — so a **failing** required context cannot present as `CLEAN`/`HAS_HOOKS`. The **absent**-context case is derived from required-status-check semantics, not observed: every required context runs on every PR here, so there was no live PR to reproduce it against. Unconditional `if failing:` / `if pending:` blockers built from the whole rollup cover the rest. What the bug cost is the **explanation**: `effectiveRules` and the `required checks not satisfied` blocker both under-reported, so an operator could not see which contexts actually govern. One safety-adjacent consequence, in the **over-holding** direction. `base_is_unprotected = not required_reviews and not required_context_list`, and this repo's `pull_request` rule sets `required_approving_review_count: 0`, so the flag hangs entirely on `requiredContexts` being empty. Under the bug that meant "the **last** status-checks rule is empty"; fixed, it means "**all** of them are". "All empty" is a subset of "last empty", and both consumers of the flag only ever *add* blockers — so the bug produced a **false hold** on a superset of cases and never retired one. Latent here, since neither ruleset carries an empty context list. It is not a fail-open. ## Fix **Commit 1 — `required_status_checks`.** - Accumulate contexts into a set across **all** rules, reported `sorted()`. Deduped because two rulesets may legitimately require the same context; sorted so the reported set is stable regardless of the order the API returns rulesets in. - Entries carrying no `context` are dropped rather than carried. Previously a missing key produced a `None` that reached the reconciliation loop and surfaced as a literal `"None"` required context; it would also crash the new sort. This is a visible change in the helper's output. - `base_is_unprotected` needs **no code change** and is confirm-safe once the union is correct: the union is empty only when no ruleset requires anything, which is exactly what the flag means. **Commit 2 — `pull_request`.** The same assign-in-loop shape sat three lines below, in the same function. Not observed misreporting — exactly one `pull_request` rule (ruleset 17988999) governs the branch today — but nothing prevents a second, and a ruleset requiring 2 approvals returned before one requiring 0 would have reported 0. `requiredApprovingReviews` now takes the `max`, `requireThreadResolution` the `OR`. That fold direction is deliberately argued from safety, not from GitHub's internal composition rule, which this change does not claim to know: **max/OR can only ever over-report**, which holds a PR for a human, where last-wins can under-report and release one. This one could lose a blocker outright, not merely under-report: a trailing `pull_request` rule with `required_approving_review_count: 0` erased an earlier ruleset's requirement and **dropped the `needs N approving review(s)` blocker**. Keep that distinct from the `base_is_unprotected` consequence above, which runs the other way (over-hold). A malformed-but-present count reads as **one** review, never zero — reading it as zero would be the single fail-open step in a fold whose whole argument is that it can only over-report. Severity split, kept separate on purpose: - `requiredApprovingReviews` — a **fail-closed behaviour change**, not currently firing. It feeds both `base_is_unprotected` and the `needs N approving review(s)` blocker. - `requireThreadResolution`, `requireSignatures`, `requireLinearHistory` — **report-only**. Set into the summary, never consumed as a blocker; the gate holds on unresolved threads unconditionally via `if threads:`. They do not borrow the first item's severity. Version bumped `0.51.5` → `0.51.6` with a matching CHANGELOG entry, following the plugin's convention — every comparable `fix(source-control)` commit in recent history (`cf743d61`, `ac27ea5a`, `30be2a0b`, `e6ee72ef`) bumped the manifest version. ## Verification New module `tests/test_babysit_merge_branch_rules.py` (7 tests), each run against the fixed code and against the unfixed file: | test | fixed | unfixed | | --- | --- | --- | | `test_contexts_from_every_ruleset_survive` | ok | **FAIL** — `['security-review / security-review'] != ['ci-status', 'do-not-merge / do-not-merge', 'pr-title / pr-title', 'security-review / security-review']` | | `test_a_context_required_by_two_rulesets_is_reported_once` | ok | **FAIL** — `['ci-status'] != ['ci-status', 'pr-title / pr-title']` | | `test_a_context_less_entry_is_dropped` | ok | **FAIL** — `[None] != []` | | `test_empty_trailing_rule_leaves_the_base_protected` | ok | **FAIL** — `True is not false` (`baseUnprotected` flipped) | | `test_the_strictest_approval_count_wins` | ok | **FAIL** — `0 != 2` | | `test_thread_resolution_required_by_any_ruleset_survives` | ok | **FAIL** — `False is not true` | | `test_no_context_anywhere_still_reports_an_unprotected_base` | ok | ok | Six regress. The seventh passes both ways **by design** — it is the over-correction guard, pinning that a genuinely context-less base still reports unprotected. It is labelled as such in its class docstring so nobody counts it among the regression tests. `test_empty_trailing_rule_leaves_the_base_protected` asserts on `evaluate()`'s `baseUnprotected` and blocker list, not on `branch_rules` alone, and its fixture sets `required_approving_review_count: 0` — with a non-zero count the flag would be `False` against the unfixed code too and the test would prove nothing. **End-to-end against a live CLEAN PR.** The fix feeds four contexts into the reconciliation matcher where one went before, so a context that failed to match its rollup entry would convert a silent under-report into a spurious blocker. Ran `evaluate()` against #2150 (CLEAN, all four contexts green): ``` requiredContexts: ["ci-status", "do-not-merge / do-not-merge", "pr-title / pr-title", "security-review / security-review"] requiredChecks: all four found: true, satisfied: true, category: "success" baseUnprotected: false blockers: [] ready: true mergeStateStatus: CLEAN ``` **Suite.** `bash plugins/source-control/skills/babysit-prs/scripts/engine.test.sh` exits 0 — 612 tests OK, `ruff` (CI pin) clean, guarded-wrapper behaviour all PASS. No shell files changed, so no shellcheck surface. **Changelog parity.** `--check` and `--check-order` pass. `--check-bump origin/main` passed 8/8 consecutive local runs on GNU Awk 5.4.0. Recording that as an observation, not a health claim: the gate is reported to have a SIGPIPE race after #2154, and local green does not establish CI green. **Not verified — recorded, not claimed.** A ruleset carrying **bypass actors** is the one shape where GitHub could plausibly report `CLEAN` to a bypassing identity while a required context is unmet; there the unmet-required blocker would be the only defence, which raises the severity of the under-report. Untestable here — every ruleset carries `bypass_actors: []`. Likewise the absent-required-context case above. Neither refutes the characterisation; both are open. **Two things worth knowing about the union.** Adding `security-review / security-review` does not mint a false blocker when that check skips: `babysit_checks.py` treats `NEUTRAL`/`SKIPPED`/`SUCCESS` as success states, so a name-stable skipped check still satisfies. And the deliberately loose context matcher now processes four contexts where it processed one — this **amplifies** pre-existing false-match exposure rather than introducing it, which is exactly what the live `evaluate()` check above is there to catch. **Sibling scripts.** `babysit_resolve_thread.py` reads no branch rules, and a repo-wide search for `rules/branches` / `required_status_checks` / `effectiveRules` finds no other fold and no other consumer — `babysit_merge.py` is the only one. `plugins/source-control/skills/setup/SKILL.md:121` documents the same endpoint to operators but instructs them to read the whole payload and flag zero-reviews-and-zero-contexts repos, so it carries no one-rule-wins assumption and needs no change. ## Related Refs #2130, #2135 — reported as observed there. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…peration guards (#2178) ## What `hook::require_jq` was `command -v jq && return 0`, else a once-per-session notice and **`exit 0`** — the whole hook skipped, the tool call proceeds. Measured against `origin/main`, with the jq-present column as the discrimination control: ``` jq PRESENT jq HIDDEN dangerous push DENY ALLOW <-- the guard was skipped entirely safe command ALLOW ALLOW ``` The same two scripts fail **closed** on the other input they cannot parse: above `MAX_COMMAND_LEN` (16384) a command is treated as obfuscation and blocked unread. Two opposite postures toward "I cannot read this input" in one file — so an author who could not fit a dangerous command under the ceiling could simply be on a machine without `jq`. ## The disposition: fail CLOSED, scoped to the irreversible-operation guards After (same harness, same machine): ``` block-dangerous-git.sh jq PRESENT jq HIDDEN dangerous push DENY DENY safe command ALLOW DENY block-no-verify.sh jq PRESENT jq HIDDEN commit --no-verify DENY DENY safe command ALLOW DENY posture control (unchanged) block-convention-violation.sh jq PRESENT=ALLOW jq HIDDEN=ALLOW block-hook-bypass.sh jq PRESENT=ALLOW jq HIDDEN=ALLOW block-noncanonical-commit.sh jq PRESENT=ALLOW jq HIDDEN=ALLOW ``` **The (safe, jq HIDDEN) = DENY cell is a real cost, not an oversight.** These guards run on every Bash/PowerShell tool call; without `jq` they cannot read the command at all, so they cannot tell a dangerous one from a safe one and deny both. On a `jq`-less machine every matched tool call is blocked until `jq` is installed or the guard's kill switch is set. That is the hard dependency option 2 named. Option 3 (a `jq`-free substring pre-check) was rejected and is not implemented. The kill switch is still a real exit: `hook::check_enabled` runs *before* the gate, so `block_dangerous_git_enabled=false` bypasses the guard on a `jq`-less machine. Asserted. ## Which hooks are in the class — mechanical, not a taste judgement The criterion is **internal consistency**: a hook is fail-closed iff it *already* fails closed on another unparsable-input condition. Today that is a `MAX_COMMAND_LEN` ceiling, and **repo-wide that is exactly two files** — `block-dangerous-git.sh` and `block-no-verify.sh`, the two the issue names. That is not a coincidence: intra-script contradiction is what #2146 reports, and a script with no length-ceiling posture has no contradiction to resolve. **Considered and deliberately excluded**, so this is not a silent scoping choice: | Hook | Why not | | --- | --- | | `block-hook-bypass` | exits 2 and carries the same "the only supported deliberate bypass is the kill switch" sentence — but it guards a **file write** (`cat > path`), trivially reversible, and has no length ceiling | | `block-noncanonical-commit` | guards a message *shape*; a mangled message is recoverable by `--amend` | | `secret-pattern-detection`, `hardcoded-path-check`, `block-convention-violation` | all guard reversible file writes | | other-plugin blocking `require_jq` callers (`context-guard/zone-gate`, `source-control/pr-*-gate`, `autonomy/lane-stop-gate`) | checked repo-wide; **none** carries a length ceiling | Severity is a slope; "already fails closed elsewhere in the same script" is a line. `require-jq-posture.test.sh` pins the membership both ways, so a hook that grows a ceiling and keeps the fail-open gate fails, and so does a hook that adopts the blocking gate without one. ## Helper design: a sibling function, not a parameter `hook::require_jq_blocking` alongside the unchanged `hook::require_jq`. **Why not a flag on the existing function:** a parameter's *omitted* value has to default to something, and the safe-looking default (fail open, today's behaviour) means a guard that should fail closed but whose flag someone forgot fails open **silently** — which is the exact defect this PR fixes, reintroduced at the API. Two names make the posture greppable, make the fail-closed path impossible to reach by accident, and make omission a visible choice. **Why not branch at the call sites:** the issue's own acceptance says the reasoning belongs at the helper, and a call-site branch leaves the decision point still unexplained. (It also would not have avoided the 16 plugin bumps: `sync-hook-utils.sh --check-bump` is content-based, so even a comment-only lib edit requires them.) **The reasoning is at the helper.** One `TWO POSTURES, AND WHY THERE ARE TWO` block sits above both functions — why fail-open is the default, why a minority must not be, the membership criterion, the exclusions, the disclosed cost, and why two functions rather than a flag. The call-site comments now say "this asserts the behaviour; that explains it", and the posture test asserts the block is actually there. ## The control that FAILS against current `main` `require-jq-posture.test.sh` was run **unchanged against `origin/main`'s guardrails plugin** (`git archive origin/main plugins/guardrails`, hashes verified equal to `origin/main`'s blobs): ``` FAIL: block-dangerous-git.sh defines MAX_COMMAND_LEN but does not call hook::require_jq_blocking FAIL: block-no-verify.sh defines MAX_COMMAND_LEN but does not call hook::require_jq_blocking FAIL: hook-utils.sh's posture block mentions 'TWO POSTURES' FAIL: jq HIDDEN, dangerous push: DENY ... : expected 'DENY', got 'ALLOW' FAIL: jq HIDDEN, commit --no-verify: DENY ... : expected 'DENY', got 'ALLOW' ... PASS=21 FAIL=15 SUITE EXIT: 1 ``` with the four cells against `main` reproducing the issue's table exactly (`DENY/ALLOW` over `ALLOW/ALLOW`). Against this branch: **PASS=36 FAIL=0**. ## How `jq` was hidden — and how that measurement was kept honest A `BASH_ENV` file defines a `command` shell function that reports `jq` absent and forwards every other lookup to the real builtin, plus a `jq` function that fails like a missing binary. **`PATH` is untouched.** Stripping `PATH` directories also removes `git`, which these guards invoke, and a guard that cannot find `git` produces the same ALLOW for an entirely unrelated reason. The suite prints a precondition line measured **inside** the hidden environment and refuses to read a verdict until it holds: ``` PRECONDITION (measured inside the jq-hidden environment): jq=hidden git=visible bash=visible path-to-jq=intact ``` `path-to-jq=intact` is `builtin command -v jq` still resolving — proof the **lookup** was hidden and the tool was not removed. Every `jq` probe in `hook-utils.sh` is a `command -v jq` (verified: 7 sites, all of that form), so the override reaches all of them. A second check sources the real `hook-utils.sh` inside the hidden environment and asserts the gate's own predicate sees no `jq`, plus the inverse without the override. **The harness caught itself once.** The first `origin/main` run archived only `plugins/guardrails/hooks`, so `block-dangerous-git.sh` could not source its bundled PowerShell classifier from `<plugin-root>/lib` and exited early — producing `ALLOW` in **all four** cells, including `jq PRESENT / dangerous push`. The jq-present discrimination control is what flagged it as a broken harness rather than a measured result. Fixed by archiving the whole plugin. This is the failure mode the issue says invalidated three prior attempts. ## Proof the fixture reached the path under test Under `jq` hidden, the denial is asserted to be the **new** path and not some unrelated failure: - names `jq` as the missing prerequisite; - carries the documented install route `https://jqlang.org/download/`; - names the guard's own kill switch (`block_dangerous_git_enabled` / `block_no_verify_enabled`); - is **not** the fail-open skip notice (`hook skipped for this session` asserted absent). And the advisory control asserts the inverse — that the fail-closed denial text is absent from every advisory hook's stderr. ## Blast radius `sync-hook-utils.sh --check-bump` requires every carrying plugin to bump when the shared lib changes, so all 16 are bumped with a CHANGELOG entry (the precedent set by b20e70a / #2147). The 15 non-guardrails entries state honestly that the lib gained a fail-closed sibling with **no behaviour change in that plugin** — nothing outside `guardrails` calls it. `guardrails` takes a **minor** bump (`0.24.1` → **`0.25.0`**), not a patch: it now denies calls it previously allowed. `origin/main` moved under this branch mid-flight — #2135 landed the *same* 16-plugin lib bump for its NUL-byte fix, so every version collided. Resolved by taking main's side of every CHANGELOG and manifest wholesale and re-applying this change's entry and bump on top, so main's entries survive intact and every plugin here is strictly above what main now carries. `block-no-verify.sh` conflicted textually on the very comment both changes rewrote; both survive (see the merge commit message). `require-jq-notice-isolation.test.sh` needed one adjustment: its discovery matched `hook::require_jq` as a substring and so would have swept in `hook::require_jq_blocking`, whose callers have no notice key to collide. The match is now anchored, and the test's own subject is documented as not applying to the blocking gate. ## Verification run on this branch | Check | Result | | --- | --- | | `require-jq-posture.test.sh` (new) | PASS=36 FAIL=0 | | same suite vs `origin/main` | PASS=21 **FAIL=15**, exit 1 | | `require-jq-notice-isolation.test.sh` | PASS=2 FAIL=0 | | `block-no-verify.test.sh` | see CI | | `block-dangerous-git.test.sh` | see CI | | `scripts/sync-hook-utils.sh --check` / `--check-bump origin/main` | pass | | `scripts/check-changelog-parity.sh --check` / `--check-bump origin/main` | pass | | `scripts/check-silent-skips.sh` | pass | | `scripts/check-shell-portability.sh --paths <changed>` | pass | | `shellcheck -x -S warning <changed>` | clean | ## Merge-order note This PR and #2167 **both bump `plugins/source-control`** (this one because it carries the shared `hook-utils.sh`). Main is at `0.51.5`; #2167 now claims `0.51.6` and this PR claims **`0.51.7`**, so it stays strictly greater either way. **Merge #2167 first** — it is one plugin and cheaper to redo. If this one lands first instead, #2167 must re-bump to `0.51.8`. Closes #2146 ## Related - #2124 / #2147 — a separate live bypass in the same guard, and the precedent for a 16-plugin lib bump - #2145 — the contract-line inaccuracy in the same file - #1938 — the stranded post-merge review-findings sweep - #2167 — the other PR in this pair; collides with this one on the `source-control` version bump --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
No linked issue ## Summary A post-merge canary for the failure class in #2691: a merge that silently deletes content another recently-merged commit had just added, leaving no `Revert:` marker and no failing test. Item 4 of that issue. Detection only — it runs on `push` to `main`, is not in `ci.yml`, and is not wired into `ci-status`, so it can never gate a merge. **It also corrects the issue's premise.** This was not a stale-*base* failure, which means `strict_required_status_checks_policy` would not have prevented it. Details below. ## Fix `scripts/check-silent-revert.sh` blames the lines each merge deleted against its own parent and reports when a large block traces to a **single** commit inside a recency window. Plus `scripts/check-silent-revert.test.sh` (26 hermetic cases), two data files, and `.github/workflows/silent-revert-canary.yml`. ### Why blame-of-deleted-lines, and not the alternatives Three designs were measured against the real history before one was chosen. **Merge-base staleness — tested and rejected on evidence.** It exonerates all three real incidents: ``` $ git merge-base --is-ancestor f603880 refs/pull/2641/head && echo YES YES $ git grep -c "read-only supporting allowlist" refs/pull/2641/head -- 'plugins/disk-hygiene/**' (no output — zero occurrences) $ git grep -c "read-only supporting allowlist" f603880 -- 'plugins/disk-hygiene/**' f603880:plugins/disk-hygiene/skills/clean/SKILL.md:1 f603880:plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py:1 ``` PR #2641's head had #2639 **in its ancestry** and **zero occurrences of #2639's content in its tree**. #2639 shows the identical shape against #2635. These branches were up to date with `main` in *history* and stale only in *content* — a bad conflict resolution or a force-push from an older worktree. So `strict` would have passed all three merges, and a merge queue would have too (CI was green — the tests were deleted alongside the code). **That reframes the canary: for this class it is not defence in depth behind a real fix, it is the only control that fires at all.** No ruleset is touched here and nothing in `github-iac` changes; ADR 0001 stands as written. **Curated marker strings (the issue's own suggestion 3) — rejected.** Only catches what someone pre-registered, and nobody had registered #2632, #2635 or #2639. Registration happens *after* you know a fix matters, which is the knowledge the incident destroys. **PR-creation-time overlap — rejected as non-discriminating.** At the 17-concurrent-PR rate ADR 0001 records, nearly every PR has siblings landing while it is open. ### False-positive strategy A canary that cries wolf gets disabled, which is worse than none. 1. **Volume**, aggregated **per culprit commit** — summing across culprits would re-admit ordinary iteration. 2. **Recency window** (40 first-parent commits). Stated limitation: content reverted from outside the window is missed by design. 3. **Intent, in constrained forms only** — a `Revert "` subject, a `This reverts commit <sha>` line, or an explicit `Intentional-removal:` trailer. Deliberately *not* a substring search for "revert": a body reading "this does not revert X" would silence a real finding. 4. **Non-blocking** — post-merge only, outside `ci-status`. **Measured, not assumed** — and measured by running *the shipped script itself* over the last **500** first-parent commits of `main`, not a stand-in: ``` FIRE 853 cc58cbc fix(repo-fleet-hygiene): report bare repos with live working trees (#2633) FIRE 451 9239f15 feat(disk-hygiene): verify redundant checkout evidence (#2641) FIRE 390 6f0a311 fix(repo-fleet-hygiene): restore GraphQL merge evidence and rollups after #2633 FIRE 346 f603880 fix(disk-hygiene): session-honest belt, read-only allowlist, ... (#2639) FIRE 340 91e77fc fix(hook-utils): stop a NUL in a payload value from voiding two blocking guards MEASUREMENT COMPLETE over 500 commits ``` **5 fires in 500 merges — 1%**, zero errors. Three are the confirmed incidents. The other two are real and are not detector bugs: #2640 (390) is the manual *restore* of #2633's revert, and #2135 (340) is a deliberate merge reconciliation the author argues at length in the PR body. Both are pre-recorded in the acknowledgment file so `main` starts green. **Rename detection is deliberately left ON.** An earlier revision passed `--no-renames`, which silently made the shipped detector a *different* detector from the calibrated one: without detection a `git mv` decomposes into delete + add, the delete side reaches the `--diff-filter=MD` enumeration as a whole-file removal, and relocating a large file a recent commit had added would fire. In a repo that restructures skills and docs this often, that is a live false-positive class. With detection on, the shipped script reproduces the calibration corpus exactly (the five rows above), and a test pins the rename case. The narrow cost, stated rather than hidden: content gutted in the same commit that renames its file is not attributed. **The uncomfortable part, stated plainly: 340 is the largest false positive and 346 is the smallest true one. No threshold separates them.** Picking a number inside that 2% gap would be overfitting, so the threshold is 200 — which costs nothing (200 and 300 fire on the identical five commits) and leaves headroom for a smaller future revert. Precision is traded for recall because the miss is expensive and the fire is cheap. Cheap requires a disposition path in **both** directions in time, so there are two: the prospective `Intentional-removal:` trailer (one line in the PR body, which GitHub carries into the squash message), and `scripts/silent-revert-acknowledged.txt` for a fire that can only be judged after the fact — a commit message cannot be amended post-merge. Without the second, one legitimate fire leaves the canary permanently red, and a permanently red canary is one on its way to being deleted. It matches `changelog-parity-baseline.txt` in shape, matches full 40-char SHAs only, and requires a recorded reason, so it is an audit trail rather than a mute button. ### A third incident, previously unfiled Calibration surfaced one #2691 never identified: **#2633's squash dropped #2632's finding rollups (853 lines)**, which #2640 restored by hand the same night. Nobody filed it. That is the clearest argument for automating the detection. ## Verification Real output, all from this branch. **Catches the actual incident** (requirement 1) — replayed against the real merges, and pinned in `scripts/silent-revert-incidents.txt` so CI re-proves it on every run: ``` $ scripts/check-silent-revert.sh --verify-known-incidents ok f603880 fires as recorded (#2639 dropped #2635 (346 lines), 13 minutes later) ok 9239f15 fires as recorded (#2641 dropped #2639 (451 lines), 10 minutes later) ok cc58cbc fires as recorded (#2633 dropped #2632's rollups (853 lines) -- unfiled until now) ok c8470ef stays clean as recorded (docs(conventions) rewrote 129 lines of a doc #2679 had just added) Canary reproduces every recorded incident at the shipped settings. ``` Range mode over the incident window — both fire, the interleaved unrelated merges stay clean: ``` $ scripts/check-silent-revert.sh a95f240~1..9239f15 ok a95f240 feat(disk-hygiene): prioritize tidiness over reclaimable bytes in reports (#2635) ok b7793d3 fix(repo-fleet-hygiene): degrade non-repo paths under --root (#2630) SILENT REVERT SUSPECTED removed by f603880 fix(disk-hygiene): session-honest belt, ... (#2639) content from a95f240 feat(disk-hygiene): prioritize tidiness over ... (#2635) lines lost 346 (threshold 200, window 40 commits) ok eda5ae5 feat(repo-fleet-hygiene): gather merge evidence via aliased GraphQL (#2642) SILENT REVERT SUSPECTED removed by 9239f15 feat(disk-hygiene): verify redundant checkout evidence (#2641) content from f603880 fix(disk-hygiene): session-honest belt, ... (#2639) lines lost 451 (threshold 200, window 40 commits) ``` **Actionable when it fires** (requirement 4) — it names what disappeared, which commit removed it, which commit added it, the per-file split, and the sample quotes back the very content #2691 reported as lost: ``` by file: 235 plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py 156 plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py 26 plugins/disk-hygiene/skills/clean/SKILL.md ... sample of the removed content: | - The skill-frontmatter guard is a fail-closed allowlist. It permits canonical bundled scan/preview | calls made from literal shell words, a small read-only supporting set for cleanup inspection ``` **Tests** (requirement 5) — `scripts/check-silent-revert.test.sh`, hermetic synthetic repos, following the `scripts/*.test.sh` + `test-git-helpers.sh` convention. 27 cases: attribution, per-culprit aggregation, the recency window, the pure-rename negative, every intent form, the "prose mentioning revert must still fire" negative, the empty-trailer negative, abbreviated-SHA rejection, whole-file deletion, the shipped default in both directions, and fail-closed exit 2 on an unresolvable range, a malformed range, and an unreachable pinned commit. ``` $ bash scripts/check-silent-revert.test.sh check-silent-revert.test.sh: 27 passed, 0 failed ``` **Verified in real CI on this PR, not just locally.** The lane runs on `pull_request` (scoped by `paths` to the canary's own files) so the detector is exercised before it lands — the scan step is gated off for PR events, so nothing on a PR inspects that PR. From the actual run log ([job](https://github.com/melodic-software/claude-code-plugins/actions/runs/31918399785/job/95094057983), 12s): ``` Test the silent-revert detector check-silent-revert.test.sh: 27 passed, 0 failed Replay the recorded incidents ok f603880 fires as recorded (#2639 dropped #2635 (346 lines), 13 minutes later) Replay the recorded incidents ok 9239f15 fires as recorded (#2641 dropped #2639 (451 lines), 10 minutes later) Replay the recorded incidents ok cc58cbc fires as recorded (#2633 dropped #2632's rollups (853 lines) -- unfiled until now) Replay the recorded incidents ok c8470ef stays clean as recorded (docs(conventions) rewrote 129 lines ...) Replay the recorded incidents Canary reproduces every recorded incident at the shipped settings. ``` That run matters for a specific reason. The blame-header pattern originally used an ERE interval (`{40}`), and interval support is an awk-implementation variable — the runner's default awk is mawk, development machines run gawk. Had it not matched, attribution would emit nothing and **every commit would report `ok`**: a false green, the exact failure class this canary exists to remove. It is now interval-free and proven against the runner's own awk above. **Cannot block a merge — verified, not just asserted.** The required contexts on `main` are: ``` $ gh api repos/melodic-software/claude-code-plugins/rules/branches/main \ --jq '.[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context' pr-title / pr-title pr-issue-linkage / pr-issue-linkage do-not-merge / do-not-merge ci-status ``` `Silent-revert canary` is not among them, and its only appearance in `ci.yml` is the `workflow_schema` filename list — never `ci-status`'s `needs`. A non-required check cannot gate a merge. **Repo gates**, all run locally on this branch: `shellcheck --rcfile=.shellcheckrc -x` (exit 0, and this repo enables `require-double-brackets` and `add-default-case`), `check-shell-portability.sh --paths` and `--all` (exit 0), `zizmor` (no findings), `actionlint` (exit 0), `typos` (exit 0), `editorconfig-checker` (exit 0), `check-jsonschema --builtin-schema vendor.github-workflows` (ok), `check-silent-skips.sh` (exit 0). The new workflow is registered in `ci.yml`'s `workflow_schema` file list; the shell scripts are committed `100755`. ### Design notes for review - **No cancelling `concurrency` group**, unlike `ci.yml` — a cancelled canary run is a silently missed detection. - **Fail-closed on an unusable push range.** `github.event.before` is all-zeros on a first push or history rewrite; the workflow falls back to the head commit and emits a `::warning::` saying earlier commits were not scanned, rather than reporting a clean scan of nothing. An unresolvable range exits **2**, never 0. - **Self-test runs before every scan**, so a broken detector cannot mask a regression behind a green canary — the same never-skip, self-test-first shape the `ci.yml` gates use. - **What runs on a PR is the detector's unit tests, never detection.** The `pull_request` trigger is `paths`-scoped to the canary's own five files, so it is inert on every other PR, and both scan steps carry `if: github.event_name != 'pull_request'`. - **Remaining limitations, stated rather than hidden:** content reverted from outside the 40-commit recency window is missed by design; content gutted in the same commit that renames its file is not attributed; and no threshold separates a large deliberate rewrite from a silent revert, which is what the acknowledgment file exists to absorb. ## Related Refs #2691 — this is item 4; the issue covers more and stays open. Refs #2713 — the docs-only silent-revert blind spot, same class from the test-coverage side. Refs melodic-software/github-iac `docs/adr/0001-relax-strict-required-status-checks.md` — unchanged; the evidence above argues it was never the relevant control for this failure mode. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
…alsified Recording #2642 as a second victim of #2633's squash added a fourth true finding at 301 lines, and two surviving sentences still described a corpus that no longer exists: check-silent-revert.sh "340 is the largest false positive and 346 is the smallest true one ... that 2% gap" silent-revert-acknowledged.txt "At 340 lines this is the largest measured false positive, six lines below the smallest real incident (346)" Measured at the shipped settings, the smallest true finding is 301, and BOTH verified-legitimate fires score above it -- 340 (#2135) and 390 (#2640). The relationship is inverted, not narrowed: the ranges overlap outright and there is no gap to split. That strengthens the existing conclusion rather than changing it, so the threshold stays at 200 and the disposition path stays the thing that makes the canary livable. Also records that 200 and 300 still fire on the identical five commits, with the 301 finding surviving 300 by one line. Separately, #2656 was mischaracterized. It does record the event as a silent revert -- "#2633 was a stale-base squash that silently reverted two merged features" -- and it is what it ASKED for, not what it recorded, that was coverage-shaped. Corrected and quoted. Comments and fixture note text only. The non-comment body of check-silent-revert.sh is byte-identical to origin/main, both fixtures' parsed fields (expect/sha and sha) are unchanged, and 27/27 detector tests plus --verify-known-incidents pass at the unchanged shipped settings. Refs: #2831 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…alsified Recording #2642 as a second victim of #2633's squash added a fourth true finding at 301 lines, and two surviving sentences still described a corpus that no longer exists: check-silent-revert.sh "340 is the largest false positive and 346 is the smallest true one ... that 2% gap" silent-revert-acknowledged.txt "At 340 lines this is the largest measured false positive, six lines below the smallest real incident (346)" Measured at the shipped settings, the smallest true finding is 301, and BOTH verified-legitimate fires score above it -- 340 (#2135) and 390 (#2640). The relationship is inverted, not narrowed: the ranges overlap outright and there is no gap to split. That strengthens the existing conclusion rather than changing it, so the threshold stays at 200 and the disposition path stays the thing that makes the canary livable. Also records that 200 and 300 still fire on the identical five commits, with the 301 finding surviving 300 by one line. Separately, #2656 was mischaracterized. It does record the event as a silent revert -- "#2633 was a stale-base squash that silently reverted two merged features" -- and it is what it ASKED for, not what it recorded, that was coverage-shaped. Corrected and quoted. Comments and fixture note text only. The non-comment body of check-silent-revert.sh is byte-identical to origin/main, both fixtures' parsed fields (expect/sha and sha) are unchanged, and 27/27 detector tests plus --verify-known-incidents pass at the unchanged shipped settings. Refs: #2831 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tector PR #2843 pinned attribute_file's diff and blame flags to git's own defaults. Three of the figures the calibration prose quotes move under those pins, because the original calibration was taken on a machine carrying diff.algorithm = histogram: #2640 reads 447 rather than 390, #2135 reads 323 rather than 340, and #2642's share of #2633's squash reads 298 rather than 301. A calibration comment that states a number the shipped detector no longer produces is the defect this branch exists to fix, so every sentence carrying a figure was re-measured rather than patched. The overlap argument survives and is stated more strongly. The smallest true finding is 298 and both cleared fires score above it, at 323 and 447, so the populations invert rather than merely abut. The 200-versus-300 sentence inverted outright and was rewritten from measurement. It claimed the finding survived a threshold of 300 by a single line; under the pins that finding is 298 and disappears instead. The commit set at 200 and 300 is still identical, because cc58cbc keeps its 853-line finding, but the finding set is not, and running the replay at 300 reports cc58cbc as firing but not as recorded. Three further corrections the re-measurement surfaced. The corpus endpoint is now named as a sha rather than written as "the last 500 first-parent commits", which is a moving window that falsifies itself on the next merge. Detection and disposition are now distinguished: five commits cross the threshold but two are cleared by the acknowledgment file, so a reader sees three. And the two recall gaps are acknowledged rather than implied, so the corpus figures read as floors by construction: attribute_file swallows git's stderr (#2880), and paths marked -diff or binary in .gitattributes contribute nothing (#2883). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwdkpWf6bptu3AqTMoeg2H
…tor's counts (#2847) Closes #2846. ## Summary The calibration comments in `scripts/check-silent-revert.sh` carry the argument that justifies the 200-line threshold. Two of their sentences depended on which finding is the smallest, and #2832 had already falsified both by recording a fourth true finding. This PR repairs them — and re-derives every figure they rest on against the detector as PR #2843 pins it, because three of those figures move under the pins. Found by fresh-context verification of #2832, after it had merged. ## Why the numbers moved PR #2843 pins `attribute_file`'s git invocations to git's own defaults (`--diff-algorithm=myers`, `--no-ext-diff`, `--no-textconv`, `--no-ignore-revs-file`, `-M`). The original calibration was taken on a machine carrying `diff.algorithm = histogram`, and the algorithm choice changes which lines a hunk calls deleted. Every figure below was re-measured against the pinned detector and reproduced byte-identically with `GIT_CONFIG_GLOBAL` emptied, which is the property `t_counts_are_immune_to_ambient_git_config` asserts. | commit | PR | pre-pin | pinned | class | | --- | --- | ---: | ---: | --- | | `cc58cbc53` | #2633 | 853 | **853** | incident | | `cc58cbc53` | #2633 (#2642's share) | 301 | **298** | incident | | `9239f1541` | #2641 | 451 | **451** | incident | | `f603880da` | #2639 | 346 | **346** | incident | | `6f0a31109` | #2640 | 390 | **447** | cleared | | `91e77fc16` | #2135 | 340 | **323** | cleared | ## Fix **The overlap argument survives; every sentence stating it was re-derived.** The smallest true finding is 298 and both cleared fires score above it, at 323 and 447. The relationship is an inversion, not a narrow gap — so `NO THRESHOLD SEPARATES THEM` is now true by a wider margin than the six-line version it replaces. THRESHOLD stays 200. **The 200-vs-300 sentence inverted and was rewritten from measurement, not patched.** The old text said "at 300 the 301-line finding survives by a single line". Under the pins that finding is 298, so at 300 it VANISHES. The COMMIT set at 200 and 300 is still identical — `cc58cbc53` keeps its 853-line finding — but the FINDING set is not, and that is the sharper argument against tuning. Measured: ``` $ SILENT_REVERT_THRESHOLD=300 scripts/check-silent-revert.sh --verify-known-incidents FAIL cc58cbc fires, but NOT as recorded recorded attribution: bfb66be 853 eda5ae5 298 what the detector reported: bfb66be 853 EXIT=1 ``` Without #2833's attribution expectations that row would have passed on the surviving 853-line finding and announced a reproduction it never performed. The passage now says that, and cites `t_replay_asserts_the_recorded_attribution`, which pins the same two-culprit shape. **Detection and disposition are now distinguished.** The corpus sentence said the canary "fires on 5 commits" and left a reader to assume that is what CI shows. It is not: `6f0a31109` and `91e77fc16` are in `scripts/silent-revert-acknowledged.txt`, so `scan_commit` clears each before it attributes a line. Five commits cross the threshold; three print `SILENT REVERT SUSPECTED`. The prose now states both and says which one the threshold is calibrated against. **The corpus endpoint is pinned.** "the last 500 first-parent commits of main" is a moving window that falsifies itself on the next merge — the same defect class this PR closes. It now reads "the 500 first-parent commits of main ending at `738791c45`". **Two recall gaps are acknowledged rather than implied.** - `attribute_file` swallows `git` stderr on both commands that produce a count, so a failed diff or blame is indistinguishable from nothing-to-attribute and can only subtract. Every corpus enumeration is therefore a floor, and the prose is worded so the caveat is structural rather than an appended qualifier (#2880). - Paths the repository's `.gitattributes` marks `-diff` or `binary` produce no hunks, so their deletions attribute to zero on every machine including CI (#2883). This is a RECALL gap, not a calibration one: no path of that class appears in any commit whose figure is quoted, and the largest such deletion anywhere in the sweep was 72 lines from a `package-lock.json` — well under the threshold. **Also corrects a mischaracterization of #2656** that #2832 introduced, which said that issue recorded the event "rather than as a silent revert". Its Evidence section opens with *"#2633 was a stale-base squash that silently reverted two merged features."* It recorded it exactly as a silent revert — what was coverage-shaped was what it **asked for**. Now quoted rather than paraphrased. ## Not fixed here The fresh-context verifier confirmed each of these; every one sits in prose this PR does not own, and each needs a rewording rather than a renumber. - **"main's 1527-commit history"** (twice). Reproduces at no named endpoint — measured 1546 at `738791c45`, 1549 at `origin/main`. The claims it supports are unaffected and do reproduce: `Revert "` = 0, `revert:` = 1, that one being `1d1fca6e8` (#1839). Renumbering it would be falsified by the next merge, which is the same moving-window defect this PR removes elsewhere. - **"the replay exited 0 for 31h28m"**. The duration reproduces exactly, but it is the content-absence window; the replay itself only existed for about 6h13m of it. #2873's prose, and the same conflation appears once in `silent-revert-incidents.txt`. - **"a four-row corpus"**. There are 5 `marker` rows, and 3 `fires` + 1 `clean` expectation rows; the sentence's own unit is one read per marker, so 5. #2873's prose. - **The repo-wide-grep counterfactual.** Its present-tense half holds, but at the tree where both markers were actually missing, a repo-wide grep would have falsely cleared only the README half — the CHANGELOG copy that makes the claim true today was added by the restore commit itself. #2873's prose. - **The `clean` row's "129 lines"** reads 136 under the pins. Issue #2865 owns that row; correcting it here would collide. ## Related - PR #2843 — merged ahead of this one; it added the pins that move three of the figures here, and its pin table records the pre-pin and pinned columns side by side. This PR layers prose on top of it and changes no pin. - PR #2873 — merged ahead of both; added the restoration markers and the `marker) continue ;;` arm. Untouched here and verified intact after the rebase. - Refs #2880 — `attribute_file` swallows git stderr, which is why the corpus figures are worded as floors rather than exact counts. Acknowledged here, not fixed. - Refs #2883 — paths marked `-diff` or `binary` contribute zero to attribution. Acknowledged here as a recall gap, not fixed. - Refs #2865 — owns the `clean` row whose "129 lines" reads 136 under the pins. Deliberately left to that lane. - Refs #2832 — the corpus attribution correction that added the fourth finding and falsified the two sentences this PR repairs. ## Verification - Every figure re-measured against the shipped detector on this branch, twice — once inheriting ambient config and once with `GIT_CONFIG_GLOBAL` emptied — with identical results. - `scripts/check-silent-revert.test.sh` (101 passed, 0 failed) and both replay modes run green against the merged content. - An independent fresh-context verifier re-measured every number in the calibration comments without access to this reasoning, running the full 500-commit corpus sweep rather than per-commit checks alone. Every figure this PR states reproduced. It raised two defects in the new prose, both fixed here: "the number a reader sees in CI is 3" read as findings when it means commits (three commits, four findings between them), and "roughly once a month" was 12-19x off — the corpus spans 7.9 days with four of its five crossings inside 76 minutes, so that rate claim was removed rather than renumbered, because the corpus measures a burst and no per-month figure is defensible from it. Its full verdict, including drift it confirmed in prose this PR does not own, is recorded in the PR comments. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #2122
Update —
mainmoved under this PR, and the disposition changed with it#2120 merged (
fd075c27), and it fixed the same function with the opposite value disposition:it STRIPS every NUL out of a value where this branch TRUNCATED at the first one. The PR went
DIRTY. Resolved by mergingorigin/maininto the branch — never a rebase, since force-push isblocked here twice over.
The resolution keeps
main's strip and this branch's flag plus fail-closed guards. That isadditive over
mainrather than a choice between the two sides, and it is what this body alreadyargued for in its own words: the disposition is immaterial for this PR's own two callers, which
refuse on the flag before reading a value, while
mainnow carries the ten scanner-class callers#2120 converted, none of which consults the flag. Truncating would have hidden a credential placed
after a NUL from
secret-pattern-detectionandhardcoded-path-check. Everything below thatsays "truncate" describes the pre-merge branch; the shipped behaviour is strip + flag.
The textual merge git produced was silently fatal, and was not taken
git auto-merged the function body into a hybrid carrying BOTH
main's per-filtersplit("\u0000") | join("")and this branch's array-levelexplode | .[0:(index(0) // length)] | implode. Strip runs first, soindex(0)looked at a value with no NUL left in it and the flagread
0on every payload — the guards would never have fired, with no conflict marker and no testof the pre-merge branch able to see it. The flag is now computed from the untouched values with the
strip applied after, and both the library and the guard comments say the ordering is load-bearing,
because it is exactly what the next textual merge will get wrong again.
Why the flag and the guards are still needed after #2120
#2120 closed the fail-open for the CONTENT guards. It did not close the COMMAND guards: stripping
SPLICES the bytes either side of the NUL into a token the payload never carried contiguously, and
the guards then match against that token. Re-measured at the hook boundary,
origin/mainatfd075c27versus this tree, same script, same host, on fixtures whose NUL is a real byte — verifiedby decoding each fixture and counting the byte (
jq -j .tool_input.command | tr -dc '\u0000' | wc -c=maingit commit --no-verify<NUL>xgit push --force<NUL>xgit commit --no-veri<NUL>fy--no-verify--forcegit status)Identical on both guards. The fifth row is stated, not counted: the splice happens to reassemble
a real
--no-verifythere, somainalready blocks it and it evidences nothing about this change.The live rows are the first four, and the first two are the ones that matter — a real
--no-verifyand a real
--forcethatmainwaves through. No clean command changed verdict in eitherdirection.
Tests re-pointed rather than deleted
Every assertion this branch wrote against truncation was measuring a value the helper no longer
produces, so each was rewritten for strip and two new cases were added: the splice
(
--no-verify<NUL>x-> the single token--no-verifyx), and an ALL-NUL value, which strips toempty — that case, and not a leading NUL, is the real reason both guards consult the flag ahead of
their empty-command skip. The guard suites keep every NUL row at exit 2; the verdict never depended
on the disposition, only its justification did, and one mislabelled row was corrected accordingly.
Conflicts and versions
lib/hook-utils.sh— header comment and jq program, resolved by hand.scripts/sync-hook-utils.sh, not hand-resolved;--checkreports 16/16 byte-identical.above
main's and is rewritten for the resolved design.plugin.jsonfiles had auto-merged tomain's number, leaving no bump at all — noconflict, only
--check-bumpcatches it, exactly the trap flagged below. Re-bumped:guardrails 0.23.1 -> 0.23.2,markdown-format 0.11.2 -> 0.11.3,source-control 0.51.2 -> 0.51.3, patch bumps for the other 13.markdown-formatto0.11.3. Whichever merges secondmust re-bump.
mainmoved twice more: three merges, and one of them was silently lossymainlanded #2147, then #2140 and #2149, while this PR sat. Three merge passes, no rebase at anypoint. Second pass: #2147 took
guardrailsto0.24.0and editedblock-dangerous-git.sh, which this branch also edits — resolved by keeping main's three-fieldhook::jq_fields "$INPUT" '.tool_input.command' '.cwd' '.tool_name'call verbatim and appending thisbranch's NUL block after it. Third pass: one changelog conflict on
source-control. Every pluginmanifest had auto-merged to main's number with no bump on both passes.
The second pass exposed a defect this branch had introduced, and it is worth reading even if you
skip the rest. An earlier commit here accidentally wrote a real NUL byte into
plugins/guardrails/CHANGELOG.md— a\u0000that was meant to be literal text in a prosedescription of the fixtures. git classifies any file containing a NUL as binary, so the textual
three-way merge never ran on that changelog: it kept ours wholesale and silently discarded main's
entire
0.24.0section, with no conflict marker and nothing ingit statusto distinguish it froma file that merged cleanly. It was caught by counting NUL bytes across the touched files, not by
reading the diff. The byte is gone, the section is restored, and the changelog's
0.24.1entry nowsits above main's
0.24.0.That is a mistake this PR made, not a pre-existing one, and it is reported rather than quietly fixed
because the failure mode generalises: a NUL in a tracked text file turns every future merge of that
file into a silent take-ours. In a repository whose CHANGELOGs are the merge-conflict surface for
every shared-library change, that is worth knowing independently of this fix.
Incidental, and relevant to the "what I could NOT verify" list below
While posting a review reply, the harness itself refused a tool call whose
commandfieldcarried a stray control character, with
command contains control characters that would be hidden in the approval dialog. That is a live observation of the validation the list below names as unverified— it fires, and it fires on the
commandfield. It is not the discriminating probe: it saysnothing about whether that validation runs before or after PreToolUse hooks, and nothing about
whether the rejected class includes NUL specifically rather than the control characters it does
cover. Recorded as an observation, not as evidence that the guards are unreachable. Nothing in this
change leans on it in either direction.
Gates re-run after the merge
sync-hook-utils.sh --check(16/16) -sync-hook-utils.sh --check-bump origin/main-check-changelog-parity.sh --check/--check-bump origin/main/--check-order-shellcheck -xwith no severity floor onlib/hook-utils.sh, thebash-formatvendored copy,both guards and all three test files (rc 0 — this is what the two open review threads reported
failing; the jq-variable spelling they flagged is gone from the current program text) -
shfmt -d -i 2(rc 0).Suite results after the merge are in the thread below.
The defect
hook::jq_fieldsframes its fields with a NUL delimiter drawn from the same byte space as thevalues it separates. A JSON NUL escape inside a value splits that value in two, the cardinality
check
((${#values[@]} == $#)) || return 1fires, and both real callers spell that|| exit 0—a PreToolUse ALLOW, emitted with no diagnostic of any kind.
One correction to the issue's mechanism, because it moves where the fix belongs. The collision is
reliably detected, not intermittently: every NUL adds exactly one record, so the count is always
N + kfork >= 1and the check never misses. The defect therefore never lived in the library'sreturn value. It lives in one exit path serving two conditions with opposite correct responses —
"jq is absent or cannot parse this" (where allowing is the documented, deliberate behaviour) and
"this payload carries a NUL" (where allowing is wrong). Separating those two is the fix.
Design
jq truncates each value at its first NUL and reports the fact; the caller owns the verdict.
lib/hook-utils.sh— each filter becomes... | explode | .[0:(index(0) // length)] | implode.The separator then cannot occur inside a value, so the record count no longer depends on what a
parseable payload holds.
same jq program, so reporting it costs no second spawn. It surfaces as
HOOK_JQ_FIELDS_NUL,assigned in the same unconditional block that resets
HOOK_JQ_FIELDS— above all three returnpaths, so no early return can leak a stale
1, which in a guard would mean blocking a cleanpayload on the strength of an earlier one.
block-no-verify.shandblock-dangerous-git.shfail CLOSED on that flag, before theirempty-command skip, because the helper truncates at the first NUL and a leading one therefore
leaves an empty value that would otherwise be waved through as "no command".
Why fail CLOSED, and why that argument does not depend on the executor
No executor-fidelity claim is made here, in either direction. Two behaviours were measured and
they disagree, and which of them a hook payload actually reaches has not been traced by anyone:
echo ha<NUL>rdprintshard, and--no-verify<NUL>xbecomes--no-verifyxexecvechild_process— argv,shell: true, andexecSyncERR_INVALID_ARG_VALUE: must be a string without null bytes, while the same calls with a clean string run normallyAn earlier draft of this PR argued that truncation was right because the executor truncates. That
was wrong — it generalised the argv case to a path that is not known to be the one in use. The
correct argument is that the design does not need it: failing closed on the flag is correct under
deletion, under truncation, and under refusal alike, so it cannot be invalidated by tracing the path
later. That is the whole case for it. Matching the value would need the trace; refusing does not.
Truncate rather than delete, on grounds that appeal to no shell
Truncation never fabricates a token the payload did not carry contiguously, and when a caller
forgets the flag it is the content class that degrades rather than the command class — a matcher
sees a prefix rather than a joined token that matches nothing. For this PR's own two callers the
choice is immaterial: they refuse on the flag before reading a value at all. It is the
conservative default, not the accurate one, and the flag is the load-bearing part.
Why the library does not block on its own
It is sourced by 15 other plugins, formatters among them, for which exiting 2 would be wrong; and a
sourced library calling
exiton its caller's behalf is hidden control flow. Policy stays with thecaller and the library only reports the fact.
Rejected alternatives
map(select(. != 0)))gsub/split+joinon a NULexplode/implodeuse integer comparison only, with no NUL anywhere in the program. This is a reason, not a measurement — see the unverified list.read -N(bash 4.1+); this lib supports 3.2+.@base64base64binary; onlyjqis a documented prerequisite.@sh+evaleval.Scope
This is a shared-library change, and the repo's own gate makes it 55 files.
plugins/guardrails/hooks/hook-utils.shis a vendored copy;lib/hook-utils.shis the source oftruth. CI enforces
scripts/sync-hook-utils.sh --check(all 16 copies byte-identical) and--check-bump(every carrying plugin bumped when the lib changes), so editing only the guardrailscopy would fail CI. Precedent: 9b90e35, 50 files. Hence 16 vendored copies, 16
plugin.jsonbumpsand 16 changelog entries, plus the lib, its test, the two guards, their two test files and the
guardrails README.
hook::jq_field— SINGULAR — is untouched. It is a separate two-line function; there is noshared internal the two route through.
grep -rn "hook::jq_field " --include=*.sh plugins/, with thevendored copies excluded, finds 22 call sites across 12 files in
claude-ops,context-guardand
source-control. None of them are touched.git diff origin/main -- lib/hook-utils.shmentionshook::jq_fieldon exactly two lines, both of them the same doc-comment cross-reference inside theplural function's header ("Values are CR-stripped, as in
hook::jq_field"); the singularfunction's own body appears nowhere in the diff. Blast radius is exactly the two guards.
No other plugin is affected by the truncation.
grep -rn "hook::jq_fields" --include=*.sh .,excluding the 16 vendored copies and
lib/hook-utils.*, returns exactly two call sites — both inthis PR. Every other hit across the 16 plugins is the doc comment in the vendored library. Nothing
round-trips a value into a file, and nothing compares a length or hash against one.
Versions, taken against
origin/mainat the time of the last rebase:guardrails 0.23.0 -> 0.23.1,markdown-format 0.11.1 -> 0.11.2,source-control 0.51.1 -> 0.51.2, and plain patch bumpsfor the other 13. Worth flagging for anyone rebasing a sibling branch: when a plugin's version moved
on
mainmid-flight,gitauto-merged the manifest to main's number, silently leaving no bumpat all — no conflict, and only
sync-hook-utils.sh --check-bumpcatches it. That happened threetimes here. #2120 is still open against the same guardrails files and owes a re-bump.
Two caller classes want opposite dispositions — which is why there is a flag
This is the strongest argument for the design, and it is demonstrated rather than theoretical.
#2120 has independently fixed the same function with the opposite disposition: at its head
9fb8383d,hook::jq_fieldsdoes... | tostring | split("<NUL>") | join("")— it strips.Neither disposition is simply right, because the two caller classes disagree:
content: harmless<NUL>aws_secret=AKIA…(a scanner)command: --no-verify<NUL>x(a guard)--no-verifyx, matches nothing, allowed--no-verify, blocked(Which of those two readings the executor would agree with is untraced, and is not the argument —
see above. The point is only that a caller ignoring the flag degrades unsafely in one class or the
other, depending which disposition the helper picks.)
Both halves measured. The command half is the boundary table below. The content half I measured by
driving the helper directly, since no shipped hook reads
.tool_input.contentthrough it onmain:So yes — truncation loses post-NUL content for a scanning caller. Stated plainly because it is a
real consequence of this design. It is not a regression (the base loses it too, and additionally
allows), and truncation is still the chosen default: it keeps the command class safe when a caller
ignores the flag, where strip keeps the content class safe instead. Strip inverts which class fails
unsafely; it does not remove the failure. Neither is chosen on executor grounds.
A single disposition cannot serve both callers. The flag is what resolves it — the helper
reports, and each caller decides: a command guard refuses outright, a content scanner refuses the
write rather than scanning a value it knows is incomplete. Either way the credential never lands.
The count, measured on
9fb8383dEvery one of the ten hooks #2120 converts calls
hook::jq_fields. Zero of them consult any NULsignal. Six own an
exit 2verdict:jq_fieldscallsexit 2pathssecret-pattern-detectionhardcoded-path-checkblock-convention-violationblock-hook-bypassblock-noncanonical-commitcli-flag-verifyskill-reference-verifystale-path-verifyflag-commit-pr-skill-bypassworkflow-resilience-checkZero flag checks is expected — the flag does not exist on their branch. The point is what it implies
for whichever of us merges second: merge order does not rescue it. This PR first, then their
rebase, and the scanning hooks receive truncated values with no flag check. Theirs first, then this
one, and the same is true the moment strip becomes truncate. A reader must not conclude that this
PR makes that conversion safe. It does not. Adding the flag checks to those ten hooks is a
prerequisite for the conversion, not a follow-up — and it is theirs to do, since those hooks exist in
converted form only on their branch. This PR deliberately does not touch them.
hardcoded-path-check.shis a third caller class worth calling out: it reads.tool_input.content,.new_stringand.new_sourceand owns twoexit 2paths, so it is both scanner and guard.Per-field reachability was checked separately and holds: at their head, both
secret-pattern-detection.shandhardcoded-path-check.shreachexit 2through.contentandthrough
.new_string. (hardcoded-path-check.shreturns early unlessCLAUDE_PROJECT_DIRis set,so a probe without it exits 0 on every payload and looks exactly like "not reachable".)
#2123 needs nothing — its diff introduces zero
hook::jq_fieldscall sites.Merge coordination: #2120 now also edits
lib/hook-utils.sh, so this is a direct conflict on thesame function rather than only on the manifest and changelog. Whoever merges second must keep both
correctness properties — the flag and the fail-closed guards from here, and the scanning-caller
requirement from there — rather than resolving by taking one side of the hunk.
Evidence
Hook boundary, before and after
Real hooks, payload piped on stdin, exit code read. BEFORE is a
git archiveoforigin/mainat468bb2d9— re-measured after #2123 merged, because #2123 changedplugins/guardrails/lib/powershell/ps-command.sh, which both guards source. AFTER is this branch.Same script, same host.
git push --no-verify/git reset --hardecho hi/git status)--no-veri<NUL>fy)--no-verify<NUL>x)Identical for both guards. No row where a clean command changed verdict. The
<NUL>xrow is the onethat matters most: it is the payload that executes as the dangerous command.
The leading-NUL row blocks for the right reason
Identical truncated content, opposite verdicts, so the flag decides rather than incidental matching:
"command": ""(empty, no NUL)commandfield absent entirelySame on both guards.
Test suites, same host, baseline vs branch
Both arms ran in full, serially, on an uncontended host: every
*.test.shunderplugins/guardrails/hooks/pluslib/hook-utils.test.sh— 14 suites, every one of them listedbelow. BASELINE is the same
468bb2d9tree used for the boundary table; BRANCH is this tip.lib/hook-utils.test.shblock-dangerous-git.test.shblock-no-verify.test.shblock-convention-violation.test.shblock-hook-bypass.test.shblock-noncanonical-commit.test.shcli-flag-verify.test.shflag-commit-pr-skill-bypass.test.shhardcoded-path-check.test.shrequire-jq-notice-isolation.test.shsecret-pattern-detection.test.shskill-reference-verify.test.shstale-path-verify.test.shworkflow-resilience-check.test.shEvery suite that does not exercise the new path is byte-identical across the two arms, so the +18 is
entirely the new cases. No pre-existing failure to disambiguate.
Two of the new library tests look redundant and are not:
HOOK_JQ_FIELDS_NULis checked both aftera clean payload and after an early return, each running a NUL payload first, because a
single-call test cannot observe a stale flag however it is written, and two of the three return
paths fire before any NUL could be seen.
Other gates, all re-run after the rebase
sync-hook-utils.sh --check(16/16) -sync-hook-utils.sh --check-bump origin/main-check-changelog-parity.sh --check/--check-bump origin/main/--check-order-check-silent-skips.sh-check-contract-clause-coverage.py-check-cross-plugin-source-drift.sh --check-check-hook-userconfig-argv.sh-check-plugin-manifest-presence.sh-sync-parse-concern-value.sh --check-sync-resolve-convention-pattern.sh --check-sync-standards-contract.sh --check-check-skill-leaf-names.sh --check-check-shell-portability.sh --paths-shellcheck -x -S warning(rc 0) -shfmt -d -i 2(rc 0) -markdownlint-cli2(0 issues) -check-manifest-duplicate-keys.py.What this PR does NOT fix, stated rather than implied
A payload jq cannot parse still returns 1 and is still allowed. Malformed JSON, a wrongly typed
field or an empty buffer all reach the same
|| exit 0, exactly as before this change. Processsubstitution also means jq's own exit status is never observed. That path is untouched here and out
of scope, and the header comment now says so instead of claiming — as an earlier draft of this very
fix did — that nothing a payload contains can reach it. That claim is the same reasoning shape that
produced #2122, and it should not ship inside its fix.
What I could NOT verify
behaviours were measured and they disagree, and Node refuses NUL-bearing strings on every shape
tried, so the command may never reach a shell parser at all. The design is built so this does not
matter: fail-closed is right under deletion, truncation, and refusal alike. An earlier draft of
this PR did lean on it, in one direction and then the other; both are gone, from the body and from
the code comments, the README and the changelog.
whether the class it rejects includes NUL specifically. The discriminating probe is
bypass-shaped and was deliberately not run. The guard that exists is worded "contains control
characters that would be hidden in the approval dialog" — approval-surface anti-spoofing, covering
command/script/urlonly, with no equivalent oncontent/new_string/file_text. Itis an implementation detail, not a documented guarantee, and nothing here leans on it in either
direction.
construct uses only
explode,implode,index, array slicing andany— core since jq 1.5 —precisely to keep that risk low, but it was not executed against an older jq. The repo's
hook-utils-windowsjob exercises Git Bash on windows-2025 in CI.per-field cost of the sanitiser was below spawn noise on this host — the no-op control benchmarked
slower than all three candidates — so no number is claimed.
Being settled separately. This fix does not depend on the answer: failing open on a parse failure
is wrong regardless of how the parse came to fail.
Related
perf/guardrails-jq-fields) — converts ten further guardrails hooks tohook::jq_fieldsand, at9fb8383d, independently fixes the same function by stripping NULs.Direct conflict on
lib/hook-utils.sh; whoever merges second must keep both correctnessproperties rather than taking one side of the hunk, and those ten hooks need
HOOK_JQ_FIELDS_NULchecks as a prerequisite. Not closed by this PR.
plugins/guardrails/lib/powershell/ps-command.sh, whichboth guards source, so the boundary table was re-measured against it. Introduces no
hook::jq_fieldscall site, so it needs nothing from this change.changelog entry in every carrying plugin.