Empirically confirmed against the installed, shipped guardrails 0.21.0, at the hook boundary — not a code-read inference, and not confined to an open PR.
One premise is explicitly unverified, and the finding is scoped accordingly: whether a NUL can actually reach .tool_input.command through the harness payload serialization is not something I can establish from here. Everything below is measured given such a payload. The defect stands either way — failing open on a parse failure is wrong regardless of how the parse came to fail — but the reachability question belongs to whoever fixes this, and should not be read as settled.
The defect
hook::jq_fields (hooks/hook-utils.sh) frames its fields with a NUL delimiter and validates the result by cardinality alone:
prog="[$prog] | .[] | (., \"\u0000\")"
...
while IFS= read -r -d "" v; do ... done < <(printf %s "$input" | jq -j "$prog" 2>/dev/null)
((${#values[@]} == $#)) || return 1
The delimiter is drawn from the same byte space as the decoded values it separates. \u0000 is valid JSON, so a payload value may legitimately contain a NUL. When it does, that one value splits into two, the cardinality check fails, and the function returns 1. Callers treat that as "cannot parse, carry on":
hooks/block-dangerous-git.sh:97 hook::jq_fields "$INPUT" ... || exit 0
hooks/block-no-verify.sh:82 hook::jq_fields "$INPUT" ... || exit 0
|| exit 0 is a PreToolUse allow.
Measured at the hook boundary
Not the library function in isolation — the shipped hooks themselves, fed on stdin:
block-no-verify.sh clean exit=2 BLOCKED: --no-verify / -n flags are not allowed with git pus...
block-no-verify.sh NUL exit=0 (no output)
block-dangerous-git.sh clean exit=2 BLOCKED: git reset --hard discards uncommitted work with no ...
block-dangerous-git.sh NUL exit=0 (no output)
Appending a JSON NUL escape to the command flips a blocked call into an allowed one, on both hooks, with no diagnostic of any kind. Reproduce with:
source <cache>/melodic-software/guardrails/0.21.0/hooks/hook-utils.sh
P="{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"git push --no-verify\u0000x\"}}"
printf %s "$P" | bash <cache>/melodic-software/guardrails/0.21.0/hooks/block-no-verify.sh; echo rc=$?
Both hooks are on main today.
Direction
Failing closed is the smaller half and is not sufficient alone — a legitimate NUL-bearing payload would then block real work, converting a silent allow into a silent denial. The framing is the root cause: a delimiter must not be drawn from the value space it delimits. Length-prefixed framing, an explicit count emitted by jq and validated by the reader, or per-field @base64, all remove the collision without depending on payload contents.
Relationship to PR #2120
Review thread PRRT_kwDOTCGFQM6XtLpy on #2120 raises this against secret-pattern-detection.sh, which that PR is newly converting to hook::jq_fields. That finding is correct and should stand.
The scope is wider than the thread claims, in both directions. #2120 does not touch hooks/hook-utils.sh — its file list is nine hooks plus the manifest and changelog — so it does not address the root cause. And the two hooks above already consume jq_fields on main, so the bypass is live in shipped code independent of that PR. #2120 therefore extends the same failure mode to nine further hooks while leaving the collision in place. Fixing the framing in hook-utils.sh fixes every consumer at once, present and pending.
Empirically confirmed against the installed, shipped
guardrails0.21.0, at the hook boundary — not a code-read inference, and not confined to an open PR.One premise is explicitly unverified, and the finding is scoped accordingly: whether a NUL can actually reach
.tool_input.commandthrough the harness payload serialization is not something I can establish from here. Everything below is measured given such a payload. The defect stands either way — failing open on a parse failure is wrong regardless of how the parse came to fail — but the reachability question belongs to whoever fixes this, and should not be read as settled.The defect
hook::jq_fields(hooks/hook-utils.sh) frames its fields with a NUL delimiter and validates the result by cardinality alone:The delimiter is drawn from the same byte space as the decoded values it separates.
\u0000is valid JSON, so a payload value may legitimately contain a NUL. When it does, that one value splits into two, the cardinality check fails, and the function returns 1. Callers treat that as "cannot parse, carry on":|| exit 0is a PreToolUse allow.Measured at the hook boundary
Not the library function in isolation — the shipped hooks themselves, fed on stdin:
Appending a JSON NUL escape to the command flips a blocked call into an allowed one, on both hooks, with no diagnostic of any kind. Reproduce with:
Both hooks are on
maintoday.Direction
Failing closed is the smaller half and is not sufficient alone — a legitimate NUL-bearing payload would then block real work, converting a silent allow into a silent denial. The framing is the root cause: a delimiter must not be drawn from the value space it delimits. Length-prefixed framing, an explicit count emitted by jq and validated by the reader, or per-field
@base64, all remove the collision without depending on payload contents.Relationship to PR #2120
Review thread
PRRT_kwDOTCGFQM6XtLpyon #2120 raises this againstsecret-pattern-detection.sh, which that PR is newly converting tohook::jq_fields. That finding is correct and should stand.The scope is wider than the thread claims, in both directions. #2120 does not touch
hooks/hook-utils.sh— its file list is nine hooks plus the manifest and changelog — so it does not address the root cause. And the two hooks above already consumejq_fieldsonmain, so the bypass is live in shipped code independent of that PR. #2120 therefore extends the same failure mode to nine further hooks while leaving the collision in place. Fixing the framing inhook-utils.shfixes every consumer at once, present and pending.