Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions lib/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -627,14 +627,24 @@ hook::jq_field() {
# never be mistaken for a complete one. 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.
# Fields are NUL-separated on the wire, and every value has its own NUL bytes
# REMOVED jq-side first, so the delimiter provably cannot occur inside a
# value: the values carry arbitrary text (a Bash command spans newlines
# routinely), and once NUL is out of the value alphabet no payload can put it
# back. Without that strip, a JSON input encoding a literal \u0000 INSIDE a
# string value — which a Write/Edit/NotebookEdit content field legitimately
# may — made jq emit the raw byte, split that value in two, and fail the
# cardinality check below, turning a caller's `|| exit 0` into a silent skip
# of the entire guard.
#
# Dropping the NUL rather than encoding around it is not the lesser option,
# it is the only representable one: a bash variable cannot hold a NUL byte,
# so no framing scheme (length prefix, base64, …) could deliver one into
# HOOK_JQ_FIELDS. It is also exactly what the per-field command substitution
# this helper replaced did — $( ) discards NUL bytes and keeps the rest of
# the value, so content AFTER a NUL is still returned and still scanned.
# Read through a process substitution rather than $( ) because the delimiter
# itself must survive the read; command substitution would strip it too.
#
# hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0
# COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}"
Expand All @@ -648,7 +658,9 @@ hook::jq_fields() {
local prog="" filter
for filter in "$@"; do
[[ -n "$prog" ]] && prog+=","
prog+="((${filter}) // \"\" | tostring)"
# split/join (1-arity, a plain string split — NOT gsub, which would put a
# NUL inside an Oniguruma pattern) removes every NUL from the value.
prog+="((${filter}) // \"\" | tostring | split(\"\\u0000\") | join(\"\"))"
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.
Expand Down
23 changes: 23 additions & 0 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2122,6 +2122,29 @@ else
ok "jq_fields: a call with no filters returns non-zero"
fi

# A JSON string may legitimately encode a NUL, and a Write/Edit content field
# does reach this helper. The NUL delimiter is only safe because every value is
# NUL-stripped jq-side FIRST: without that strip jq emitted the raw byte, the
# read split one value into two, the cardinality check failed, and every
# caller's `|| exit 0` skipped its guard outright — a fail-open the per-field
# command substitution this helper replaced never had, because `$( )` dropped
# the NUL and kept the rest of the value. The value after the NUL must survive.
# The payload is built with jq (`[0] | implode`) so no literal escape sequence
# for the byte lives in this file's source.
jf_nul_input=$(jq -nc '{tool_name:"Write",tool_input:{content:("before" + ([0] | implode) + "after")}}')
jf_nul_input="${jf_nul_input//$'\r'/}"
if hook::jq_fields "$jf_nul_input" '.tool_name' '.tool_input.content'; then
if [[ "${#HOOK_JQ_FIELDS[@]}" -eq 2 &&
"${HOOK_JQ_FIELDS[0]}" == "Write" &&
"${HOOK_JQ_FIELDS[1]}" == "beforeafter" ]]; then
ok "jq_fields: a NUL inside a value is stripped, never read as the delimiter"
else
fail "jq_fields NUL-bearing value: got (${#HOOK_JQ_FIELDS[@]}) '${HOOK_JQ_FIELDS[0]-}' / '${HOOK_JQ_FIELDS[1]-}'"
fi
else
fail "jq_fields returned $? on a NUL-bearing value — the caller's || exit 0 would skip its guard"
fi

# Non-string JSON values are stringified rather than dropped, so a numeric or
# null field cannot desynchronize the indexes either.
if hook::jq_fields '{"a":5,"b":null}' '.a' '.b' &&
Expand Down
2 changes: 1 addition & 1 deletion plugins/actionlint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions plugins/actionlint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
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 inside a payload value no longer makes `hook::jq_fields`
come back empty (#2120).** The helper delimits its batched fields with NUL, and a JSON string may
legitimately encode one — a `Write`/`Edit`/`NotebookEdit` content field can. jq emitted the raw
byte, the read split that value in two, the cardinality check saw one value too many, and the
helper returned non-zero — which every caller treats as "skip", so the hook exited without doing
its work. Each value is now NUL-stripped INSIDE the jq filter, so the delimiter provably cannot
occur in a value. Stripping is not a lesser alternative to an encoding scheme, it is the only
representable behavior: a bash variable cannot hold a NUL byte, and the per-field command
substitution this helper replaced dropped the byte and kept the rest of the value — so content
AFTER a NUL is returned and scanned exactly as it was before the batching. Synced from
`lib/hook-utils.sh`.

## [0.8.0]

### Removed
Expand Down
30 changes: 21 additions & 9 deletions plugins/actionlint/hooks/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -627,14 +627,24 @@ hook::jq_field() {
# never be mistaken for a complete one. 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.
# Fields are NUL-separated on the wire, and every value has its own NUL bytes
# REMOVED jq-side first, so the delimiter provably cannot occur inside a
# value: the values carry arbitrary text (a Bash command spans newlines
# routinely), and once NUL is out of the value alphabet no payload can put it
# back. Without that strip, a JSON input encoding a literal \u0000 INSIDE a
# string value — which a Write/Edit/NotebookEdit content field legitimately
# may — made jq emit the raw byte, split that value in two, and fail the
# cardinality check below, turning a caller's `|| exit 0` into a silent skip
# of the entire guard.
#
# Dropping the NUL rather than encoding around it is not the lesser option,
# it is the only representable one: a bash variable cannot hold a NUL byte,
# so no framing scheme (length prefix, base64, …) could deliver one into
# HOOK_JQ_FIELDS. It is also exactly what the per-field command substitution
# this helper replaced did — $( ) discards NUL bytes and keeps the rest of
# the value, so content AFTER a NUL is still returned and still scanned.
# Read through a process substitution rather than $( ) because the delimiter
# itself must survive the read; command substitution would strip it too.
#
# hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0
# COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}"
Expand All @@ -648,7 +658,9 @@ hook::jq_fields() {
local prog="" filter
for filter in "$@"; do
[[ -n "$prog" ]] && prog+=","
prog+="((${filter}) // \"\" | tostring)"
# split/join (1-arity, a plain string split — NOT gsub, which would put a
# NUL inside an Oniguruma pattern) removes every NUL from the value.
prog+="((${filter}) // \"\" | tostring | split(\"\\u0000\") | join(\"\"))"
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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/autonomy/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions plugins/autonomy/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ 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 inside a payload value no longer makes `hook::jq_fields`
come back empty (#2120).** The helper delimits its batched fields with NUL, and a JSON string may
legitimately encode one — a `Write`/`Edit`/`NotebookEdit` content field can. jq emitted the raw
byte, the read split that value in two, the cardinality check saw one value too many, and the
helper returned non-zero — which every caller treats as "skip", so the hook exited without doing
its work. Each value is now NUL-stripped INSIDE the jq filter, so the delimiter provably cannot
occur in a value. Stripping is not a lesser alternative to an encoding scheme, it is the only
representable behavior: a bash variable cannot hold a NUL byte, and the per-field command
substitution this helper replaced dropped the byte and kept the rest of the value — so content
AFTER a NUL is returned and scanned exactly as it was before the batching. Synced from
`lib/hook-utils.sh`.

## [0.14.0]

### Removed
Expand Down
30 changes: 21 additions & 9 deletions plugins/autonomy/hooks/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -627,14 +627,24 @@ hook::jq_field() {
# never be mistaken for a complete one. 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.
# Fields are NUL-separated on the wire, and every value has its own NUL bytes
# REMOVED jq-side first, so the delimiter provably cannot occur inside a
# value: the values carry arbitrary text (a Bash command spans newlines
# routinely), and once NUL is out of the value alphabet no payload can put it
# back. Without that strip, a JSON input encoding a literal \u0000 INSIDE a
# string value — which a Write/Edit/NotebookEdit content field legitimately
# may — made jq emit the raw byte, split that value in two, and fail the
# cardinality check below, turning a caller's `|| exit 0` into a silent skip
# of the entire guard.
#
# Dropping the NUL rather than encoding around it is not the lesser option,
# it is the only representable one: a bash variable cannot hold a NUL byte,
# so no framing scheme (length prefix, base64, …) could deliver one into
# HOOK_JQ_FIELDS. It is also exactly what the per-field command substitution
# this helper replaced did — $( ) discards NUL bytes and keeps the rest of
# the value, so content AFTER a NUL is still returned and still scanned.
# Read through a process substitution rather than $( ) because the delimiter
# itself must survive the read; command substitution would strip it too.
#
# hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0
# COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}"
Expand All @@ -648,7 +658,9 @@ hook::jq_fields() {
local prog="" filter
for filter in "$@"; do
[[ -n "$prog" ]] && prog+=","
prog+="((${filter}) // \"\" | tostring)"
# split/join (1-arity, a plain string split — NOT gsub, which would put a
# NUL inside an Oniguruma pattern) removes every NUL from the value.
prog+="((${filter}) // \"\" | tostring | split(\"\\u0000\") | join(\"\"))"
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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/bash-format/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions plugins/bash-format/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
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 inside a payload value no longer makes `hook::jq_fields`
come back empty (#2120).** The helper delimits its batched fields with NUL, and a JSON string may
legitimately encode one — a `Write`/`Edit`/`NotebookEdit` content field can. jq emitted the raw
byte, the read split that value in two, the cardinality check saw one value too many, and the
helper returned non-zero — which every caller treats as "skip", so the hook exited without doing
its work. Each value is now NUL-stripped INSIDE the jq filter, so the delimiter provably cannot
occur in a value. Stripping is not a lesser alternative to an encoding scheme, it is the only
representable behavior: a bash variable cannot hold a NUL byte, and the per-field command
substitution this helper replaced dropped the byte and kept the rest of the value — so content
AFTER a NUL is returned and scanned exactly as it was before the batching. Synced from
`lib/hook-utils.sh`.

## [0.7.0]

### Removed
Expand Down
30 changes: 21 additions & 9 deletions plugins/bash-format/hooks/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -627,14 +627,24 @@ hook::jq_field() {
# never be mistaken for a complete one. 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.
# Fields are NUL-separated on the wire, and every value has its own NUL bytes
# REMOVED jq-side first, so the delimiter provably cannot occur inside a
# value: the values carry arbitrary text (a Bash command spans newlines
# routinely), and once NUL is out of the value alphabet no payload can put it
# back. Without that strip, a JSON input encoding a literal \u0000 INSIDE a
# string value — which a Write/Edit/NotebookEdit content field legitimately
# may — made jq emit the raw byte, split that value in two, and fail the
# cardinality check below, turning a caller's `|| exit 0` into a silent skip
# of the entire guard.
#
# Dropping the NUL rather than encoding around it is not the lesser option,
# it is the only representable one: a bash variable cannot hold a NUL byte,
# so no framing scheme (length prefix, base64, …) could deliver one into
# HOOK_JQ_FIELDS. It is also exactly what the per-field command substitution
# this helper replaced did — $( ) discards NUL bytes and keeps the rest of
# the value, so content AFTER a NUL is still returned and still scanned.
# Read through a process substitution rather than $( ) because the delimiter
# itself must survive the read; command substitution would strip it too.
#
# hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0
# COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}"
Expand All @@ -648,7 +658,9 @@ hook::jq_fields() {
local prog="" filter
for filter in "$@"; do
[[ -n "$prog" ]] && prog+=","
prog+="((${filter}) // \"\" | tostring)"
# split/join (1-arity, a plain string split — NOT gsub, which would put a
# NUL inside an Oniguruma pattern) removes every NUL from the value.
prog+="((${filter}) // \"\" | tostring | split(\"\\u0000\") | join(\"\"))"
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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/biome-format/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
16 changes: 16 additions & 0 deletions plugins/biome-format/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
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 inside a payload value no longer makes `hook::jq_fields`
come back empty (#2120).** The helper delimits its batched fields with NUL, and a JSON string may
legitimately encode one — a `Write`/`Edit`/`NotebookEdit` content field can. jq emitted the raw
byte, the read split that value in two, the cardinality check saw one value too many, and the
helper returned non-zero — which every caller treats as "skip", so the hook exited without doing
its work. Each value is now NUL-stripped INSIDE the jq filter, so the delimiter provably cannot
occur in a value. Stripping is not a lesser alternative to an encoding scheme, it is the only
representable behavior: a bash variable cannot hold a NUL byte, and the per-field command
substitution this helper replaced dropped the byte and kept the rest of the value — so content
AFTER a NUL is returned and scanned exactly as it was before the batching. Synced from
`lib/hook-utils.sh`.

## [0.6.0]

### Removed
Expand Down
Loading