Skip to content

guardrails: twelve hooks read payload values through hook::jq_fields and none consults the NUL signal #2136

Description

@kyle-sexton

Follow-up, deliberately scoped out of the helper PR so its blast radius is chosen rather than inherited.

State on main today

hook::jq_fields strips NUL bytes from every value (landed with #2120). Twelve hooks call it. Zero consult any NUL signal, and eight own a block verdict:

HOOK                             jq_fields  NUL-check  exit 2
block-convention-violation       2          0          3
block-dangerous-git              2          0          7
block-hook-bypass                2          0          2
block-noncanonical-commit        2          0          5
block-no-verify                  2          0          3
cli-flag-verify                  2          0          1
flag-commit-pr-skill-bypass      2          0          0
hardcoded-path-check             2          0          2
secret-pattern-detection         2          0          2
skill-reference-verify           3          0          0
stale-path-verify                3          0          0
workflow-resilience-check        2          0          0

Ten of the twelve were newly converted by #2120; six of those own exit 2.

Why a signal is needed at all

Whatever the helper does with a NUL — strip it, truncate at it, or refuse — the caller is deciding a verdict on a value that is not the bytes the payload carried. Stripping joins text across the byte; truncating discards everything after it. Either way a hook that reports "scanned, clean" has scanned something other than what it was given, and today it cannot tell.

Two hooks read content fields with no upstream protection of any kind: secret-pattern-detection.sh and hardcoded-path-check.sh both take .tool_input.content, .new_string, .new_source, and both own exit 2. The control-character validation that exists in the harness covers command, script, and url only — and its own message names its purpose as hiding text from the human approval dialog, so it is an anti-spoofing measure rather than an input sanitizer.

What this issue asks for

Once the helper exposes a NUL signal, add the check to the callers — roughly three lines each — so a hook refuses rather than reporting a clean scan of a modified value.

It is not in the helper PR on purpose. Six verdict-owning hooks going fail-closed simultaneously is a real change in behaviour on main, and it deserves its own verification pass rather than riding along with a library change. Doing it in one sweep also means a single mistake affects every guard at once.

Suggested sequencing

  1. The helper exposes the signal (in progress).
  2. This issue: add checks to the two content-scanning verdict-owners first — secret-pattern-detection.sh and hardcoded-path-check.sh — since those are the ones where a modified value can hide a credential or a machine path.
  3. Then the remaining verdict-owners.
  4. The four non-verdict hooks last, or never, if refusing is the wrong response for them — that is a per-hook judgement, not a sweep.

Measured, not assumed

The census above was run three times independently and agreed on twelve, zero, and eight. An earlier count of "four and two" was published and was wrong — it enumerated the hooks named in a review thread rather than the conversion set. Re-run it before acting rather than trusting this table; the file list moves.

exit 2 reachability through the content fields specifically was confirmed on both scanning hooks — a credential in .tool_input.content and in .new_string each produce exit 2, with clean controls at 0. Note when reproducing that hardcoded-path-check.sh returns early when CLAUDE_PROJECT_DIR is unset, so a probe without it exits 0 on every payload and reads exactly like "not reachable".

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