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
23 changes: 16 additions & 7 deletions docs/conventions/hook-observability/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ telemetry..."`), not a generic `"Running hook..."`.

### 2. `systemMessage`: user-visible, scoped by who can act on the content

An exit-0 JSON output field (`hookSpecificOutput` sibling), 10,000-character cap (an overflow to a
A JSON output field (`hookSpecificOutput` sibling) that Claude Code reads on every exit code, exit 2
included ("Claude Code still reads any valid JSON output on stdout", hooks reference, Exit code 2,
fetched 2026-09-27), 10,000-character cap (an overflow to a
file, not a truncation; see [Output caps](#output-caps-stated-by-the-reference)), shown to the
user immediately. Composed via `hook::emit_channels` / `hook::emit_skip_notice`
(`lib/hook-utils.sh`) alongside `additionalContext` in one JSON document. Claude Code parses a
Expand Down Expand Up @@ -81,8 +83,10 @@ count, or the disclosure becomes the noise problem it was meant to prevent.
**Not required** for two situations that are already visible or already correctly agent-scoped:

- **Exit-2 blocking paths.** A `PreToolUse` hook that blocks a tool call via exit code 2 is
already user-visible through Claude Code's own permission-denial UI. An additional
`systemMessage` on top of a block would be redundant, not more observable.
already user-visible through Claude Code's own permission-denial UI, and Claude reads its stderr
as the denial reason. Repeating the block reason on `systemMessage` would be redundant, not more
observable. The field is not discarded on a block (see above), so a blocking hook may still carry
one, but only for content that meets the carve-out below, never for the reason itself.
- **Legitimate advisory findings *the model can act on*.** A hook that surfaces a finding to Claude
for it to act on (e.g. a lint result, a suggested fix) belongs on `additionalContext` only. That
is the correct channel for agent-actionable content, not a gap. This is the case the
Expand Down Expand Up @@ -286,10 +290,15 @@ Fleet audits check, per wired producer hook:
matcher.
- Any `systemMessage` that is neither a prerequisite-skip notice nor a content-mutation notice
satisfies all three carve-out conditions, and its model-channel counterpart asserts no operator
presence. Not mechanically gated, but reviewed per hook. As of this writing `context-guard`'s
`zone-crossing-inject.sh` is the only site in the fleet admitted this way; every other call site
is a prerequisite skip or a content-mutation notice, so a second one is a signal to re-read the
three conditions rather than to follow the precedent.
presence. Not mechanically gated, but reviewed per hook. As of this writing two sites in the
fleet are admitted this way: `context-guard`'s `zone-crossing-inject.sh`, and `guardrails`'
`block-hook-bypass.sh` operator-lever notice (#4679). That notice lists switches only the operator
may flip (condition 1); stderr separately carries the verdict and the agent's remedy, names an
operator option only as the operator's to set, and never says the operator has seen anything
(condition 2 and the delivery rule); and it fires once
per session and agent, with the latch's renewal declined (condition 3). Every other call site is
a prerequisite skip or a content-mutation notice, so a third one is a signal to re-read the three
conditions rather than to follow the precedent.
- Every path on which the hook rewrote file content names what it changed on the user channel,
bounded by a per-run cap with the remainder reported as a count. Not mechanically gated, but
reviewed per hook. The adopting reference is `plugins/typos-format/hooks/typos-format.sh`.
Expand Down
79 changes: 60 additions & 19 deletions lib/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4039,9 +4039,9 @@ hook::reset_analysis_state() {
# real, the path it names never arrived. The four places that discover an
# orphaned operand (a second operator, a here-doc opener, a process
# substitution, the end of a segment) share this one spelling. Reads and writes
# hook::bash_parse_segments's own locals through dynamic scope, so it is not
# callable on its own.
# shellcheck disable=SC2154 # `pend` is a local of hook::bash_parse_segments
# hook::bash_parse_segments_uncached's own locals through dynamic scope, so it
# is not callable on its own.
# shellcheck disable=SC2154 # `pend` is a local of hook::bash_parse_segments_uncached
hook::_bps_orphan_pending() {
((pend)) || return 0
HOOK_SEG_REDIR_OPAQUE[${#HOOK_SEG_REDIR_OP[@]} - 1]=1
Expand All @@ -4052,9 +4052,9 @@ hook::_bps_orphan_pending() {
# target of the redirection still waiting for its operand. Called from the four
# places a word can end (blank, redirection operator, control operator, end of
# input); single-sourced so those four cannot drift apart on the quoting
# provenance they record. Reads and writes hook::bash_parse_segments's own
# locals through dynamic scope, so it is not callable on its own.
# shellcheck disable=SC2154 # every unassigned name here is a local of hook::bash_parse_segments
# provenance they record. Reads and writes hook::bash_parse_segments_uncached's
# own locals through dynamic scope, so it is not callable on its own.
# shellcheck disable=SC2154 # every unassigned name here is a local of hook::bash_parse_segments_uncached
hook::_bps_close_word() {
local _bps_q=0 _bps_last
if ((wq_quoted || wq_esc)); then
Expand Down Expand Up @@ -4082,7 +4082,7 @@ hook::_bps_close_word() {
# operand that never arrived leaves its redirection OPAQUE: the operator is
# real, the path it names is not recoverable from this command string.
# Dynamic scope, like hook::_bps_close_word.
# shellcheck disable=SC2154 # every unassigned name here is a local of hook::bash_parse_segments
# shellcheck disable=SC2154 # every unassigned name here is a local of hook::bash_parse_segments_uncached
hook::_bps_flush_segment() {
hook::_bps_orphan_pending
if ((${#seg[@]})); then
Expand All @@ -4097,7 +4097,48 @@ hook::_bps_flush_segment() {
HOOK_SEG_REDIR_OPAQUE=()
}

# Single linear pass: read the command into a char array once (O(n)), then walk
# hook::_bps_chars <cmd>: fill hook::bash_parse_segments_uncached's `chars`
# with the bytes of <cmd>, one per element, in time linear in its length.
#
# `${s:i:1}` is not O(1): bash measures the whole of `s` on every expansion
# (a multibyte scan under a UTF-8 locale, a byte scan under C), so one call per
# character is quadratic. Measured (#4528): 1.2 s for a 10,000-character
# command under en_US.UTF-8 and 4.9 s for 20,000, which put a ~70 KB command
# past the Bash row's 60-second hook timeout. Each slice here is taken from a
# string of at most 4096, then 64, bytes, so no expansion measures more than
# that. Bytes rather than characters, under C: every character the tokenizer
# compares against is ASCII, and no byte of a UTF-8 multibyte sequence is, so
# a word reassembled from bytes is the word the characters spelled.
# Dynamic scope, like hook::_bps_close_word.
hook::_bps_chars() {
[[ "${LC_ALL-}" == C ]] || {
hook::_c_locale hook::_bps_chars "$@"
return
}
local __hu_s="$1" __hu_n=${#1} __hu_o1 __hu_o2 __hu_j __hu_big __hu_bn __hu_blk __hu_bl
chars=()
for ((__hu_o1 = 0; __hu_o1 < __hu_n; __hu_o1 += 4096)); do
__hu_big=${__hu_s:__hu_o1:4096}
__hu_bn=${#__hu_big}
for ((__hu_o2 = 0; __hu_o2 < __hu_bn; __hu_o2 += 64)); do
__hu_blk=${__hu_big:__hu_o2:64}
__hu_bl=${#__hu_blk}
for ((__hu_j = 0; __hu_j < __hu_bl; __hu_j++)); do
chars+=("${__hu_blk:__hu_j:1}")
done
done
done
}

# hook::bash_parse_segments is the name every hook calls, and
# hook::bash_parse_segments_uncached the same parse under the name a dispatcher
# that shares one parse across the hooks of an event (guardrails
# run-guards.sh) falls through to on a miss. Same split as hook::jq_fields.
hook::bash_parse_segments() {
hook::bash_parse_segments_uncached "$@"
}

# Single linear pass: read the command into a byte array once (O(n)), then walk
# it splitting top-level segments on UNQUOTED control operators and tokenizing
# each segment into argv words honoring '…', "…", $'…', backslash escapes
# (including backslash-newline continuation), and unquoted `#` comments to EOL
Expand Down Expand Up @@ -4133,19 +4174,19 @@ hook::_bps_flush_segment() {
# A segment with redirections but no argv word (`> f` alone) invokes no
# callback, so its redirections are not reported.
# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape
hook::bash_parse_segments() {
hook::bash_parse_segments_uncached() {
local cmd="$1" cb="$2"
local -a chars=()
local c nx n=${#cmd} i __hu_acd
# Walk ${cmd:i:1} in-process. The previous `read -N1` from a process
# substitution forked a subshell (and a printf) per parse even though both
# are builtins (Command Substitution, Bash Reference Manual;
# https://mywiki.wooledge.org/CommandSubstitution). Same character walk as
# hook::env_s_split. Cygwin's fork is a non-copy-on-write Win32 CreateProcess
# (Cygwin User's Guide, Process Creation).
for ((i = 0; i < n; i++)); do
chars+=("${cmd:i:1}")
done
local c nx n i __hu_acd
# In-process, not `read -N1` from a process substitution, which forked a
# subshell (and a printf) per parse even though both are builtins (Command
# Substitution, Bash Reference Manual;
# https://mywiki.wooledge.org/CommandSubstitution); Cygwin's fork is a
# non-copy-on-write Win32 CreateProcess (Cygwin User's Guide, Process
# Creation). Not a here-string either: one at or above the pipe capacity
# can block the shell (hook::json_complete).
hook::_bps_chars "$cmd"
n=${#chars[@]}
# `pend` is set while a redirection is waiting for its operand word: the next
# word to close becomes that target instead of an argv word.
local word="" have=0 pend=0 rop_txt="" rfd_next="" rdup=""
Expand Down
91 changes: 91 additions & 0 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,7 @@ lc12g2_run() {
if [[ -z "${LC_ALL+x}" ]]; then ok "C locale: an unset LC_ALL stays unset"; else fail "C locale: LC_ALL left set to [$LC_ALL]"; fi
)
(
# shellcheck disable=SC2030
export LC_ALL=C.UTF-8
lc12g2_run
if [[ "$LC_ALL" == C.UTF-8 && "$(declare -p LC_ALL)" == 'declare -x LC_ALL='* ]]; then
Expand Down Expand Up @@ -3556,6 +3557,96 @@ else
fail "bash_parse_segments per-word quoting provenance: got [$bps_word_q], want [0 2 1]"
fi

# --- hook::bash_parse_segments: byte walk, caller's locale, linear cost ------
# The command is split into bytes under C (#4528). A word reassembled from the
# bytes of a multibyte character, or from a byte that is not valid UTF-8, must
# be exactly the text written, and the caller must be handed back its locale.
bps_mb_case() { # <desc> <command> <want words joined by |>
bps_last=()
hook::bash_parse_segments "$2" bps_collect
local got
got=$(join_a ${bps_last[@]+"${bps_last[@]}"})
if [[ "$got" == "$3" ]]; then
ok "bash_parse_segments bytes: $1"
else
fail "bash_parse_segments bytes $1: got [$(printf %q "$got")], want [$(printf %q "$3")]"
fi
}
bps_mb_run() {
bps_mb_case "multibyte words survive the byte walk" \
'echo "héllo wörld ✓" 日本語 𝄞' 'echo|héllo wörld ✓|日本語|𝄞'
bps_mb_case "an invalid UTF-8 byte stays the byte it was" \
$'printf \xff\xfe \xc3' $'printf|\xff\xfe|\xc3'
bps_mb_case "a multibyte heredoc body is skipped, the next line parsed" \
$'cat <<EOF\nünï ✓\nEOF\necho après' 'echo|après'
local mb='é日' before
before=${#mb}
hook::bash_parse_segments 'echo é' bps_collect
if [[ "${#mb}" == "$before" ]]; then
ok "bash_parse_segments: the caller's locale is restored"
else
fail "bash_parse_segments: locale leaked, \${#mb} $before -> ${#mb}"
fi
}
bps_mb_in() { # <LC_ALL value, empty for unset> -> bps_mb_run there, then the LC_ALL check
(
# shellcheck disable=SC2030,SC2031
if [[ -n "$1" ]]; then declare -x LC_ALL="$1"; else unset LC_ALL; fi
bps_mb_run
local now
now=$(declare -p LC_ALL 2>/dev/null)
if [[ -z "$1" && -z "$now" ]]; then
ok "bash_parse_segments: an unset LC_ALL stays unset"
elif [[ -n "$1" && "$now" == "declare -x LC_ALL=\"$1\"" ]]; then
ok "bash_parse_segments: LC_ALL=$1 keeps its value and export flag"
else
fail "bash_parse_segments: LC_ALL [${1:-unset}] came back as [${now:-unset}]"
fi
)
}
bps_mb_in ""
bps_mb_in C.UTF-8

# Cost grows linearly with the command. The per-character `${cmd:i:1}` walk
# this replaced was quadratic: 1.2 s at 10,000 characters and 4.9 s at 20,000
# under en_US.UTF-8. A ratio of two sizes measured back to back, best of
# three, keeps the check off the host's absolute speed: 8x the text costs
# about 8x when linear and 50x or more when quadratic, so 20 separates them
# with room for noise either way.
bps_lin_ms() { # <command> -> best of three parse times, in microseconds
local best=-1 t0 t1 d _r
for _r in 1 2 3; do
t0=$EPOCHREALTIME
hook::bash_parse_segments "$1" :
t1=$EPOCHREALTIME
d=$((${t1/./} - ${t0/./}))
((best < 0 || d < best)) && best=$d
done
printf '%s' "$best"
}
if [[ -z "${EPOCHREALTIME:-}" ]]; then
ok "bash_parse_segments linear cost: not timed (EPOCHREALTIME absent, Bash < 5.0)"
else
bps_lin_line='git commit -m "ünïcödé prose, again" && echo ok; '
bps_lin_small=""
while ((${#bps_lin_small} < 2000)); do bps_lin_small+="$bps_lin_line"; done
bps_lin_large=""
for _ in 1 2 3 4 5 6 7 8; do bps_lin_large+="$bps_lin_small"; done
(
# shellcheck disable=SC2031
declare -x LC_ALL=C.UTF-8
small=$(bps_lin_ms "$bps_lin_small")
large=$(bps_lin_ms "$bps_lin_large")
((small > 0)) || small=1
ratio=$((large / small))
if ((ratio < 20)); then
ok "bash_parse_segments: 8x the command costs ${ratio}x (${small} us -> ${large} us)"
else
fail "bash_parse_segments: 8x the command costs ${ratio}x (${small} us -> ${large} us); the walk is superlinear again"
fi
)
fi

# repo_root_to / repo_relative_path_to write in this shell. hook::repo_root is
# the one path helper that keeps a print form, and it must agree with its twin.
rr_to=""
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.59",
"version": "0.8.60",
"description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.",
"author": {
"name": "Melodic Software",
Expand Down
6 changes: 6 additions & 0 deletions plugins/actionlint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
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.60] - 2026-09-27

### Changed

- hook-utils.sh: `hook::bash_parse_segments` splits a command in time linear in its length. It took one `${cmd:i:1}` per character, and bash measures the whole string on each of those, so a parse was quadratic: 1.27 s for a 10,000-character heredoc under en_US.UTF-8 against 84 ms now. The command is split in 4096- and 64-byte blocks under the C locale, and the caller's `LC_ALL` is put back afterwards. Every segment it reports is byte-identical to before under en_US.UTF-8, C.UTF-8 and C. The parse is also reachable as `hook::bash_parse_segments_uncached`, for a dispatcher that shares one parse across the hooks of an event ([#4528](https://github.com/melodic-software/claude-code-plugins/issues/4528)).

## [0.8.59] - 2026-09-27

### Changed
Expand Down
Loading
Loading