Skip to content

fix(hook-utils): stop a NUL in a payload value from voiding two blocking guards - #2135

Merged
kyle-sexton merged 8 commits into
mainfrom
fix/hook-utils-nul-fail-open
Aug 10, 2026
Merged

fix(hook-utils): stop a NUL in a payload value from voiding two blocking guards#2135
kyle-sexton merged 8 commits into
mainfrom
fix/hook-utils-nul-fail-open

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes #2122

Update — main moved 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 merging origin/main into the branch — never a rebase, since force-push is
blocked here twice over.

The resolution keeps main's strip and this branch's flag plus fail-closed guards. That is
additive over main rather than a choice between the two sides, and it is what this body already
argued 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 main now 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-detection and hardcoded-path-check. Everything below that
says "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-filter
split("\u0000") | join("") and this branch's array-level explode | .[0:(index(0) // length)] | implode. Strip runs first, so index(0) looked at a value with no NUL left in it and the flag
read 0 on every payload
— the guards would never have fired, with no conflict marker and no test
of 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/main at
fd075c27 versus this tree, same script, same host, on fixtures whose NUL is a real byte — verified
by decoding each fixture and counting the byte (jq -j .tool_input.command | tr -dc '\u0000' | wc -c =

  1. rather than trusting that the escape survived construction:
payload main this change
git commit --no-verify<NUL>x 0 ALLOWED 2 blocked
git push --force<NUL>x 0 ALLOWED 2 blocked
a lone NUL 0 ALLOWED 2 blocked
a trailing NUL 0 ALLOWED 2 blocked
git commit --no-veri<NUL>fy 2 blocked 2 blocked
clean --no-verify 2 2
clean --force 2 2
harmless (git status) 0 0

Identical on both guards. The fifth row is stated, not counted: the splice happens to reassemble
a real --no-verify there, so main already 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-verify
and a real --force that main waves through. No clean command changed verdict in either
direction.

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 to
empty — 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.
  • The 16 vendored copies were regenerated with scripts/sync-hook-utils.sh, not hand-resolved;
    --check reports 16/16 byte-identical.
  • 16 CHANGELOGs where both sides claimed the same version: this branch's entry moves up one patch
    above main's and is rewritten for the resolved design.
  • All 16 plugin.json files had auto-merged to main's number, leaving no bump at all — no
    conflict, only --check-bump catches 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.
  • Coordination with fix(markdown-format): resolve the repo root from the filesystem so nested files reach the root config without git #2130: it also bumps markdown-format to 0.11.3. Whichever merges second
    must re-bump.

main moved twice more: three merges, and one of them was silently lossy

main landed #2147, then #2140 and #2149, while this PR sat. Three merge passes, no rebase at any
point. Second pass: #2147 took guardrails to 0.24.0 and edited
block-dangerous-git.sh, which this branch also edits — resolved by keeping main's three-field
hook::jq_fields "$INPUT" '.tool_input.command' '.cwd' '.tool_name' call verbatim and appending this
branch's NUL block after it. Third pass: one changelog conflict on source-control. Every plugin
manifest 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 \u0000 that was meant to be literal text in a prose
description 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.0 section
, with no conflict marker and nothing in git status to distinguish it from
a 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.1 entry now
sits 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 command field
carried 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 command field. It is not the discriminating probe: it says
nothing 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 -x with no severity floor on lib/hook-utils.sh, the bash-format vendored 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_fields frames its fields with a NUL delimiter drawn from the same byte space as the
values it separates. A JSON NUL escape inside a value splits that value in two, the cardinality
check ((${#values[@]} == $#)) || return 1 fires, 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 + k for k >= 1 and the check never misses. The defect therefore never lived in the library's
return 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.
  • A leading record carries the NUL flag, computed from the untruncated values and emitted by the
    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 return
    paths, so no early return can leak a stale 1, which in a guard would mean blocking a clean
    payload on the strength of an earlier one.
  • block-no-verify.sh and block-dangerous-git.sh fail CLOSED on that flag, before their
    empty-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:

measured result
bash parsing a command it reads (stdin, script file) discards the NUL — echo ha<NUL>rd prints hard, and --no-verify<NUL>x becomes --no-verifyx
a NUL inside an argv word handed to execve the string simply ends there
Node v24.18.0 child_process — argv, shell: true, and execSync refuses outright, ERR_INVALID_ARG_VALUE: must be a string without null bytes, while the same calls with a clean string run normally

An 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 exit on its caller's behalf is hidden control flow. Policy stays with the
caller and the library only reports the fact.

Rejected alternatives

Alternative Why not
Delete the NUL (map(select(. != 0))) Fabricates contiguity the payload did not have, and inverts which caller class degrades unsafely when a hook forgets the flag; see above. Not rejected on executor grounds.
gsub / split+join on a NUL Both work on jq 1.8.2 here, but each puts a NUL inside the jq program text — a regex pattern and a string literal. A construct whose behaviour varied across jq builds would fail EVERY payload: a universal fail-open, strictly worse than the payload-dependent one. explode/implode use integer comparison only, with no NUL anywhere in the program. This is a reason, not a measurement — see the unverified list.
Length-prefixed framing Needs read -N (bash 4.1+); this lib supports 3.2+.
An explicit emitted count Redundant once the separator is absent from the value space.
Per-field @base64 Needs a base64 binary; only jq is a documented prerequisite.
@sh + eval Puts payload-derived text through eval.
Fail closed inside the library Impossible without the library exiting on its caller's behalf, which is wrong for the 15 other plugins.

Scope

This is a shared-library change, and the repo's own gate makes it 55 files.
plugins/guardrails/hooks/hook-utils.sh is a vendored copy; lib/hook-utils.sh is the source of
truth. 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 guardrails
copy would fail CI. Precedent: 9b90e35, 50 files. Hence 16 vendored copies, 16 plugin.json bumps
and 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 no
shared internal the two route through. grep -rn "hook::jq_field " --include=*.sh plugins/, with the
vendored copies excluded, finds 22 call sites across 12 files in claude-ops, context-guard
and source-control. None of them are touched. git diff origin/main -- lib/hook-utils.sh mentions
hook::jq_field on exactly two lines, both of them the same doc-comment cross-reference inside the
plural function's header ("Values are CR-stripped, as in hook::jq_field"); the singular
function'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 in
this 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/main at 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 bumps
for the other 13. Worth flagging for anyone rebasing a sibling branch: when a plugin's version moved
on main mid-flight, git auto-merged the manifest to main's number, silently leaving no bump
at all — no conflict, and only sync-hook-utils.sh --check-bump catches it. That happened three
times 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_fields does ... | tostring | split("<NUL>") | join("") — it strips.

Neither disposition is simply right, because the two caller classes disagree:

payload under strip under truncate
content: harmless<NUL>aws_secret=AKIA… (a scanner) secret is joined and scanned secret is cut off and invisible
command: --no-verify<NUL>x (a guard) joins to --no-verifyx, matches nothing, allowed leaves --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.content through it on main:

payload: .tool_input.content = "harmless preamble<NUL>aws_secret=AKIA…"
this branch (truncate)  rc=0  flag=1  value=[harmless preamble]   credential NOT visible
468bb2d9    (base)      rc=1  flag=-  value=[<none>]              credential NOT visible

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 9fb8383d

Every one of the ten hooks #2120 converts calls hook::jq_fields. Zero of them consult any NUL
signal. Six own an exit 2 verdict:

hook jq_fields calls flag checks exit 2 paths
secret-pattern-detection 2 0 2
hardcoded-path-check 2 0 2
block-convention-violation 2 0 3
block-hook-bypass 2 0 2
block-noncanonical-commit 2 0 5
cli-flag-verify 2 0 1
skill-reference-verify 3 0 0
stale-path-verify 3 0 0
flag-commit-pr-skill-bypass 2 0 0
workflow-resilience-check 2 0 0

Zero 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.sh is a third caller class worth calling out: it reads .tool_input.content,
.new_string and .new_source and owns two exit 2 paths, so it is both scanner and guard.

Per-field reachability was checked separately and holds: at their head, both
secret-pattern-detection.sh and hardcoded-path-check.sh reach exit 2 through .content and
through .new_string. (hardcoded-path-check.sh returns early unless CLAUDE_PROJECT_DIR is 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_fields call sites.

Merge coordination: #2120 now also edits lib/hook-utils.sh, so this is a direct conflict on the
same 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 archive of origin/main at
468bb2d9 — re-measured after #2123 merged, because #2123 changed
plugins/guardrails/lib/powershell/ps-command.sh, which both guards source. AFTER is this branch.
Same script, same host.

case before after
clean git push --no-verify / git reset --hard 2 2
clean harmless (echo hi / git status) 0 0
trailing NUL 0 2
NUL splitting the flag (--no-veri<NUL>fy) 0 2
NUL then junk (--no-verify<NUL>x) 0 2
leading NUL 0 2
NUL in an otherwise harmless command 0 2

Identical for both guards. No row where a clean command changed verdict. The <NUL>x row is the one
that 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:

payload exit
"command": "" (empty, no NUL) 0
command field absent entirely 0
leading NUL, truncates to empty 2
a lone NUL and nothing else 2

Same on both guards.

Test suites, same host, baseline vs branch

Both arms ran in full, serially, on an uncontended host: every *.test.sh under
plugins/guardrails/hooks/ plus lib/hook-utils.test.sh — 14 suites, every one of them listed
below. BASELINE is the same 468bb2d9 tree used for the boundary table; BRANCH is this tip.

suite baseline branch delta
lib/hook-utils.test.sh 156 / 0 162 / 0 +6 new cases
block-dangerous-git.test.sh 341 / 0 346 / 0 +5 new cases
block-no-verify.test.sh 120 / 0 127 / 0 +7 new cases
block-convention-violation.test.sh 31 / 0 31 / 0
block-hook-bypass.test.sh 260 / 0 260 / 0
block-noncanonical-commit.test.sh 202 / 0 202 / 0
cli-flag-verify.test.sh 52 / 0 52 / 0
flag-commit-pr-skill-bypass.test.sh 29 / 0 29 / 0
hardcoded-path-check.test.sh 94 / 0 94 / 0
require-jq-notice-isolation.test.sh 2 / 0 2 / 0
secret-pattern-detection.test.sh 52 / 0 52 / 0
skill-reference-verify.test.sh 96 / 0 96 / 0
stale-path-verify.test.sh 87 / 0 87 / 0
workflow-resilience-check.test.sh 16 / 0 16 / 0
total 1538 / 0 1556 / 0 +18, 0 failures either side

Every 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_NUL is checked both after
a 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. Process
substitution 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

  • How a command actually travels from hook payload to execution. Nobody traced it. Two shell
    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 harness's control-character validation runs before or after PreToolUse hooks, and
    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 / url only, with no equivalent on content / new_string / file_text. It
    is an implementation detail, not a documented guarantee, and nothing here leans on it in either
    direction.
  • Behaviour on jq builds other than 1.8.2, and on bash other than 5.3.9 (Cygwin). The chosen
    construct uses only explode, implode, index, array slicing and any — 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-windows job exercises Git Bash on windows-2025 in CI.
  • Any performance claim. The spawn count is unchanged at one, which is structural. Measured
    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.
  • Whether a NUL payload can reach a PreToolUse hook through the harness's own serialization.
    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

@cursor

cursor Bot commented Aug 10, 2026

Copy link
Copy Markdown

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.

@kyle-sexton
kyle-sexton force-pushed the fix/hook-utils-nul-fail-open branch from 232899b to d6cd5e9 Compare August 10, 2026 05:58
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 38s —— View job


Security review complete

  • Gather context (PR diff, description, custom instructions)
  • Review lib/hook-utils.sh (hook::jq_fields logic change)
  • Review block-no-verify.sh / block-dangerous-git.sh (fail-closed NUL flag usage)
  • Review vendored copies for drift / inconsistency
  • Check test files, README, changelog for anything security-relevant
  • Post final findings as a review

Scope: reviewed only the files this PR touches — lib/hook-utils.sh, block-no-verify.sh, block-dangerous-git.sh, the 16 vendored hook-utils.sh copies, and the associated tests/README/changelogs. Per the review's lane split, unpinned-action risk, workflow trigger patterns, permissions, and template injection are deferred to zizmor — this diff touches no .github/ workflow, settings, or permissions file at all, so that lane is moot here.

No security issues found. Specifically checked for and ruled out:

  • jq program injection — the prog string is built by concatenating filter arguments (lib/hook-utils.sh#L699-L702), but every call site passes only static literals ('.tool_input.command', '.tool_name') hardcoded in the two guard scripts, never payload-derived text. No attacker-controlled input reaches the jq program text itself.
  • Fail-open regression via the new cardinality check — the check moved from ${#values[@]} == $# to == $# + 1 (lib/hook-utils.sh#L724) to account for the new leading flag record. Verified this can't be satisfied by a crafted payload that both trips the NUL flag and produces an output count that spuriously matches: the flag is computed from the untruncated $v array before any splitting occurs, so it accurately reflects every requested field regardless of how many records the truncation step later emits.
  • False-negative on the NUL flag — traced how a raw JSON escape becomes an actual NUL codepoint after jq parses it (strings pass through `tostring` as a no-op), so `explode | index(0)` reliably detects it. A appearing only inside a re-serialized nested object/array (via tostring on non-string values) stays textually escaped rather than a real NUL byte, so it can't smuggle a literal NUL past the flag check or later split the wire separator — consistent, not a bypass.
  • Ordering of the fail-closed check — confirmed in both guards that HOOK_JQ_FIELDS_NUL is tested before the empty-command skip (block-no-verify.sh#L99-L106, block-dangerous-git.sh#L101-L108), so a leading-NUL payload that truncates to an empty command can't slip through the "no command" allow path.
  • Blast radius claims in the PR body — independently re-ran the greps: hook::jq_fields (plural) has exactly two call sites, both in this PR's two guards; hook::jq_field (singular, untouched) has 12 unrelated call sites across claude-ops, source-control, context-guard, none touched by this diff. Confirmed.
  • Vendored copy drift — diffed lib/hook-utils.sh against the guardrails and source-control vendored copies; byte-identical, as CI's sync gate requires.
  • Information disclosure — the new BLOCKED: stderr messages are static text, never echo the payload's command/content back, so no payload-derived data leaks through the hook-feedback channel.

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 14s —— View job


Review complete

Scope reviewed: lib/hook-utils.sh (source of truth), lib/hook-utils.test.sh, plugins/guardrails/hooks/{block-no-verify,block-dangerous-git}.sh + their .test.sh files, plugins/guardrails/README.md, and the 16 vendored hook-utils.sh copies + manifest/changelog bumps. Security review (claude-security-review) is a separate lane and out of scope here.

Findings

  1. CI is currently red on two independent gates — inline comment posted on lib/hook-utils.sh L702-704: the plugin-gate job's ShellCheck step fails (exit 1) on SC2016 (info) for the two new prog+='...' lines that build the jq program ($v is a jq variable, not meant to bash-expand — correct code, but ShellCheck flags it and CI doesn't pass -S warning the way the PR body's manual check did). This also fails hygiene's aggregate step, which depends on plugin-gate. Verified directly from the CI job log, not from the PR body's own claims.

  2. pr-issue-linkage is failing (verified via job 93367835320): the PR body has a Closes #2122 line but no ## Related section at all, which CLAUDE.md's linkage contract requires (## Related non-empty, or N/A). This is a required check per the repo's own PR-linkage gate — the body needs a ## Related section added (N/A is fine if nothing applies) and CI re-run.

  3. Minor / nit — stale figure in the PR description, not the code. The body states "guardrails 0.22.1 -> 0.22.2" but the actual diff bumps plugins/guardrails/.claude-plugin/plugin.json 0.23.0 -> 0.23.1 (source-control's 0.51.1 -> 0.51.2 claim is correct). Likely left over from before the rebase onto fix(guardrails): pin the reconstruction scan's locale so it stops decoding a literal search #2127 (which took guardrails to 0.23.0 first). Doesn't affect correctness — plugin.json and CHANGELOG.md agree with each other for all 16 plugins (independently verified) — just worth fixing in the description since the rest of the body leans heavily on exact version numbers as evidence.

What I independently verified (not just author-claimed)

  • All 16 vendored hook-utils.sh copies are byte-identical to lib/hook-utils.sh (md5sum match).
  • plugin.json version bumps and the corresponding CHANGELOG.md new-entry headers agree, for all 16 plugins.
  • hook::jq_field (singular) body is untouched — confirmed via diff grep, no hits outside doc comments.
  • Traced the hook::jq_fields jq program logic by hand: the flag record ("1"/"0") is emitted once, computed from untruncated explode arrays, ahead of N truncated value records; the cardinality check ($# + 1) correctly rejects both under- and over-counts (e.g. two concatenated JSON docs); HOOK_JQ_FIELDS_NUL is reset in the same unconditional block as HOOK_JQ_FIELDS, ahead of all three early-return paths, so no return path can leak a stale flag. No correctness bug found in the truncation/flag logic itself.
  • Both guards (block-no-verify.sh, block-dangerous-git.sh) consult the flag and exit 2 before the empty-command skip, in the same order, with set -uo pipefail posing no unbound-variable risk since the flag is always set before it's read.
  • Test coverage in lib/hook-utils.test.sh and both guard .test.sh files exercises leading/trailing/mid-token NUL, NUL in a non-command field, flag-staleness across calls, and error-message content — no coverage gaps found for the stated scope.

Not independently verified (author-claimed only)

  • The exact pass/fail counts in the PR body's "Test suites" and "Hook boundary" tables, and the executor-behavior measurements (bash NUL-discard, Node child_process NUL-refusal) — I did not have shell/jq/bash-script execution available in this review environment (bash, jq, shellcheck all required approval that wasn't grantable here), so these are the author's own numbers, not independently reproduced. If this is a recurring limitation for this review lane, allowlisting bash, jq, and shellcheck would let a future review actually re-run the test suites and lint gates directly instead of relying on CI's own job logs.

Comment thread lib/hook-utils.sh Outdated
Comment thread lib/hook-utils.sh Outdated
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

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>
@kyle-sexton
kyle-sexton force-pushed the fix/hook-utils-nul-fail-open branch from d6cd5e9 to 187a0e0 Compare August 10, 2026 06:24
#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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 6m 12s —— View job


Security review complete

  • Gather context (PR diff, changed files)
  • Review lib/hook-utils.sh (hook::jq_fields change — now strip+flag, not the truncate design in the PR body)
  • Review block-no-verify.sh / block-dangerous-git.sh fail-closed logic
  • Check vendored copies for drift (byte-identical, confirmed via sha256sum)
  • Check for injection surfaces, GH Actions risk, secret handling
  • Post final findings

Scope: lib/hook-utils.sh, lib/hook-utils.test.sh, plugins/guardrails/hooks/{block-no-verify,block-dangerous-git}.sh + tests, plugins/guardrails/README.md, and the 16 vendored hook-utils.sh copies + manifest/changelog bumps. No .github/ workflow file is touched by this diff, so the unpinned-action / trigger / permissions / template-injection lane is out of scope here (deferred to zizmor per instructions).

Note: the code on this branch has moved past what the PR description narrates. The description argues for a truncate design (explode/implode), but the tip commit (re-point the NUL cases at strip semantics) replaced that with strip (split("")|join("")) to reconcile with #2120, which merged to main first (8446af4) with its own strip-based fix. I reviewed the code as it actually is at d3af1b3, not the superseded prose.

Findings

1. 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 main.

The PR body states (and a prior review round trusted): "hook::jq_fields, excluding vendored copies, returns exactly two call sites — both in this PR" and frames #2120 as "still open", with the flag-check gap for its ten converted hooks as a future merge-order risk ("Whoever merges second must keep both correctness properties").

That's no longer accurate. git log shows #2120 (8446af4, "parse each hook payload in one jq process") already merged to main, and this branch already pulled it in via 11dcc3b. Right now, on this exact branch (and on main), there are 12 non-vendored call sites, not 2 — confirmed by direct grep, not the stale count in the description. Of those, five perform command/argv-pattern matching with a documented exit 2 block path and do not consult HOOK_JQ_FIELDS_NUL:

(the remaining five — hardcoded-path-check.sh, secret-pattern-detection.sh, skill-reference-verify.sh, stale-path-verify.sh, flag-commit-pr-skill-bypass.sh — are content-scanning, which the PR body's own reasoning treats as lower-risk under strip semantics since splicing preserves byte order/adjacency rather than truncating).

This isn't a defect this diff introduces — the strip behavior and the unflagged callers both predate it, arriving via #2120. But this PR is the one that performed the #2120 merge integration (11dcc3bd3af1b3), had full visibility into the resulting state, and is the natural place either to add the same fail-closed flag check to the five matcher hooks above or to correct the description's scope claims so a reader isn't relying on a "two call sites, still hypothetical" picture that's no longer true. I did not verify per-hook exploitability for all five (that requires tracing each hook's own matching logic, which is outside this diff) — I spot-checked block-hook-bypass.sh, which matches substrings/patterns rather than exact argv-token equality, so it's less exposed to the specific --no-verify<NUL>x-style splice than block-no-verify.sh's exact-flag check was. The other four were not individually traced.

Suggested fix: either (a) add the same if ((HOOK_JQ_FIELDS_NUL)); then ... exit 2; fi guard used in block-no-verify.sh/block-dangerous-git.sh to the five matcher hooks listed above, or (b) if that's intentionally deferred to a follow-up, update the PR description's "exactly two call sites" / "#2120 still open" claims to reflect that #2120 has already merged and the gap is live now, not contingent on merge order.

What I checked and found clean

  • jq program injectionfilter values are only ever hardcoded literals from the two guard call sites; no payload-derived text reaches the jq program text.
  • The rewritten hook::jq_fields — flag computed from map(explode | index(0) != null) | any) over the unstripped array before the per-value split("")|join(""); HOOK_JQ_FIELDS_NUL=0 is reset unconditionally ahead of all three early-return paths ((($#)), missing jq, cardinality mismatch), so no early return can leak a stale 1 or a stale 0. Cardinality check ($# + 1) correctly accounts for the new leading flag record.
  • The ShellCheck SC2016 finding from the earlier review round is resolved — the rewritten program no longer uses a jq-side $v variable inside single-quoted bash literals (confirmed via grep -n '\$v\b', no matches), so that CI failure no longer applies to this code.
  • Ordering in both guardsHOOK_JQ_FIELDS_NUL is checked before the empty-command skip in both block-no-verify.sh and block-dangerous-git.sh, so a leading-NUL payload that strips to empty can't slip through the "no command" allow path.
  • Vendored-copy drift — all 16 copies are byte-identical to lib/hook-utils.sh (sha256sum match).
  • No .github/ workflow, settings, or permissions file is touched by this diff.
  • No secrets/credentials handling — the new BLOCKED: stderr messages are static text, never echo payload content back.
    · branch fix/hook-utils-nul-fail-open

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 7m 7s —— View job


Code review complete

  • Gather context (PR diff at latest sync, CLAUDE.md guidelines)
  • Review lib/hook-utils.sh diff since last review round
  • Review lib/hook-utils.test.sh diff
  • Review the two guard scripts + their test files
  • Check CI status for previously-flagged ShellCheck SC2016 issue
  • Post findings

Scope: this review is scoped to correctness, architecture, error handling, test coverage, and maintainability per CLAUDE.md/the lane split — security review is left to claude-security-review, already posted separately. I reviewed the full diff against origin/main, with extra attention to what changed since the prior review round: the two newest commits (11dcc3b merging origin/main — which brought in #2120's independently-landed strip fix for the same function — and d3af1b3 re-pointing tests at strip semantics).

Finding

plugins/guardrails/CHANGELOG.md line 16 contains a raw, unescaped NUL byte, and it is the live cause of this PR's ci failure. Permalink

The sentence "...is a real byte decoded from a JSON ` ` escape:" was clearly meant to show the escape sequence as literal text between backticks, but an actual 0x00 byte landed there instead of its escaped representation. I could not post this as an inline PR comment — GitHub rejects it because the file's diff has no resolvable line/hunk (see next paragraph) — so it's reported here instead, independently verified three ways in this checkout:

  1. file plugins/guardrails/CHANGELOG.mddata, not UTF-8 text — every other file this PR touches, including all 15 sibling CHANGELOG.md files, reports clean UTF-8 text.
  2. git diff renders this file as Binary files a/plugins/guardrails/CHANGELOG.md and b/plugins/guardrails/CHANGELOG.md differ — no textual diff at all, so this addition isn't reviewable on GitHub's normal file-diff view either.
  3. Traced it directly to the currently-failing ci run (31394028937, job hygiene, editorconfig-checker step): plugins/guardrails/CHANGELOG.md: Wrong character encoding ("binary" instead of "utf-8"). That's the only failing check right now.

Fix: replace the raw NUL byte with the literal escaped text (e.g. ``) so the line reads as prose again, then re-run editorconfig-checker locally to confirm.

What I verified and found correct

  • The previously-flagged ShellCheck SC2016 failure (two earlier review-round comments) is resolved, not by adding a shellcheck disable, but because the merge with perf(guardrails): parse each hook payload in one jq process, not two or three #2120 replaced the explode/implode-with-$v-jq-variable truncate construct with a split("") | join("") strip construct that never puts a jq variable inside a single-quoted bash literal. Confirmed no $v remains anywhere in lib/hook-utils.sh, and all 16 vendored copies are still byte-identical to it.
  • pr-issue-linkage now passes (previously flagged as failing for a missing ## Related section).
  • hook::jq_fields logic is correct under the merged strip semantics: HOOK_JQ_FIELDS_NUL is computed from the untruncated array before the strip (ordering is load-bearing and is called out in a comment), reset unconditionally ahead of all three early-return paths so no stale flag can leak, and the $# + 1 cardinality check can't be spuriously satisfied since NUL is fully absent from every stripped value.
  • Both guards (block-no-verify.sh, block-dangerous-git.sh) check the flag before the empty-command skip, in the same order, so an all-NUL command (which strips to an empty string) can't slip through as "no command."
  • Test coverage is thorough for the new behavior: leading/trailing/mid-token/all-NUL cases, the new splice case (--no-verify<NUL>x--no-verifyx, flagged), a literal `` text field that must not trip the flag, and flag-staleness across both a clean call and an early-return call. No gaps found for the stated scope.
  • The guardrails CHANGELOG.md/plugin.json (0.23.2) content is otherwise accurate and matches the current merged code, not the pre-merge design — good, since the PR body itself (written before the origin/main merge) still describes the superseded truncate design and doesn't mention that perf(guardrails): parse each hook payload in one jq process, not two or three #2120 landed with strip and this branch merged on top of it. Worth a description update for anyone reading it fresh, but not a code defect.

Not independently verified

  • The PR body's test-suite pass/fail counts and boundary-table measurements — I don't have bash/jq execution available in this review environment, so these remain author-claimed, not independently reproduced here.
    · branch fix/hook-utils-nul-fail-open

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 2s —— View job


Security review complete

  • Gather context (PR diff via gh pr diff, prior review rounds on this PR)
  • Review lib/hook-utils.sh diff since last security review round
  • Review the two guard scripts (block-no-verify.sh, block-dangerous-git.sh)
  • Check vendored copies for drift
  • Check CHANGELOGs / README for the previously-flagged raw NUL byte issue
  • Post findings

Scope: all 55 changed files at HEAD 435b2felib/hook-utils.sh (source of truth) and its test, the 16 vendored hooks/hook-utils.sh copies + plugin.json version bumps + CHANGELOG.md entries, plugins/guardrails/README.md, and the two guards (block-no-verify.sh, block-dangerous-git.sh) + their test files. No .github/ workflow, settings, or permissions file is touched by this diff, so unpinned-action risk, trigger patterns, permissions, and template injection are out of scope for this lane (deferred to zizmor).

No security issues found

This round re-verifies the code as it now sits after two merges from origin/main (pulling in #2120's independently-landed strip fix and #2123's here-string fix) plus the d3af1b3 test re-point commit. The design landed is strip + fail-closed flag, not the truncate design the PR description originally argued for — I reviewed the actual code, not the superseded prose.

  • Prior CI-blocking finding is resolved. The two earlier review rounds flagged a live ShellCheck SC2016 failure from a jq-side $v variable inside single-quoted bash literals (prog+='... $v ...'). That construct is gone: the merge with perf(guardrails): parse each hook payload in one jq process, not two or three #2120 replaced it with A | (X, Y), feeding the same array literal to both the flag computation and the value stripping, with no jq variable anywhere in the program text. Confirmed via grep -n '\$v\b' lib/hook-utils.sh — no matches.
  • Prior CI-blocking finding is resolved. The raw, unescaped NUL byte previously found in plugins/guardrails/CHANGELOG.md (line 16), which made that file data/binary and failed the editorconfig-checker step, is gone. file lib/hook-utils.sh plugins/guardrails/CHANGELOG.md plugins/actionlint/CHANGELOG.md plugins/bash-format/CHANGELOG.md now reports clean Unicode text, UTF-8 text for all of them.
  • jq program injectionfilter arguments to hook::jq_fields are still only ever hardcoded string literals from the two guard call sites ('.tool_input.command', '.cwd', '.tool_name'); no payload-derived text reaches the jq program text.
  • Fail-open regression via the cardinality check((${#values[@]} == $# + 1)) correctly accounts for the new leading flag record. The flag is computed from the untruncated array (map(explode | index(0) != null) | any) strictly before the per-value split("") | join(""), and the code comment explicitly calls out that this ordering is load-bearing — the exact hazard a naive textual merge would reintroduce. Traced this by hand against the current program text (lib/hook-utils.sh around the hook::jq_fields body) and it holds.
  • HOOK_JQ_FIELDS_NUL cannot leak stale state — reset to 0 unconditionally in the same block as HOOK_JQ_FIELDS=(), ahead of all three early-return paths (no filters, missing jq, cardinality mismatch), so a guard can never inherit a stale 1 from a prior invocation nor silently keep a stale 0 after a NUL-bearing call.
  • Guard ordering — both block-no-verify.sh and block-dangerous-git.sh check HOOK_JQ_FIELDS_NUL and exit 2 before the empty-command skip, so a leading/all-NUL command that strips to an empty string can't slip through the "no command" allow path.
  • Vendored-copy drift — all 16 plugins/*/hooks/hook-utils.sh copies share the identical blob SHA dcb0e820f... with lib/hook-utils.sh per the changed-files listing (byte-identical), consistent with the repo's sync gate.
  • Test files (lib/hook-utils.test.sh, both guard .test.sh files) build NUL-bearing fixtures entirely inside jq -n ([0] | implode), never via a raw byte or shell interpolation in the test source itself — no injection surface there, and no payload content is echoed back in the new BLOCKED: stderr messages (static text only).
  • CHANGELOG / README / plugin.json content — plain prose and version bumps, no injection surface, consistent across all 16 plugins.

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 SC2016 CI failure, the missing ## Related PR-body section, and the raw-NUL-byte CHANGELOG file) have each already been addressed in the commits since. Nothing new surfaced in this pass.
· branch fix/hook-utils-nul-fail-open

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 58s —— View job


Code review complete

  • Gather context (gh pr diff, CLAUDE.md, prior review rounds)
  • Review lib/hook-utils.sh diff (post-sync commit)
  • Review lib/hook-utils.test.sh diff
  • Review the two guard scripts + their test files
  • Review plugins/guardrails/README.md and CHANGELOG entries
  • Check vendored-copy sync consistency
  • Post findings as inline comments + summary

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 claude-security-review. Reviewed the tip of this branch (435b2fe, which merges origin/main a second time), not the pre-merge design the PR body originally described.

Traced correctness of the core change

Walked the rewritten hook::jq_fields jq program by hand against lib/hook-utils.sh L698-L750:

  • The flag record (map(explode | index(0) != null) | any) is computed over the raw tostring'd array before the per-value split("")|join("") strip — ordering matches what the header comment (L664-L668) mandates, and I confirmed there's no $v jq-variable left anywhere that a textual merge could reorder around.
  • HOOK_JQ_FIELDS_NUL=0 and HOOK_JQ_FIELDS=() are both reset unconditionally at the top of the function, ahead of all three early-return paths ((($#)), missing jq, cardinality mismatch) — no path leaks a stale flag from a prior call.
  • Cardinality check is $# + 1 (flag record + N values), which correctly accounts for the new leading record without reopening the original over/under-count bug.
  • Both guards (block-no-verify.sh [L99-L106], block-dangerous-git.sh L125-L130) check HOOK_JQ_FIELDS_NUL before their empty-command skip, so an all-NUL command (strips to "") can't slip through as "no command."

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)

  • The ShellCheck SC2016 finding from the two earlier inline review comments (on $v-inside-single-quotes) no longer applies — grep -n '\$v\b' lib/hook-utils.sh returns no matches on this tip; the strip/flag construct doesn't use a jq-side $v variable at all.
  • The raw-NUL-byte-in-CHANGELOG.md finding from the previous review round is fixed — file plugins/guardrails/CHANGELOG.md now reports Unicode text, UTF-8 text, and the line in question reads as escaped `` prose.
  • Current CI (mcp__github_ci__get_ci_status) shows 4/6 checks passed, 0 failed, 2 still in progress (ci, this review) — consistent with those fixes holding.

Finding (inline, minor)

Posted on block-dangerous-git.test.sh:849-863: block-dangerous-git.sh emits the identical three-line BLOCKED:/What a guard can.../Fix: reissue... stderr message as block-no-verify.sh (byte-for-byte, confirmed via grep), but only block-no-verify.test.sh asserts on the message content — block-dangerous-git.test.sh's run_nul cases check exit codes only. A future edit to one guard's wording wouldn't be caught by the other's test suite. Low severity, easy parity fix.

What I did not independently verify

The PR body's test-suite pass/fail counts and boundary-measurement tables — no bash/jq execution tool is granted to this review lane (consistent with what earlier review rounds reported), so I traced the jq program logic by hand and via diff inspection rather than running the suites myself. Treat those counts as author-claimed. CI's own ci run (in progress at time of this review) is the independent signal for pass/fail.

Everything else checked and found consistent

  • All 16 vendored hook-utils.sh copies carry the identical git blob SHA (dcb0e820f...) per the PR's file list — byte-identical, as the sync gate requires.
  • plugin.json/CHANGELOG.md version bumps line up across all 16 plugins spot-checked (actionlint 0.8.2→0.8.3, guardrails 0.24.0→0.24.1).
  • README.md and CHANGELOG.md prose for guardrails accurately describes the strip (not truncate) design actually shipped on this tip, unlike the PR body's original (now-superseded, and now corrected) narration.
    · branch fix/hook-utils-nul-fail-open

Comment thread plugins/guardrails/hooks/block-dangerous-git.test.sh
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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 30s —— View job


Security review complete

  • Gather context (gh pr diff / git diff against origin/main, prior review rounds)
  • Diff since last security review round (435b2fe043d60c)
  • Review lib/hook-utils.sh / vendored copies for drift
  • Review the two guard scripts for regressions
  • Post findings

Scope: all 55 files this PR changes relative to origin/mainlib/hook-utils.sh (source of truth) + its test, the 16 vendored hooks/hook-utils.sh copies + plugin.json bumps + CHANGELOG.md entries, plugins/guardrails/README.md, and the two guards (block-no-verify.sh, block-dangerous-git.sh) + their test files. No .github/ workflow, settings, or permissions file is touched, so unpinned-action risk, trigger patterns, permissions, and template injection stay out of scope for this lane (deferred to zizmor).

No security issues found

This is a synchronize trigger on a PR that has already been through five prior automated security-review passes on this thread. I diffed the current tip (043d60c) against the last commit a security review actually ran on (435b2fe) and confirmed no security-relevant file changed in that window:

  • git diff 435b2fe..HEAD touching lib/, any vendored hooks/hook-utils.sh, any plugin.json, any CHANGELOG.md, or the two guard scripts / their tests — empty. The hook::jq_fields logic, the fail-closed HOOK_JQ_FIELDS_NUL checks in both guards, and all 16 vendored copies are byte-for-byte what the prior round (which found no issues) already reviewed.
  • The only content that changed in this window is plugins/guardrails/README.md (8 lines) — a pure prose fix renaming /pull-request create/source-control:pull-request create and /commit/source-control:commit in documentation text, no code or security-relevant behavior.
  • Everything else in the 043d60c merge commit (docs/, plugins/claude-ops/, plugins/planning/, plugins/source-control/skills/commit/scripts/exec-bit-check.sh, etc.) came in from origin/main via the merge and predates the merge-base (cf743d6) — confirmed via git diff cf743d6..HEAD --stat, which reproduces exactly this PR's own 55-file set with no unrelated files present. None of it is this PR's own contribution.

Re-affirming what the prior rounds already established and which still holds on this tip:

  • jq program injectionfilter args to hook::jq_fields are hardcoded literals only, never payload-derived.
  • Fail-open regression via the cardinality check$# + 1 correctly accounts for the leading flag record; the flag is computed from the untruncated/unstripped array strictly before the strip, and a code comment marks that ordering as load-bearing.
  • HOOK_JQ_FIELDS_NUL can't leak stale state — reset unconditionally ahead of all three early-return paths.
  • Both guards check the flag before their empty-command skip — an all-NUL command can't slip through as "no command."
  • Vendored-copy drift — all 16 copies share the identical blob SHA with lib/hook-utils.sh.
  • No secrets/credential handling — the BLOCKED: stderr messages are static text, never echo payload content.
    · branch fix/hook-utils-nul-fail-open

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 26s —— View job


Code review complete

  • Gather context (gh pr diff against origin/main, prior review rounds on this PR)
  • Review lib/hook-utils.sh diff since the last review round (now at 043d60c, a third origin/main merge)
  • Review lib/hook-utils.test.sh diff
  • Review the two guard scripts + their test files
  • Review plugins/guardrails/README.md / CHANGELOG entries
  • Check vendored-copy sync consistency
  • Check CI status
  • Post findings

Scope: correctness, architecture, error handling, test coverage, maintainability — security is left to the claude-security-review lane, already posted separately for this HEAD. Reviewed the full 55-file diff against origin/main at tip 043d60c.

No new findings

This sync (435b2fe043d60c) is a third merge of origin/main into the branch, adding one unrelated commit (cf743d6, a source-control exec-bit-check fix). I diffed 435b2fe..043d60c for every file this PR's own scope touches and confirmed none of the core logic changed: lib/hook-utils.sh, lib/hook-utils.test.sh, both guard scripts, and their test files are byte-identical to what the previous review round (posted 2026-08-10T14:26–14:30Z) already covered. The only content delta in scope is plugins/guardrails/README.md picking up two unrelated skill-name updates (/pull-request create/source-control:pull-request create) from origin/main — documentation only, no behavioral surface.

Independently re-verified that the two CI-blocking issues from earlier rounds are still resolved at this tip:

  • No jq-side $v variable remains in lib/hook-utils.sh (grep -n '\$v\b' — no matches), so the ShellCheck SC2016 false-positive is gone for good, not just patched around.
  • plugins/guardrails/CHANGELOG.md and lib/hook-utils.sh both report clean Unicode text, UTF-8 text via file — the earlier raw-NUL-byte corruption has not recurred.
  • Spot-checked guardrails: plugin.json version (0.24.1) matches the CHANGELOG.md top entry (## [0.24.1]).

gh/CI status is green: ci, pr-title, pr-issue-linkage, and do-not-merge all passed; claude-security-review completed successfully for this tip.

Still outstanding (already flagged, unchanged, non-blocking)

The minor test-coverage-parity gap from the previous round's inline comment on block-dangerous-git.test.sh is still present: block-dangerous-git.sh emits the identical three-line BLOCKED: stderr message as block-no-verify.sh, but only block-no-verify.test.sh asserts on the message content — block-dangerous-git.test.sh's run_nul cases (L849-L861) still check exit codes only. Not re-posting a duplicate inline comment since the file is unchanged since that finding was raised — the existing thread already covers it.

Not independently verified

bash/jq/shellcheck execution is not available to this review lane in this environment (approval not grantable for running scripts), so the PR body's test-suite pass/fail counts and boundary-measurement tables remain author-claimed, not independently reproduced here. CI's own green ci run is the independent signal for pass/fail at this tip.
· branch fix/hook-utils-nul-fail-open

@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Suite and boundary results after the three merges

Scoped honestly: this is what was re-run on this host after the merges, and what was not. The host is
heavily contended (several agents running concurrently), which is why the list is shorter than the
14-suite table in the body — that table was measured before main moved and should be read as
historical, not current.

Re-run and green

suite result note
lib/hook-utils.test.sh PASS=172 FAIL=0 includes the re-pointed NUL cases and the two new ones
plugins/guardrails/hooks/block-no-verify.test.sh PASS=128 FAIL=0 measured at the second-merge tree; the third merge touched only plugins/source-control/CHANGELOG.md

shellcheck -x with no severity floor over lib/hook-utils.sh, plugins/bash-format/hooks/hook-utils.sh,
both guards and all three test files: rc 0. shfmt -d -i 2: rc 0.
sync-hook-utils.sh --check: 16/16 byte-identical. sync-hook-utils.sh --check-bump origin/main,
check-changelog-parity.sh --check / --check-bump origin/main / --check-order: all clean.

Not re-run — stated rather than implied

  • block-dangerous-git.test.sh did not finish on this host. It was started three times and
    killed or outrun each time by machine contention. Its NUL rows are covered directly by the
    boundary measurement below, but the suite's own 346-case run is unverified after the merges.
  • The other eleven guardrails suites were not re-run. Those are the hooks perf(guardrails): parse each hook payload in one jq process, not two or three #2120 converted to this
    helper. sync-hook-utils.sh --check proves the sixteen vendored copies are byte-identical to
    lib/hook-utils.sh; it proves nothing about those hooks' behaviour against the changed library.
    The library change they see is additive — a new global, and the flag computed before the same
    strip they already had — but that is an argument, not a measurement.

Boundary, re-measured against CURRENT main

The body's table cites main at fd075c27. #2147 has since rewritten how block-dangerous-git
reads its payload fields, so the two live rows were re-measured against main at 04822fc4:

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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 47s —— View job


Security review complete

  • Gather context (gh pr diff / prior review rounds on this thread)
  • Diff since last security review round (043d60c10f8da3)
  • Re-verify no security-relevant surface changed
  • Post findings

Scope: all 55 files this PR changes relative to origin/mainlib/hook-utils.sh (source of truth) + its test, the 16 vendored hooks/hook-utils.sh copies + plugin.json bumps + CHANGELOG.md entries, plugins/guardrails/README.md, and the two guards (block-no-verify.sh, block-dangerous-git.sh) + their test files. No .github/ workflow, settings, or permissions file is touched, so unpinned-action risk, trigger patterns, permissions, and template injection stay out of scope for this lane (deferred to zizmor).

No security issues found

This is a synchronize trigger. The only change since the last security-review pass (043d60c) is a single commit, 10f8da3:

plugins/guardrails/hooks/block-dangerous-git.test.sh | 14 ++++++++++++++
1 file changed, 14 insertions(+)

It adds two assert_contains checks (stderr message content: "NUL byte" and "reissue the tool call without the embedded NUL") to block-dangerous-git.test.sh's NUL cases, mirroring the assertions already present in block-no-verify.test.sh. This directly resolves the test-coverage-parity gap the code-review lane flagged in an earlier round — it's test-only, adds no new code path, and doesn't touch lib/hook-utils.sh, either guard script, any vendored copy, or any plugin.json/CHANGELOG.md. No security surface changed.

Re-affirming what prior rounds on this thread already established and which still holds at 10f8da3:

  • jq program injectionfilter args to hook::jq_fields are hardcoded literals only, never payload-derived.
  • Fail-open regression via the cardinality check$# + 1 correctly accounts for the leading flag record; the flag is computed from the untruncated/unstripped array strictly before the strip, and a code comment marks that ordering as load-bearing.
  • HOOK_JQ_FIELDS_NUL can't leak stale state — reset unconditionally ahead of all three early-return paths.
  • Both guards check the flag before their empty-command skip — an all-NUL command can't slip through as "no command."
  • Vendored-copy drift — all 16 copies share the identical blob SHA with lib/hook-utils.sh.
  • No secrets/credential handling — the BLOCKED: stderr messages (now asserted identically in both test files) are static text, never echo payload content.
    · branch fix/hook-utils-nul-fail-open

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 29s —— View job


Code review complete

  • Gather context (git diff origin/main...HEAD, prior review rounds on this PR)
  • Review what changed since the last review round (043d60c10f8da3)
  • Check CI status
  • Post findings

Scope: this sync adds exactly one commit, touching exactly one file: plugins/guardrails/hooks/block-dangerous-git.test.sh (+14 lines). Everything else in the 55-file diff is unchanged since the previous round's code-review pass (which found no issues in it), so this review is scoped to that one addition.

The change resolves the parity gap flagged in the prior round

The previous code-review pass on this PR left an inline comment: block-dangerous-git.sh emits the identical three-line BLOCKED:/What a guard can.../Fix: reissue... stderr message as block-no-verify.sh, but only block-no-verify.test.sh asserted on the message content — block-dangerous-git.test.sh's NUL cases checked exit codes only, so a wording drift between the two guards wouldn't be caught by either suite.

This commit adds a nul_stderr helper plus two assert_contains checks to block-dangerous-git.test.sh, mirroring the existing pattern in block-no-verify.test.sh line-for-line (nul_stderr helper, same jq -n --arg h --arg t payload construction, same two assertions). Traced by hand against the actual guard source:

  • plugins/guardrails/hooks/block-dangerous-git.sh:126 emits "BLOCKED: the payload carries a NUL byte, which a command cannot reliably carry." — matches the new assertion's "NUL byte" substring.
  • plugins/guardrails/hooks/block-dangerous-git.sh:128 emits "Fix: reissue the tool call without the embedded NUL." — matches the new assertion's "reissue the tool call without the embedded NUL" substring.
  • The fixture head (git reset --hard) is a pattern block-dangerous-git.sh actually recognizes as dangerous, so the NUL-carrying payload reaches the block path this test means to exercise rather than falling through on a pattern miss.
  • HOOK and assert_contains are both already in scope from the file's existing header (guardrails-test-helpers.sh source, HOOK="$HOOK_DIR/block-dangerous-git.sh"), so the addition is self-contained and consistent with the surrounding file's conventions.

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

bash/jq execution isn't available to this review lane in this environment (approval isn't grantable here), so I traced the assertion logic against the guard's actual stderr text and the sibling test file by hand/grep rather than running the suite. CI (ci job) was still queued at review time; pr-title, pr-issue-linkage, do-not-merge, and claude-security-review had already completed successfully with no failures.
· branch fix/hook-utils-nul-fail-open

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 35s —— View job


Security review complete

  • Gather context (PR diff, prior review rounds already on this thread)
  • Diff current HEAD against last-reviewed commit (10f8da3faff1a4)
  • Review the new content for security issues
  • Post findings

Scope: all 55 files this PR changes relative to origin/mainlib/hook-utils.sh (source of truth) + its test, the 16 vendored hooks/hook-utils.sh copies + plugin.json bumps + CHANGELOG.md entries, plugins/guardrails/README.md, and the two guards (block-no-verify.sh, block-dangerous-git.sh) + their test files. No .github/ workflow, settings, or permissions file is touched, so unpinned-action risk, trigger patterns, permissions, and template injection stay out of scope for this lane (deferred to zizmor).

No security issues found

This is a synchronize trigger. The only change since the last security-review pass (10f8da3) is a single commit, faff1a4:

plugins/guardrails/hooks/block-dangerous-git.test.sh | 9 +++++++++
plugins/guardrails/hooks/block-no-verify.test.sh     | 9 +++++++++
2 files changed, 18 insertions(+)

It adds one paired test case to each guard's suite — an all-NUL command (nul_stderr '' '') asserted to be blocked for the NUL reason, and an empty command with no NUL asserted to be allowed (exit 0) via the existing empty-command skip. This pins the ordering already reviewed and confirmed in prior rounds (HOOK_JQ_FIELDS_NUL checked before the empty-command skip in both guards) with an explicit test pair rather than changing it. Test-only: no change to lib/hook-utils.sh, either guard script, any vendored copy, or any plugin.json/CHANGELOG.md. No new security surface.

Re-affirming what prior rounds on this thread already established and which still holds at faff1a4:

  • jq program injectionfilter args to hook::jq_fields are hardcoded literals only, never payload-derived.
  • Fail-open regression via the cardinality check$# + 1 correctly accounts for the leading flag record; the flag is computed from the untruncated/unstripped array strictly before the strip, and a code comment marks that ordering as load-bearing.
  • HOOK_JQ_FIELDS_NUL can't leak stale state — reset unconditionally ahead of all three early-return paths.
  • Both guards check the flag before their empty-command skip — an all-NUL command can't slip through as "no command" (now also pinned by the new test pair above).
  • Vendored-copy drift — all 16 copies share the identical blob SHA with lib/hook-utils.sh.
  • No secrets/credential handling — the BLOCKED: stderr messages are static text, never echo payload content.
    · branch fix/hook-utils-nul-fail-open

@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 47s —— View job


Security review complete

  • Gather context (git log/git show against prior review rounds on this thread)
  • Diff current HEAD (ceedf32) against last-reviewed commit
  • Re-verify no new security-relevant surface changed
  • Post findings

Scope: all 55 files this PR changes relative to origin/mainlib/hook-utils.sh (source of truth) + its test, the 16 vendored hooks/hook-utils.sh copies + plugin.json bumps + CHANGELOG.md entries, plugins/guardrails/README.md, and the two guards (block-no-verify.sh, block-dangerous-git.sh) + their test files. No .github/ workflow, settings, or permissions file is touched, so unpinned-action risk, trigger patterns, permissions, and template injection stay out of scope for this lane (deferred to zizmor).

No security issues found

This is a synchronize trigger. The tip commit, ceedf32, is test-only:

plugins/guardrails/hooks/block-dangerous-git.test.sh | 9 +++++++++
plugins/guardrails/hooks/block-no-verify.test.sh     | 9 +++++++++
2 files changed, 18 insertions(+)

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 lib/hook-utils.sh, either guard script, any vendored copy, or any plugin.json/CHANGELOG.md. No new security surface.

Re-affirming what prior rounds on this thread already established and which still holds at ceedf32:

  • jq program injectionfilter args to hook::jq_fields are hardcoded literals only, never payload-derived.
  • Fail-open regression via the cardinality check$# + 1 correctly accounts for the leading flag record; the flag is computed from the untruncated/unstripped array strictly before the strip, and a code comment marks that ordering as load-bearing.
  • HOOK_JQ_FIELDS_NUL can't leak stale state — reset unconditionally ahead of all three early-return paths.
  • Both guards check the flag before their empty-command skip — an all-NUL command can't slip through as "no command" (now pinned by an explicit ordering-boundary test pair in both suites, added by this exact commit).
  • Vendored-copy drift — all 16 copies share the identical blob SHA with lib/hook-utils.sh.
  • No secrets/credential handling — the BLOCKED: stderr messages are static text, never echo payload content.
    · branch fix/hook-utils-nul-fail-open

…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>
@kyle-sexton
kyle-sexton force-pushed the fix/hook-utils-nul-fail-open branch from ceedf32 to 4aff456 Compare August 10, 2026 16:40
@claude

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 56s —— View job


Security review complete

  • Gather context (gh pr diff / prior review rounds already on this thread)
  • Diff current HEAD (4aff456) against the last fully-reviewed commit (043d60c)
  • Re-verify no new security-relevant surface changed
  • Post findings

Scope: all 55 files this PR changes relative to origin/mainlib/hook-utils.sh (source of truth) + its test, the 16 vendored hooks/hook-utils.sh copies + plugin.json bumps + CHANGELOG.md entries, plugins/guardrails/README.md, and the two guards (block-no-verify.sh, block-dangerous-git.sh) + their test files. No .github/ workflow, settings, or permissions file is touched, so unpinned-action risk, trigger patterns, permissions, and template injection stay out of scope for this lane (deferred to zizmor).

No security issues found

git diff 043d60c..HEAD --stat confirms exactly two files changed since the last full security-review pass on this thread, both test-only:

plugins/guardrails/hooks/block-dangerous-git.test.sh | 23 ++++++++++++++++++++
plugins/guardrails/hooks/block-no-verify.test.sh     |  9 +++++++++
2 files changed, 32 insertions(+)

The two commits since then (10f8da3, 4aff456) add:

  1. assert_contains checks in block-dangerous-git.test.sh for the BLOCKED: stderr message content on the NUL path — mirroring assertions already present in block-no-verify.test.sh, closing a coverage-parity gap the code-review lane flagged earlier (message-content drift between the two guards wasn't caught by either suite before).
  2. A pinning pair in both test files: an all-NUL command asserted blocked with the NUL reason, paired with a NUL-free empty command asserted still allowed via the pre-existing empty-command skip. This pins the ordering already reviewed and confirmed correct in prior rounds (HOOK_JQ_FIELDS_NUL checked before the empty-command skip in both guards) with an explicit test, rather than changing behavior.

No change to lib/hook-utils.sh, either guard script, any vendored copy, or any plugin.json/CHANGELOG.md. No new security surface — this diff only adds assertions against already-shipped, already-reviewed behavior.

Re-affirming what prior rounds on this thread already established and which still holds at 4aff456:

  • jq program injectionfilter args to hook::jq_fields are hardcoded literals only, never payload-derived.
  • Fail-open regression via the cardinality check$# + 1 correctly accounts for the leading flag record; the flag is computed from the untruncated/unstripped array strictly before the strip, and a code comment marks that ordering as load-bearing.
  • HOOK_JQ_FIELDS_NUL can't leak stale state — reset unconditionally ahead of all three early-return paths.
  • Both guards check the flag before their empty-command skip — an all-NUL command can't slip through as "no command" (now pinned by an explicit ordering-boundary test pair in both suites, confirmed in this round's diff).
  • Vendored-copy drift — all 16 copies share the identical blob SHA with lib/hook-utils.sh.
  • No secrets/credential handling — the BLOCKED: stderr messages are static text, never echo payload content.
    · branch fix/hook-utils-nul-fail-open

kyle-sexton added a commit that referenced this pull request Aug 10, 2026
…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

claude Bot commented Aug 10, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 53s —— View job


Security review complete

  • Gather context (prior review rounds already on this thread)
  • Diff current HEAD (854b649) against last-reviewed commit (4aff456)
  • Re-verify no new security-relevant surface changed
  • Post findings

Scope: all 55 files this PR changes relative to origin/mainlib/hook-utils.sh (source of truth) + its test, the 16 vendored hooks/hook-utils.sh copies + plugin.json bumps + CHANGELOG.md entries, plugins/guardrails/README.md, and the two guards (block-no-verify.sh, block-dangerous-git.sh) + their test files. No .github/ workflow, settings, or permissions file is touched by this diff, so unpinned-action risk, trigger patterns, permissions, and template injection stay out of scope for this lane (deferred to zizmor).

No security issues found

This is a synchronize trigger. git diff origin/main...HEAD --stat confirms the PR's own file set is unchanged — still exactly the same 55 files as the last fully-reviewed round, 2114 insertions(+), 496 deletions(-).

The tip commit, 854b649, is Merge remote-tracking branch 'origin/main' into HEAD. git diff 4aff456..HEAD -- <every security-relevant file in this PR's scope> (lib/hook-utils.sh, lib/hook-utils.test.sh, both guards + their tests, the guardrails vendored copy, plugin.json, CHANGELOG.md) returns empty — none of it changed. Everything the merge actually brought in (plugins/songwriting/**, the new plugins/mutation-testing/** plugin, plugins/tdd/**, plugins/verification/**, scripts/check-changelog-parity.sh, .github/workflows/ci.yml, .claude-plugin/marketplace.json, etc. — 51 files, +2724/-222) landed from origin/main and is not this PR's own contribution; none of it is in this PR's diff against origin/main.

Re-affirming what prior rounds on this thread already established and which still holds at 854b649:

  • jq program injectionfilter args to hook::jq_fields are hardcoded literals only, never payload-derived.
  • Fail-open regression via the cardinality check$# + 1 correctly accounts for the leading flag record; the flag is computed from the untruncated/unstripped array strictly before the strip, and a code comment marks that ordering as load-bearing.
  • HOOK_JQ_FIELDS_NUL can't leak stale state — reset unconditionally ahead of all three early-return paths.
  • Both guards check the flag before their empty-command skip — an all-NUL command can't slip through as "no command" (pinned by explicit ordering-boundary tests in both suites).
  • Vendored-copy drift — all 16 copies share the identical blob SHA with lib/hook-utils.sh.
  • No secrets/credential handling — the BLOCKED: stderr messages are static text, never echo payload content.
    · branch fix/hook-utils-nul-fail-open

@kyle-sexton
kyle-sexton merged commit 91e77fc into main Aug 10, 2026
33 checks passed
@kyle-sexton
kyle-sexton deleted the fix/hook-utils-nul-fail-open branch August 10, 2026 20:14
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
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>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
#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>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
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>
kyle-sexton added a commit that referenced this pull request Aug 10, 2026
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>
kyle-sexton added a commit that referenced this pull request Aug 11, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 11, 2026
… 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>
kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 16, 2026
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>
kyle-sexton added a commit that referenced this pull request Aug 16, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 17, 2026
…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>
kyle-sexton added a commit that referenced this pull request Aug 17, 2026
…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
kyle-sexton added a commit that referenced this pull request Aug 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

guardrails: a NUL byte in a payload value makes block-no-verify and block-dangerous-git fail open

1 participant