Skip to content

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

Description

@kyle-sexton

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions