From 187a0e0b02cbe391cf877e883e1686bd0339515c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:10:15 -0400 Subject: [PATCH 1/4] fix(hook-utils): stop a NUL in a payload value from voiding two blocking guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- lib/hook-utils.sh | 90 +++++++++++++---- lib/hook-utils.test.sh | 97 +++++++++++++++++++ plugins/actionlint/.claude-plugin/plugin.json | 2 +- plugins/actionlint/CHANGELOG.md | 13 +++ plugins/actionlint/hooks/hook-utils.sh | 90 +++++++++++++---- plugins/autonomy/.claude-plugin/plugin.json | 2 +- plugins/autonomy/CHANGELOG.md | 13 +++ plugins/autonomy/hooks/hook-utils.sh | 90 +++++++++++++---- .../bash-format/.claude-plugin/plugin.json | 2 +- plugins/bash-format/CHANGELOG.md | 13 +++ plugins/bash-format/hooks/hook-utils.sh | 90 +++++++++++++---- .../biome-format/.claude-plugin/plugin.json | 2 +- plugins/biome-format/CHANGELOG.md | 13 +++ plugins/biome-format/hooks/hook-utils.sh | 90 +++++++++++++---- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 13 +++ plugins/claude-ops/hooks/hook-utils.sh | 90 +++++++++++++---- .../context-guard/.claude-plugin/plugin.json | 2 +- plugins/context-guard/CHANGELOG.md | 13 +++ plugins/context-guard/hooks/hook-utils.sh | 90 +++++++++++++---- .../.claude-plugin/plugin.json | 2 +- plugins/desktop-notification/CHANGELOG.md | 13 +++ .../desktop-notification/hooks/hook-utils.sh | 90 +++++++++++++---- .../eol-normalizer/.claude-plugin/plugin.json | 2 +- plugins/eol-normalizer/CHANGELOG.md | 13 +++ plugins/eol-normalizer/hooks/hook-utils.sh | 90 +++++++++++++---- plugins/go-format/.claude-plugin/plugin.json | 2 +- plugins/go-format/CHANGELOG.md | 13 +++ plugins/go-format/hooks/hook-utils.sh | 90 +++++++++++++---- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 25 +++++ plugins/guardrails/README.md | 10 ++ .../guardrails/hooks/block-dangerous-git.sh | 29 +++++- .../hooks/block-dangerous-git.test.sh | 30 ++++++ plugins/guardrails/hooks/block-no-verify.sh | 29 +++++- .../guardrails/hooks/block-no-verify.test.sh | 39 ++++++++ plugins/guardrails/hooks/hook-utils.sh | 90 +++++++++++++---- .../.claude-plugin/plugin.json | 2 +- plugins/markdown-format/CHANGELOG.md | 13 +++ plugins/markdown-format/hooks/hook-utils.sh | 90 +++++++++++++---- .../.claude-plugin/plugin.json | 2 +- plugins/powershell-format/CHANGELOG.md | 13 +++ plugins/powershell-format/hooks/hook-utils.sh | 90 +++++++++++++---- .../.claude-plugin/plugin.json | 2 +- plugins/rate-limit-guard/CHANGELOG.md | 13 +++ plugins/rate-limit-guard/hooks/hook-utils.sh | 90 +++++++++++++---- .../ruff-format/.claude-plugin/plugin.json | 2 +- plugins/ruff-format/CHANGELOG.md | 13 +++ plugins/ruff-format/hooks/hook-utils.sh | 90 +++++++++++++---- .../source-control/.claude-plugin/plugin.json | 2 +- plugins/source-control/CHANGELOG.md | 13 +++ plugins/source-control/hooks/hook-utils.sh | 90 +++++++++++++---- .../typos-format/.claude-plugin/plugin.json | 2 +- plugins/typos-format/CHANGELOG.md | 13 +++ plugins/typos-format/hooks/hook-utils.sh | 90 +++++++++++++---- 55 files changed, 1703 insertions(+), 313 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index b16f3f7256..eca0feb63b 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -2131,6 +2131,103 @@ else fail "jq_fields non-string handling: got '${HOOK_JQ_FIELDS[0]-}' / '${HOOK_JQ_FIELDS[1]-}'" fi +# --- jq_fields: a NUL in a value must not split the frame (#2122) ------------- +# The separator was drawn from the same byte space as the values it separates, +# so a JSON NUL escape inside a value split that value in two, failed the +# cardinality check, and returned 1 — which every guardrail caller spells +# `|| exit 0`, i.e. ALLOW. jq now truncates each value at its first NUL, so the +# separator cannot occur inside one and the record count no longer depends on +# what a parseable payload holds. Truncation is a conservative reading, not a +# claim about how a NUL would execute; the NUL itself is reported in +# HOOK_JQ_FIELDS_NUL, because only the caller can own the verdict. +# +# A NUL cannot live in a shell variable, so the payload is built inside jq: +# `[0] | implode` is the one-character NUL string (jq re-emits it as a NUL +# escape on the wire), and `[92] | implode` is a backslash — which keeps both +# a raw NUL and an escape out of this file's own source. +jf_bs=$(jq -rn '[92] | implode') +jf_nul=$(jq -n '{ + tool_name: "Bash", + tool_input: { command: ("git push --no-veri" + ([0] | implode) + "fy") }, + session_id: ("s" + ([0] | implode) + "1"), + only_nul: ([0] | implode), + runs: ("p" + ([0] | implode) + ([0] | implode) + "q"), + literal: (([92] | implode) + "u0000") +}') + +if hook::jq_fields "$jf_nul" \ + '.tool_input.command' '.tool_name' '.session_id' '.only_nul' '.runs' '.literal' '.absent'; then + if [[ "${#HOOK_JQ_FIELDS[@]}" -eq 7 && + "${HOOK_JQ_FIELDS[0]}" == "git push --no-veri" && + "${HOOK_JQ_FIELDS[1]}" == "Bash" && + "${HOOK_JQ_FIELDS[2]}" == "s" && + -z "${HOOK_JQ_FIELDS[3]}" && + "${HOOK_JQ_FIELDS[4]}" == "p" && + "${HOOK_JQ_FIELDS[5]}" == "${jf_bs}u0000" && + -z "${HOOK_JQ_FIELDS[6]}" ]]; then + ok "jq_fields: a NUL-bearing value keeps every slot and truncates at the NUL" + else + fail "jq_fields NUL framing: got (${#HOOK_JQ_FIELDS[@]}) '${HOOK_JQ_FIELDS[0]-}' / '${HOOK_JQ_FIELDS[1]-}' / '${HOOK_JQ_FIELDS[2]-}' / '${HOOK_JQ_FIELDS[3]-}' / '${HOOK_JQ_FIELDS[4]-}' / '${HOOK_JQ_FIELDS[5]-}' / '${HOOK_JQ_FIELDS[6]-}'" + fi +else + fail "jq_fields returned $? on a payload whose value carried a NUL" +fi + +# The point of the fix is what the CALLER sees. A NUL used to make the helper +# return non-zero, which every guardrail turns into an allow; now it returns +# success and reports the NUL, so the caller can fail CLOSED on its own terms. +if hook::jq_fields "$jf_nul" '.tool_input.command'; then + if [[ "$HOOK_JQ_FIELDS_NUL" == 1 ]]; then + ok "jq_fields: a NUL is reported to the caller instead of returning its fail-open code" + else + fail "jq_fields HOOK_JQ_FIELDS_NUL: expected 1 on a NUL-bearing payload, got '$HOOK_JQ_FIELDS_NUL'" + fi +else + fail "jq_fields returned $? on a NUL-bearing payload — callers spell that ALLOW" +fi + +# A NUL only the LAST filter carries must still raise the flag — the report is +# over every requested value, not just the first. +if hook::jq_fields "$jf_nul" '.tool_name' '.only_nul' && [[ "$HOOK_JQ_FIELDS_NUL" == 1 ]]; then + ok "jq_fields: a NUL in any requested field raises the flag" +else + fail "jq_fields flag on a trailing NUL field: got '$HOOK_JQ_FIELDS_NUL'" +fi + +# A LEADING NUL truncates the value to nothing. The value is then indistinguish- +# able from an absent field, so the flag is the only thing separating "no +# command was sent" from "a command was sent and could not be read faithfully". +jf_lead=$(jq -n '{tool_input: {command: (([0] | implode) + "git push --force")}}') +if hook::jq_fields "$jf_lead" '.tool_input.command' && + [[ -z "${HOOK_JQ_FIELDS[0]}" && "$HOOK_JQ_FIELDS_NUL" == 1 ]]; then + ok "jq_fields: a leading NUL empties the value and still raises the flag" +else + fail "jq_fields leading NUL: got '${HOOK_JQ_FIELDS[0]-}' flag '$HOOK_JQ_FIELDS_NUL'" +fi + +# Set on EVERY call, not only when a NUL is present: a caller that never resets +# it must not inherit a stale 1 from an earlier invocation. Both assertions run +# the NUL payload FIRST on purpose — a single call cannot observe a stale value +# however it is written. +hook::jq_fields "$jf_nul" '.tool_input.command' +if hook::jq_fields "$jf_input" '.tool_name' && [[ "$HOOK_JQ_FIELDS_NUL" == 0 ]]; then + ok "jq_fields: a clean payload clears the flag rather than leaving it stale" +else + fail "jq_fields stale flag: expected 0 after a clean payload, got '$HOOK_JQ_FIELDS_NUL'" +fi + +# The early returns — no filters, and a host without jq — fire before any NUL +# could be observed, so the flag has to be cleared in the same unconditional +# block that resets the array rather than on detection. In a guard a stale 1 +# would block a clean payload on the strength of an earlier one. +hook::jq_fields "$jf_nul" '.tool_input.command' +hook::jq_fields "$jf_nul" || true +if [[ "$HOOK_JQ_FIELDS_NUL" == 0 ]]; then + ok "jq_fields: an early return clears the flag instead of leaking the previous call's" +else + fail "jq_fields early-return flag: expected 0, got '$HOOK_JQ_FIELDS_NUL'" +fi + echo echo "PASS=$PASS FAIL=$FAIL" [[ $FAIL -eq 0 ]] diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index d1010e41b9..6fccf6e683 100644 --- a/plugins/actionlint/.claude-plugin/plugin.json +++ b/plugins/actionlint/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "actionlint", - "version": "0.8.0", + "version": "0.8.1", "description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.", "author": { "name": "Melodic Software", diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index 7773a6ad2e..240dbc1dcb 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `actionlint` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.8.0] ### Removed diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 5dbc104a40..295ed13938 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "autonomy", - "version": "0.14.0", + "version": "0.14.1", "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.", "author": { "name": "Melodic Software", diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index b3d730e6fb..864064d975 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to the `autonomy` plugin are documented here. Format follows Versions 0.1.0–0.7.0 predate this file (introduced with 0.7.1); their history lives in the merged work-package PRs (#333, #343, #356, #372, #377, #600, #676). +## [0.14.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.14.0] ### Removed diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index b8c53822d7..536bfbcfae 100644 --- a/plugins/bash-format/.claude-plugin/plugin.json +++ b/plugins/bash-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "bash-format", - "version": "0.7.0", + "version": "0.7.1", "description": "Auto-format and lint shell scripts on edit via shfmt + ShellCheck, using the consuming repo's own .editorconfig and .shellcheckrc.", "author": { "name": "Melodic Software", diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index 380c0c5286..274ae29af1 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `bash-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.7.0] ### Removed diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index 93ac5bdc28..408e4d47d7 100644 --- a/plugins/biome-format/.claude-plugin/plugin.json +++ b/plugins/biome-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "biome-format", - "version": "0.6.0", + "version": "0.6.1", "description": "Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo — using the consuming repo's own Biome config.", "author": { "name": "Melodic Software", diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 57c961d0bf..867a6e7345 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `biome-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.6.0] ### Removed diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index e7b0f96006..b15f7b9844 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.28.0", + "version": "0.28.1", "description": "Claude Code operations toolkit. Seven skills: observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of seven advisory *-audit telemetry-emitter hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 2fa44fd7c6..3ccb1e21fe 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.28.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.28.0] ### Removed diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index 1ef371303f..b630064076 100644 --- a/plugins/context-guard/.claude-plugin/plugin.json +++ b/plugins/context-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "context-guard", - "version": "0.6.0", + "version": "0.6.1", "description": "Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks report once per transition into a worse zone across two channels — the continuation menu to the operator, who owns that choice, and to the model only the zone determination plus the counter-steer that a zone word is not a decay signal (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker.", "author": { "name": "Melodic Software", diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index e08a2e708e..da5806efca 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to the `context-guard` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.6.0] ### Removed diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index 57b5b0fb63..10e5465e22 100644 --- a/plugins/desktop-notification/.claude-plugin/plugin.json +++ b/plugins/desktop-notification/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "desktop-notification", - "version": "0.6.0", + "version": "0.6.1", "description": "Alert you when Claude Code needs input — an audible terminal bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts.", "author": { "name": "Melodic Software", diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index d6b528675b..389a16e4a1 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `desktop-notification` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.6.0] ### Removed diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index 460d52f54c..9ba9b14b71 100644 --- a/plugins/eol-normalizer/.claude-plugin/plugin.json +++ b/plugins/eol-normalizer/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "eol-normalizer", - "version": "0.6.0", + "version": "0.6.1", "description": "Normalize a written file's working-tree line endings to its .gitattributes eol value on edit — symmetric CRLF/LF driven by git check-attr, advisory and never blocking.", "author": { "name": "Melodic Software", diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index 8b1dcd753e..5f98c2e6d5 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.6.0] ### Removed diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index e60f5e2e8d..d1de41b5ec 100644 --- a/plugins/go-format/.claude-plugin/plugin.json +++ b/plugins/go-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "go-format", - "version": "0.3.0", + "version": "0.3.1", "description": "Auto-fix Go formatting and import management on edit via goimports — runs unconditionally (no consumer-config gate), skipping generated files.", "author": { "name": "Melodic Software", diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index 5b761d00f2..17aa47ea9c 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `go-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.3.0] ### Removed diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index c977eb36d9..aa188ec7c5 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", - "version": "0.23.0", + "version": "0.23.1", "description": "Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, multi-line `git commit -m` messages (an actual-newline `-m` mangles across shells; single-line `-m` passes), commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory, opt-in) un-throttled Workflow fan-out that risks burst 529s, and (advisory, opt-in) direct gh pr create calls bypassing this marketplace's own pull-request skill — each independently toggleable.", "author": { "name": "Melodic Software", diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 0315fbc555..2eff4d9016 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,31 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.23.1] + +### Fixed + +- **`block-no-verify` and `block-dangerous-git` allowed any command carrying a NUL byte (#2122).** + Both guards read their payload through `hook::jq_fields`, which separates its fields with a NUL, + so a JSON NUL escape inside the command split that value in two and failed the helper's + cardinality check. The helper returned non-zero, and both hooks spell that `|| exit 0` — a + PreToolUse ALLOW, emitted with no diagnostic of any kind. Measured at the hook boundary on the + shipped hooks: every NUL-bearing form of `git push --no-verify` and `git reset --hard` — trailing, + splitting the flag, leading, or followed by junk — exited 0 where the clean forms exited 2. +- **Both guards now fail CLOSED on a NUL byte in any field they read.** jq 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 the new `HOOK_JQ_FIELDS_NUL` + global, and both guards block on it — ahead of their empty-command skip, so a leading NUL leaving + no command text cannot pass as "no command". They refuse rather than match because the text a + guard can read is not dependably the text that would run: bash **discards** a NUL while parsing a + command it reads, Node's `child_process` **refuses** a NUL-bearing string outright, and which of + them (if either) a hook payload reaches has not been traced. Refusing is correct under all of + them and needs no such trace. Synced from `lib/hook-utils.sh`. +- **A non-zero return from `hook::jq_fields` still means the guards allow, and that is unchanged.** + It no longer fires on NUL content, but jq being absent, a malformed payload, a wrongly typed + field, two concatenated JSON documents, or an empty buffer all still reach it — and every caller + spells it `|| exit 0`. That path is out of scope here and is documented rather than claimed away. + ## [0.23.0] **Note on the version bump.** MINOR rather than patch, on the same test 0.22.1 applied: does the diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index b03c5615ce..7541c8d49f 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -70,6 +70,16 @@ out of scope until such a signal exists. **These are friction guards against accidental/casual bypass, not a sandbox.** (A command longer than 16 KB is not parsed and is blocked fail-closed.) +- **A NUL byte in the payload blocks, whatever the command says.** + `block-no-verify` and `block-dangerous-git` refuse any payload whose read + fields carry a NUL, before they look at the command at all — including one + that leaves no command text behind. The reason is that the text a guard can + read is not dependably the text that would run: 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 — and + which of them, if either, a hook payload reaches has not been traced. Refusing + is the one verdict correct under all of them, and needs no such trace. A NUL is + treated as malformed input rather than as an exotic-but-valid command. - **`block-hook-bypass` string-matching floor.** Detection strips quoted literal spans before matching the executable token, so quoted prose or a commit message merely mentioning `cat >` / `python3 -c open(...)` is not flagged. The diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index 92f4740b56..dbe9ead807 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -90,11 +90,32 @@ hook::require_jq "PreToolUse" "guardrails-block-dangerous-git" "$INPUT" # Both payload fields in ONE jq process (hook::jq_fields), not two. A jq spawn is # ~140 ms of fork() emulation on Windows Git Bash and this guard runs on every -# Bash/PowerShell call. Failure semantics are unchanged: a missing jq or an -# unparsable payload yields rc 1 here, which exits 0 exactly as the empty-COMMAND -# skip below did — hook::require_jq above has already made the degraded state -# visible once per session. +# Bash/PowerShell call. rc 1 here means jq is absent, or jq could not parse the +# payload at all — it exits 0 exactly as the empty-COMMAND skip below did, and +# hook::require_jq above has already made a missing jq visible once per session. +# That remaining allow-on-unparsable path is unchanged by #2122 and is NOT what +# the NUL check below covers. hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 + +# A NUL byte in EITHER field read above is fail-CLOSED, and is decided BEFORE +# the empty-COMMAND skip below: the helper truncates a value at its first NUL, +# so a leading one leaves an empty command that would otherwise be waved through +# as "no command" (#2122). +# +# Blocking rather than matching, because the value a guard can read is not +# reliably the thing that would run. 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 — and which of them, if +# either, a hook payload reaches has not been traced. Blocking is the one verdict +# correct under all of them, so it needs no such trace. A NUL here is malformed +# input, not an exotic-but-valid command. +if ((HOOK_JQ_FIELDS_NUL)); then + echo "BLOCKED: the payload carries a NUL byte, which a command cannot reliably carry." >&2 + echo "What a guard can read is not dependably what would run, so this is refused rather than matched." >&2 + echo "Fix: reissue the tool call without the embedded NUL." >&2 + exit 2 +fi + COMMAND="${HOOK_JQ_FIELDS[0]}" [[ -n "$COMMAND" ]] || exit 0 TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}" diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 1d7f28734d..cae7051038 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -669,4 +669,34 @@ run_pwsh "PS: call-op, single-quoted literal git (blocked by name)" \ run_pwsh "PS: call-op, quoted literal path whose basename is git (blocked by name)" \ '& "C:\Git\cmd\git.exe" reset --hard' 2 +# --- A NUL in the payload must not void the guard (#2122) -------------------- +# hook::jq_fields separates its fields with a NUL. A JSON NUL escape inside the +# command used to split that value in two, fail the helper's cardinality check +# and return non-zero — which this hook spells `|| exit 0`, a PreToolUse ALLOW +# with no diagnostic. Asserted at the HOOK boundary, not in the helper, because +# the boundary is where the bypass was observable. +# +# The rule is one line with no exceptions: a NUL in any field the hook reads +# BLOCKS, whatever the surrounding text says. That includes a leading NUL, which +# leaves no command text at all, and a NUL in a command with nothing dangerous in +# it. The guard refuses rather than matching because the text it can read is not +# dependably the text that would run, and which executor behaviour applies has +# not been traced. +# +# A NUL cannot live in a shell variable, so the payload is assembled inside jq: +# `[0] | implode` is the one-character NUL string, which jq re-emits as a NUL +# escape on the wire — the form the harness would deliver. +run_nul() { + local label="$1" head="$2" tail="$3" expected="$4" rc + (cd "$REPO_SHA1" && bash "$HOOK" <<<"$(jq -n --arg h "$head" --arg t "$tail" \ + '{tool_name:"Bash",tool_input:{command:($h + ([0] | implode) + $t)}}')" >/dev/null 2>&1) + rc=$? + assert_exit "$label" "$expected" "$rc" +} +run_nul "NUL after --hard (blocked)" "git reset --hard" "" 2 +run_nul "NUL splitting the flag itself (blocked)" "git reset --ha" "rd" 2 +run_nul "NUL then junk (blocked)" "git reset --hard" "x" 2 +run_nul "leading NUL truncates to no command (blocked)" "" "git reset --hard" 2 +run_nul "NUL in an otherwise harmless command (blocked)" "git status" "; echo bye" 2 + report diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh index cacd9cdbaa..4072316ef0 100755 --- a/plugins/guardrails/hooks/block-no-verify.sh +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -75,11 +75,32 @@ hook::require_jq "PreToolUse" "guardrails-block-no-verify" "$INPUT" # Both payload fields in ONE jq process (hook::jq_fields), not two. A jq spawn is # ~140 ms of fork() emulation on Windows Git Bash and this guard runs on every -# Bash/PowerShell call. Failure semantics are unchanged: a missing jq or an -# unparsable payload yields rc 1 here, which exits 0 exactly as the empty-COMMAND -# skip below did — hook::require_jq above has already made the degraded state -# visible once per session. +# Bash/PowerShell call. rc 1 here means jq is absent, or jq could not parse the +# payload at all — it exits 0 exactly as the empty-COMMAND skip below did, and +# hook::require_jq above has already made a missing jq visible once per session. +# That remaining allow-on-unparsable path is unchanged by #2122 and is NOT what +# the NUL check below covers. hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 + +# A NUL byte in EITHER field read above is fail-CLOSED, and is decided BEFORE +# the empty-COMMAND skip below: the helper truncates a value at its first NUL, +# so a leading one leaves an empty command that would otherwise be waved through +# as "no command" (#2122). +# +# Blocking rather than matching, because the value a guard can read is not +# reliably the thing that would run. 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 — and which of them, if +# either, a hook payload reaches has not been traced. Blocking is the one verdict +# correct under all of them, so it needs no such trace. A NUL here is malformed +# input, not an exotic-but-valid command. +if ((HOOK_JQ_FIELDS_NUL)); then + echo "BLOCKED: the payload carries a NUL byte, which a command cannot reliably carry." >&2 + echo "What a guard can read is not dependably what would run, so this is refused rather than matched." >&2 + echo "Fix: reissue the tool call without the embedded NUL." >&2 + exit 2 +fi + COMMAND="${HOOK_JQ_FIELDS[0]}" [[ -n "$COMMAND" ]] || exit 0 TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}" diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh index af864f0e1e..b7455b4753 100755 --- a/plugins/guardrails/hooks/block-no-verify.test.sh +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -312,4 +312,43 @@ assert_contains "PS msg: iex of a literal gets the same actionable advice" \ "$(pwsh_stderr "iex 'git commit --no-verify'")" \ "Drop the iex/'&'/'.'" +# --- A NUL in the payload must not void the guard (#2122) -------------------- +# hook::jq_fields separates its fields with a NUL. A JSON NUL escape inside the +# command used to split that value in two, fail the helper's cardinality check +# and return non-zero — which this hook spells `|| exit 0`, a PreToolUse ALLOW +# with no diagnostic. Asserted at the HOOK boundary, not in the helper, because +# the boundary is where the bypass was observable. +# +# The rule is one line with no exceptions: a NUL in any field the hook reads +# BLOCKS, whatever the surrounding text says. That includes a leading NUL, which +# leaves no command text at all, and a NUL in a command with nothing dangerous in +# it. The guard refuses rather than matching because the text it can read is not +# dependably the text that would run, and which executor behaviour applies has +# not been traced. +# +# A NUL cannot live in a shell variable, so the payload is assembled inside jq: +# `[0] | implode` is the one-character NUL string, which jq re-emits as a NUL +# escape on the wire — the form the harness would deliver. +run_nul() { + local label="$1" head="$2" tail="$3" expected="$4" rc + bash "$HOOK" <<<"$(jq -n --arg h "$head" --arg t "$tail" \ + '{tool_name:"Bash",tool_input:{command:($h + ([0] | implode) + $t)}}')" >/dev/null 2>&1 + rc=$? + assert_exit "$label" "$expected" "$rc" +} +run_nul "NUL after --no-verify (blocked)" "git push --no-verify" "" 2 +run_nul "NUL splitting the flag itself (blocked)" "git push --no-veri" "fy" 2 +run_nul "NUL then junk (blocked)" "git push --no-verify" "x" 2 +run_nul "leading NUL truncates to no command (blocked)" "" "git push --no-verify" 2 +run_nul "NUL in an otherwise harmless command (blocked)" "echo hi" "; echo bye" 2 + +# The block has to say what is wrong and what to do about it, not just refuse. +nul_stderr() { + bash "$HOOK" <<<"$(jq -n --arg h "$1" --arg t "$2" \ + '{tool_name:"Bash",tool_input:{command:($h + ([0] | implode) + $t)}}')" 2>&1 >/dev/null +} +assert_contains "NUL msg: names the byte" "$(nul_stderr 'git push --no-verify' 'x')" "NUL byte" +assert_contains "NUL msg: gives the fix" "$(nul_stderr 'git push --no-verify' 'x')" \ + "reissue the tool call without the embedded NUL" + report diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index 09ae51d4bb..3794e1af35 100644 --- a/plugins/markdown-format/.claude-plugin/plugin.json +++ b/plugins/markdown-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "markdown-format", - "version": "0.11.1", + "version": "0.11.2", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2 — only in repos that carry their own markdownlint config.", "author": { "name": "Melodic Software", diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 410b36090b..f3b80f5dfb 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `markdown-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.2] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.11.1] ### Fixed diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index 6aaed42fbf..f293590617 100644 --- a/plugins/powershell-format/.claude-plugin/plugin.json +++ b/plugins/powershell-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "powershell-format", - "version": "0.7.0", + "version": "0.7.1", "description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo — using the consuming repo's own analyzer settings.", "author": { "name": "Melodic Software", diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index fbcec854bd..ec8488ca6e 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `powershell-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.7.0] ### Removed diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index e532e5bfc4..6a5f74f142 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "rate-limit-guard", - "version": "0.5.0", + "version": "0.5.1", "description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index 02bb74991c..dcfb0fa2da 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.5.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.5.0] ### Removed diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index 716f718c3b..505f7d4936 100644 --- a/plugins/ruff-format/.claude-plugin/plugin.json +++ b/plugins/ruff-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "ruff-format", - "version": "0.6.0", + "version": "0.6.1", "description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo — using the consuming repo's own Ruff config.", "author": { "name": "Melodic Software", diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index f766e2f607..e94a598c17 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `ruff-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.6.0] ### Removed diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 9f229565f4..c009a7bfd8 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.51.1", + "version": "0.51.2", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop — safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and standing merge-rung raises binding from the team-tracked layer only — with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply — interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index 08bf023a5d..8d904b5002 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.51.2] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.51.1] ### Fixed diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/source-control/hooks/hook-utils.sh +++ b/plugins/source-control/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json index d007c2115f..5fb2c4a198 100644 --- a/plugins/typos-format/.claude-plugin/plugin.json +++ b/plugins/typos-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "typos-format", - "version": "0.6.0", + "version": "0.6.1", "description": "Spell-check on edit via typos-cli, unconditionally — report-only by default, honoring the consuming repo's own typos configuration when one is present.", "author": { "name": "Melodic Software", diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md index 28bbc042d8..eef9197941 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `typos-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.1] + +### Fixed + +- **Shared `hook-utils.sh`: a NUL byte in a payload value no longer makes `hook::jq_fields` + fail (#2122).** The helper separates its fields with a NUL, so a JSON NUL escape inside a value + split that value in two and failed the helper's own cardinality check — returning non-zero, + which its callers spell `|| exit 0`. jq now truncates each value at its first NUL, so the + separator cannot occur inside a value and no parseable payload can make the helper fail; the NUL + itself is reported in a new `HOOK_JQ_FIELDS_NUL` global, set on every call, so a caller that owns a + block/allow verdict can fail closed on its own terms. No hook in this plugin calls the helper. + Synced from `lib/hook-utils.sh`. + ## [0.6.0] ### Removed diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index b590b9160e..3b86da2a01 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -622,27 +622,71 @@ hook::jq_field() { # the whole contract) would DROP the field here and silently shift every later # index onto the wrong filter. Emptiness stays the caller's decision. # -# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent or fails, or when -# fewer values came back than filters were asked for, so a partial read can -# never be mistaken for a complete one. Values are CR-stripped, as in +# Returns 1 — with HOOK_JQ_FIELDS empty — when jq is absent, when jq cannot +# parse the payload, or when the record count does not match what was asked for +# in EITHER direction (an over-count rejects too: two concatenated well-formed +# JSON documents parse fine and yield twice the records). Every guardrail caller +# spells that `|| exit 0`, a PreToolUse ALLOW, so what can reach it matters. NUL +# CONTENT no longer can (#2122). A payload jq itself rejects — malformed JSON, a +# wrongly typed field, an empty buffer — still does, exactly as it did before +# this change; that path is untouched here, and process substitution means jq's +# own exit status is not observed either. Values are CR-stripped, as in # hook::jq_field. # -# Fields are NUL-separated on the wire: the values carry arbitrary text -# (a Bash command spans newlines routinely) and NUL is the one byte a shell -# string cannot hold, so it is the only separator that cannot occur inside a -# value. A JSON input that encodes a literal \u0000 INSIDE a string value is the -# residual — jq would emit it raw and split that value in two; hook payloads do -# not carry one, and a command substitution would have discarded it anyway. -# Read through a process substitution rather than $( ) for the same reason: -# command substitution strips NUL bytes. +# HOOK_JQ_FIELDS_NUL is set on EVERY call — "1" when any REQUESTED field carried +# a NUL byte, "0" otherwise, and "0" on every failure path — so a caller can +# never inherit a stale "1" from an earlier invocation by forgetting to reset it. +# A caller that owns a block/allow verdict MUST consult it and fail CLOSED. +# +# Fields are NUL-separated on the wire, and jq TRUNCATES each value at its first +# NUL before emitting it, so the separator provably cannot occur inside a value +# and the record count no longer depends on what a PARSEABLE payload holds. jq +# used to emit the NUL raw, the value split in two, the cardinality check below +# failed, and the callers' `|| exit 0` turned a PreToolUse BLOCK into a silent +# ALLOW (#2122). +# +# Truncation is NOT a claim about how a NUL executes, and must not be read as +# one. Two behaviours were measured and they disagree: bash DISCARDS a NUL while +# parsing a command it reads (stdin or a script file), so `echo hard` prints +# `hard`; Node's child_process REFUSES a NUL-bearing string outright, on argv, on +# `shell: true`, and on exec alike. Which of those — if either — a hook payload +# would ever reach is UNTRACED. No value semantics chosen here can claim fidelity +# to the executor, in either direction, and none is claimed. +# +# That is exactly why the verdict is fail-CLOSED on the flag rather than a match +# against the value: blocking is correct under deletion, under truncation, and +# under refusal alike, so it does not depend on anyone having traced the path — +# and it cannot be invalidated by tracing it later. The flag is the load-bearing +# part; the value semantics below are not. +# +# Truncation over deletion is then chosen on grounds that appeal to no shell at +# all: it 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. That choice is immaterial for this library's own two +# callers, which refuse on the flag before reading a value. +# +# The library cannot impose that verdict itself — the plugins sourcing it include +# formatters, for which exiting 2 would be wrong — so policy stays with the +# caller and the library only reports the fact. +# +# `explode | .[0:(index(0) // length)] | implode`, not a gsub or a split, so +# that no regex pattern and no string literal in the jq PROGRAM text carries a +# NUL byte: a construct whose behaviour varies across jq builds would fail EVERY +# payload, which is strictly worse than the payload-dependent bug being fixed. +# +# Read through a process substitution rather than $( ): command substitution +# strips NUL bytes, which would eat the separators themselves. # # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 +# if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" -# shellcheck disable=SC2034 # result global is consumed by the sourcing hook, not this file +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { local input="$1" shift HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 local prog="" filter @@ -650,9 +694,17 @@ hook::jq_fields() { [[ -n "$prog" ]] && prog+="," prog+="((${filter}) // \"\" | tostring)" done - # `-j` concatenates outputs verbatim, so emitting the NUL as its own output - # after each value yields exactly value NUL value NUL … with nothing added. - prog="[$prog] | .[] | (., \"\\u0000\")" + # The NUL flag rides in front of the values as one more record, so reporting + # it costs no second jq process — the whole point of this helper. It is + # computed from the UNTRUNCATED values, then each value is truncated at its + # first NUL. `-j` concatenates outputs verbatim, so emitting the separator as + # its own output yields exactly value NUL value NUL … with nothing added. + # `A | (X, Y)` feeds the SAME array to both branches, so the flag and the + # values are computed from one input with no jq variable in the program text. + prog="[$prog]" + prog+=' | ((if (map(explode | index(0) != null) | any) then "1" else "0" end),' + prog+=' (.[] | explode | .[0:(index(0) // length)] | implode))' + prog+=" | (., \"\\u0000\")" local -a values=() local v clean # `read -d ''` (not `mapfile -d ''`, which is Bash 4.4+; this lib supports @@ -672,8 +724,12 @@ hook::jq_fields() { clean="${v//$'\r'/}" values+=("$clean") done < <(printf '%s' "$input" | jq -j "$prog" 2>/dev/null) - ((${#values[@]} == $#)) || return 1 - HOOK_JQ_FIELDS=("${values[@]}") + # One record for the flag plus one per filter. Short of that, jq produced no + # usable output — it is absent, it failed, or it rejected the payload. What can + # no longer shorten it is NUL CONTENT: the values carry no separator byte. + ((${#values[@]} == $# + 1)) || return 1 + HOOK_JQ_FIELDS_NUL="${values[0]}" + HOOK_JQ_FIELDS=("${values[@]:1}") } # Reduce a tool + optional Bash command to a privacy-safe subject label. For From d3af1b395e3209ee526303c89ae9a10c1ea6ea92 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:37:26 -0400 Subject: [PATCH 2/4] test(hook-utils): re-point the NUL cases at strip semantics, and add the splice case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-verifyx` 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) --- lib/hook-utils.test.sh | 50 ++++++++++++++----- .../hooks/block-dangerous-git.test.sh | 15 +++--- .../guardrails/hooks/block-no-verify.test.sh | 15 +++--- 3 files changed, 55 insertions(+), 25 deletions(-) diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index bbfc32cc76..c3b5639517 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -2158,11 +2158,13 @@ fi # The separator was drawn from the same byte space as the values it separates, # so a JSON NUL escape inside a value split that value in two, failed the # cardinality check, and returned 1 — which every guardrail caller spells -# `|| exit 0`, i.e. ALLOW. jq now truncates each value at its first NUL, so the +# `|| exit 0`, i.e. ALLOW. jq now STRIPS every NUL out of each value, so the # separator cannot occur inside one and the record count no longer depends on -# what a parseable payload holds. Truncation is a conservative reading, not a -# claim about how a NUL would execute; the NUL itself is reported in -# HOOK_JQ_FIELDS_NUL, because only the caller can own the verdict. +# what a parseable payload holds. Stripping is not a claim about how a NUL would +# execute — it SPLICES the bytes either side into a token the payload never +# carried contiguously — which is exactly why the NUL itself is reported in +# HOOK_JQ_FIELDS_NUL: only the caller can own the verdict, and the two guards +# refuse on the flag rather than match the spliced value. # # A NUL cannot live in a shell variable, so the payload is built inside jq: # `[0] | implode` is the one-character NUL string (jq re-emits it as a NUL @@ -2181,14 +2183,14 @@ jf_nul=$(jq -n '{ if hook::jq_fields "$jf_nul" \ '.tool_input.command' '.tool_name' '.session_id' '.only_nul' '.runs' '.literal' '.absent'; then if [[ "${#HOOK_JQ_FIELDS[@]}" -eq 7 && - "${HOOK_JQ_FIELDS[0]}" == "git push --no-veri" && + "${HOOK_JQ_FIELDS[0]}" == "git push --no-verify" && "${HOOK_JQ_FIELDS[1]}" == "Bash" && - "${HOOK_JQ_FIELDS[2]}" == "s" && + "${HOOK_JQ_FIELDS[2]}" == "s1" && -z "${HOOK_JQ_FIELDS[3]}" && - "${HOOK_JQ_FIELDS[4]}" == "p" && + "${HOOK_JQ_FIELDS[4]}" == "pq" && "${HOOK_JQ_FIELDS[5]}" == "${jf_bs}u0000" && -z "${HOOK_JQ_FIELDS[6]}" ]]; then - ok "jq_fields: a NUL-bearing value keeps every slot and truncates at the NUL" + ok "jq_fields: a NUL-bearing value keeps every slot and is stripped, not split" else fail "jq_fields NUL framing: got (${#HOOK_JQ_FIELDS[@]}) '${HOOK_JQ_FIELDS[0]-}' / '${HOOK_JQ_FIELDS[1]-}' / '${HOOK_JQ_FIELDS[2]-}' / '${HOOK_JQ_FIELDS[3]-}' / '${HOOK_JQ_FIELDS[4]-}' / '${HOOK_JQ_FIELDS[5]-}' / '${HOOK_JQ_FIELDS[6]-}'" fi @@ -2217,17 +2219,39 @@ else fail "jq_fields flag on a trailing NUL field: got '$HOOK_JQ_FIELDS_NUL'" fi -# A LEADING NUL truncates the value to nothing. The value is then indistinguish- -# able from an absent field, so the flag is the only thing separating "no -# command was sent" from "a command was sent and could not be read faithfully". +# A leading NUL KEEPS its text under stripping — it is the splice, not an empty +# value, that a command guard has to survive. `--no-verifyx` arrives as the +# single token `--no-verifyx`, which no matcher recognizes, so a caller reading +# only the value would allow it. The flag is the only thing that separates it +# from a clean payload, which is why the two guards refuse on the flag instead of +# matching the value. jf_lead=$(jq -n '{tool_input: {command: (([0] | implode) + "git push --force")}}') if hook::jq_fields "$jf_lead" '.tool_input.command' && - [[ -z "${HOOK_JQ_FIELDS[0]}" && "$HOOK_JQ_FIELDS_NUL" == 1 ]]; then - ok "jq_fields: a leading NUL empties the value and still raises the flag" + [[ "${HOOK_JQ_FIELDS[0]}" == "git push --force" && "$HOOK_JQ_FIELDS_NUL" == 1 ]]; then + ok "jq_fields: a leading NUL keeps the value's text and still raises the flag" else fail "jq_fields leading NUL: got '${HOOK_JQ_FIELDS[0]-}' flag '$HOOK_JQ_FIELDS_NUL'" fi +jf_splice=$(jq -n '{tool_input: {command: ("git commit --no-verify" + ([0] | implode) + "x")}}') +if hook::jq_fields "$jf_splice" '.tool_input.command' && + [[ "${HOOK_JQ_FIELDS[0]}" == "git commit --no-verifyx" && "$HOOK_JQ_FIELDS_NUL" == 1 ]]; then + ok "jq_fields: stripping splices a token the payload never carried, and flags it" +else + fail "jq_fields splice: got '${HOOK_JQ_FIELDS[0]-}' flag '$HOOK_JQ_FIELDS_NUL'" +fi + +# A value that is NOTHING BUT NUL bytes strips to empty, and is then +# indistinguishable from an absent field. That case — not a leading NUL — is why +# both guards consult the flag AHEAD of their empty-command skip. +jf_allnul=$(jq -n '{tool_input: {command: (([0] | implode) + ([0] | implode))}}') +if hook::jq_fields "$jf_allnul" '.tool_input.command' && + [[ -z "${HOOK_JQ_FIELDS[0]}" && "$HOOK_JQ_FIELDS_NUL" == 1 ]]; then + ok "jq_fields: an all-NUL value strips to empty and still raises the flag" +else + fail "jq_fields all-NUL: got '${HOOK_JQ_FIELDS[0]-}' flag '$HOOK_JQ_FIELDS_NUL'" +fi + # Set on EVERY call, not only when a NUL is present: a caller that never resets # it must not inherit a stale 1 from an earlier invocation. Both assertions run # the NUL payload FIRST on purpose — a single call cannot observe a stale value diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index cae7051038..0e3a9cdd52 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -677,11 +677,13 @@ run_pwsh "PS: call-op, quoted literal path whose basename is git (blocked by nam # the boundary is where the bypass was observable. # # The rule is one line with no exceptions: a NUL in any field the hook reads -# BLOCKS, whatever the surrounding text says. That includes a leading NUL, which -# leaves no command text at all, and a NUL in a command with nothing dangerous in -# it. The guard refuses rather than matching because the text it can read is not -# dependably the text that would run, and which executor behaviour applies has -# not been traced. +# BLOCKS, whatever the surrounding text says. That includes a command whose text +# is entirely NUL bytes, which strips to nothing and would otherwise be waved +# through by the empty-command skip, and a NUL in a command with nothing +# dangerous in it. The guard refuses rather than matching because the text it can +# read is not dependably the text that would run — stripping SPLICES the bytes +# either side of the NUL into a token the payload never carried contiguously — +# and which executor behaviour applies has not been traced. # # A NUL cannot live in a shell variable, so the payload is assembled inside jq: # `[0] | implode` is the one-character NUL string, which jq re-emits as a NUL @@ -696,7 +698,8 @@ run_nul() { run_nul "NUL after --hard (blocked)" "git reset --hard" "" 2 run_nul "NUL splitting the flag itself (blocked)" "git reset --ha" "rd" 2 run_nul "NUL then junk (blocked)" "git reset --hard" "x" 2 -run_nul "leading NUL truncates to no command (blocked)" "" "git reset --hard" 2 +run_nul "leading NUL, text preserved (blocked)" "" "git reset --hard" 2 +run_nul "all-NUL command strips to empty (blocked)" "" "" 2 run_nul "NUL in an otherwise harmless command (blocked)" "git status" "; echo bye" 2 report diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh index b7455b4753..986cb9c777 100755 --- a/plugins/guardrails/hooks/block-no-verify.test.sh +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -320,11 +320,13 @@ assert_contains "PS msg: iex of a literal gets the same actionable advice" \ # the boundary is where the bypass was observable. # # The rule is one line with no exceptions: a NUL in any field the hook reads -# BLOCKS, whatever the surrounding text says. That includes a leading NUL, which -# leaves no command text at all, and a NUL in a command with nothing dangerous in -# it. The guard refuses rather than matching because the text it can read is not -# dependably the text that would run, and which executor behaviour applies has -# not been traced. +# BLOCKS, whatever the surrounding text says. That includes a command whose text +# is entirely NUL bytes, which strips to nothing and would otherwise be waved +# through by the empty-command skip, and a NUL in a command with nothing +# dangerous in it. The guard refuses rather than matching because the text it can +# read is not dependably the text that would run — stripping SPLICES the bytes +# either side of the NUL into a token the payload never carried contiguously — +# and which executor behaviour applies has not been traced. # # A NUL cannot live in a shell variable, so the payload is assembled inside jq: # `[0] | implode` is the one-character NUL string, which jq re-emits as a NUL @@ -339,7 +341,8 @@ run_nul() { run_nul "NUL after --no-verify (blocked)" "git push --no-verify" "" 2 run_nul "NUL splitting the flag itself (blocked)" "git push --no-veri" "fy" 2 run_nul "NUL then junk (blocked)" "git push --no-verify" "x" 2 -run_nul "leading NUL truncates to no command (blocked)" "" "git push --no-verify" 2 +run_nul "leading NUL, text preserved (blocked)" "" "git push --no-verify" 2 +run_nul "all-NUL command strips to empty (blocked)" "" "" 2 run_nul "NUL in an otherwise harmless command (blocked)" "echo hi" "; echo bye" 2 # The block has to say what is wrong and what to do about it, not just refuse. From 10f8da38fecfbde3d30b6492e449a144de736072 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:59:41 -0400 Subject: [PATCH 3/4] test(guardrails): assert the NUL block's stderr in block-dangerous-git too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../guardrails/hooks/block-dangerous-git.test.sh | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 36c5995f77..a0cb000301 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -860,4 +860,18 @@ run_nul "leading NUL, text preserved (blocked)" "" "git reset --hard" 2 run_nul "all-NUL command strips to empty (blocked)" "" "" 2 run_nul "NUL in an otherwise harmless command (blocked)" "git status" "; echo bye" 2 +# The block has to say what is wrong and what to do about it, not just refuse. +# +# Asserted HERE as well as in block-no-verify.test.sh, and the duplication is the +# point: both guards emit the same three lines by design, so a message edited in +# one and not the other is exactly the drift neither file would otherwise catch. +# Exit-code-only coverage cannot see it — the verdict is identical either way. +nul_stderr() { + bash "$HOOK" <<<"$(jq -n --arg h "$1" --arg t "$2" \ + '{tool_name:"Bash",tool_input:{command:($h + ([0] | implode) + $t)}}')" 2>&1 >/dev/null +} +assert_contains "NUL msg: names the byte" "$(nul_stderr 'git reset --hard' 'x')" "NUL byte" +assert_contains "NUL msg: gives the fix" "$(nul_stderr 'git reset --hard' 'x')" \ + "reissue the tool call without the embedded NUL" + report From 4aff45665b4b277f0f48603e6b8a3bf025c3ea5a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:39:19 -0400 Subject: [PATCH 4/4] test(guardrails): pin flag-before-skip ordering at the boundary in both 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) --- plugins/guardrails/hooks/block-dangerous-git.test.sh | 9 +++++++++ plugins/guardrails/hooks/block-no-verify.test.sh | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index a0cb000301..cd5f03f8fa 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -874,4 +874,13 @@ assert_contains "NUL msg: names the byte" "$(nul_stderr 'git reset --hard' 'x')" assert_contains "NUL msg: gives the fix" "$(nul_stderr 'git reset --hard' 'x')" \ "reissue the tool call without the embedded NUL" +# The all-NUL command reaches the flag BEFORE the empty-COMMAND skip — its block +# must carry the NUL reason, and an empty command with no NUL must still take +# that skip. The pair is what pins the ordering; either row alone is equally +# consistent with a guard that refuses every empty command or blocks for some +# other reason. +assert_contains "NUL msg: all-NUL command refused by the flag, not skipped" \ + "$(nul_stderr '' '')" "NUL byte" +run "empty command, no NUL (allowed)" "" 0 + report diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh index 986cb9c777..8d0d388725 100755 --- a/plugins/guardrails/hooks/block-no-verify.test.sh +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -354,4 +354,13 @@ assert_contains "NUL msg: names the byte" "$(nul_stderr 'git push --no-verify' ' assert_contains "NUL msg: gives the fix" "$(nul_stderr 'git push --no-verify' 'x')" \ "reissue the tool call without the embedded NUL" +# The all-NUL command reaches the flag BEFORE the empty-COMMAND skip — its block +# must carry the NUL reason, and an empty command with no NUL must still take +# that skip. The pair is what pins the ordering; either row alone is equally +# consistent with a guard that refuses every empty command or blocks for some +# other reason. +assert_contains "NUL msg: all-NUL command refused by the flag, not skipped" \ + "$(nul_stderr '' '')" "NUL byte" +run "empty command, no NUL (allowed)" "" 0 + report