diff --git a/docs/conventions/hook-observability/README.md b/docs/conventions/hook-observability/README.md index e748c8311f..70b567c4e9 100644 --- a/docs/conventions/hook-observability/README.md +++ b/docs/conventions/hook-observability/README.md @@ -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 @@ -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 @@ -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`. diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 05dc32b2e0..3c092166e2 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -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 @@ -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() { # + 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 < ${#mb}" + fi +} +bps_mb_in() { # -> 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() { # -> 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="" diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index 2a36f87854..893da52043 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.59", + "version": "0.8.60", "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 e7b9dbcfd5..36f512ba42 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -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 diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index eba0b9af33..087cee941d 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.24.2", + "version": "0.24.3", "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 86eaef0fe4..5ee432c84a 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `autonomy` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.24.3] - 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). + ## [0.24.2] - 2026-09-27 ### Changed diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index df47d2052c..5d9b385419 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.59", + "version": "0.7.60", "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 23baa800ce..df914721df 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,12 @@ 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.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.7.59] - 2026-09-27 ### Changed diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index 618f71f602..cfdfc107dd 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.57", + "version": "0.6.58", "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 53be2b3bbd..355a127a86 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,12 @@ 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.58] - 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.6.57] - 2026-09-27 ### Changed diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index a5ffffbd19..ca71d56c57 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.62.6", + "version": "0.62.7", "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used: a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface: every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json: full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labeled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age, plus on Windows a kernel-object census (Token objects against uptime, paged pool) that names a host-level leak beneath all four suspects; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces, namely built-in CLI commands, bundled skills, plugin-backed built-ins, and session-provided skills, against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry from the OTEL store, the collector, the per-session hook event log and hook-event JSONL, and ccusage, with trend reports, a per-session report of what fired, what was blocked and the event timeline, 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: 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: 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, 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, the skill-usage log and the hook log root live, places the root's self-ignoring guard, and detects retired conventions. Plus an opt-in, default-off per-session hook event log (one JSON line per hook event on every event the generated registry marks observable, written to /sessions/.jsonl, with SessionEnd retention by session count or age and an optional detached pre-prune command), a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures. The last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that routes envelopes under the same root: per session when the envelope carries a session id, else into the shared 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 b672e6c8a0..3128ae493b 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,12 @@ 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.62.7] - 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.62.6] - 2026-09-27 - **`lanes` and `observability` merge adjacent pre-compute probes.** `lanes` renders the diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index b7cfeff057..b076aa15ca 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.7.71", + "version": "0.7.72", "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 b016fd874d..22d83f0b83 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,12 @@ 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.7.72] - 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.7.71] - 2026-09-24 ### Changed diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index 2d654b3e3d..d3d8b2ca53 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.50", + "version": "0.6.51", "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 7546fabab9..f2ed53cae5 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,12 @@ 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.51] - 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.6.50] - 2026-09-27 ### Changed diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index 97dc932255..48a374c55d 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.59", + "version": "0.6.60", "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 ee16a54661..df0001fff4 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,12 @@ 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.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.6.59] - 2026-09-27 ### Changed diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/plugins/gaming/.claude-plugin/plugin.json b/plugins/gaming/.claude-plugin/plugin.json index be34e848c1..afc64dc377 100644 --- a/plugins/gaming/.claude-plugin/plugin.json +++ b/plugins/gaming/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "gaming", - "version": "0.8.0", + "version": "0.8.1", "description": "Apply, track, tune, and cleanly remove the community DLSS 5 Neural Rendering mod (OptiScaler forks) in PC games on Windows, from Steam, Epic, EA app, Battle.net, GOG, Ubisoft Connect and the Xbox app. Anti-cheat checks that refuse by default and install only on a typed at-your-own-risk acknowledgement, pre-install snapshots and manifests for byte-exact removal, a per-game ledger, and an upstream watch for fork and driver releases. Ships no NVIDIA binary: the runtime DLL comes from a path, an installed DLSS 5 title, or a source the user configures.", "author": { "name": "Melodic Software", diff --git a/plugins/gaming/CHANGELOG.md b/plugins/gaming/CHANGELOG.md index 82768e0f5d..bd39f706e9 100644 --- a/plugins/gaming/CHANGELOG.md +++ b/plugins/gaming/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `gaming` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.1] - 2026-09-27 + +### Fixed + +- A folder the discovery scan cannot list is reported with the path from the error, from `CategoryInfo.TargetName`, or from a quoted path in the message when Windows leaves `TargetObject` empty. The selftest holds that folder open with no sharing so the listing fails with a sharing violation. A Deny ACE, including one for Everyone, does not stop an elevated runner from listing. + ## [0.8.0] - 2026-09-27 ### Added diff --git a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 index 7eea60ce41..416e88a2ee 100644 --- a/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 +++ b/plugins/gaming/skills/dlss5/scripts/Invoke-Dlss5Mod.ps1 @@ -232,7 +232,7 @@ function Find-AntiCheat($root) { Get-ChildItem -LiteralPath $p -Force -ErrorAction SilentlyContinue } } - $script:AcScanGaps = @($err | ForEach-Object { "on-disk scan: $($_.TargetObject) unreadable: $($_.Exception.Message)" }) + $script:AcScanGaps = @($err | ForEach-Object { "on-disk scan: $(ErrorPath $_ '') unreadable: $($_.Exception.Message)" }) @($items | Where-Object { IsAntiCheatName $_.Name } | ForEach-Object FullName | Sort-Object -Unique) } @@ -417,6 +417,16 @@ function Game($launcher, $name, $dir, $source) { } # One bad record is reported and skipped; the rest of that launcher's records still load. function Record($label, [scriptblock]$body) { try { & $body } catch { $script:Unchecked += "${label} unreadable: $($_.Exception.Message)" } } +# The path a listing error names. Windows often leaves TargetObject empty and puts the path on +# CategoryInfo.TargetName, or only inside a quoted path in the message. +function ErrorPath($err, $fallback) { + foreach ($named in @($err.TargetObject, $err.CategoryInfo.TargetName)) { if ("$named") { return "$named" } } + $msg = "$($err.Exception.Message)" + if ($msg -match "'([A-Za-z]:\\[^']+)'") { return $Matches[1] } + if ($msg -match '"([A-Za-z]:\\[^"]+)"') { return $Matches[1] } + if ("$fallback") { return "$fallback" } + return "$fallback" +} # Lists a folder; an absent one is silent, one that cannot be listed is reported. # The name filter is applied afterwards: Get-ChildItem -Filter drops an access-denied folder, at # any depth, without recording an error. @@ -424,7 +434,7 @@ function ListDir($label, $path, [hashtable]$opts = @{}) { $pattern = $opts.Filter ?? '*'; $o = @{} + $opts; $o.Remove('Filter') $err = $null Get-ChildItem -LiteralPath $path @o -Force -ErrorAction SilentlyContinue -ErrorVariable err | Where-Object Name -like $pattern - foreach ($x in $err) { if ($x.Exception -isnot [Management.Automation.ItemNotFoundException]) { $script:Unchecked += "${label}: $($x.TargetObject) unreadable: $($x.Exception.Message)" } } + foreach ($x in $err) { if ($x.Exception -isnot [Management.Automation.ItemNotFoundException]) { $script:Unchecked += "${label}: $(ErrorPath $x $path) unreadable: $($x.Exception.Message)" } } } function Find-SteamGames { $steam = (RegProps 'HKCU:\Software\Valve\Steam').SteamPath @@ -1282,7 +1292,7 @@ function Find-RuntimeCandidates { @(Get-ScanRoots | Where-Object { Test-Path -LiteralPath $_ -PathType Container } | ForEach-Object { $err = $null Get-ChildItem -LiteralPath $_ -Recurse -File -Force -ErrorAction SilentlyContinue -ErrorVariable err | Where-Object Name -eq 'nvngx_dlssnr.dll' - foreach ($x in $err) { $script:ScanGaps += "runtime scan: $($x.TargetObject) unreadable: $($x.Exception.Message)" } + foreach ($x in $err) { $script:ScanGaps += "runtime scan: $(ErrorPath $x '') unreadable: $($x.Exception.Message)" } } | Sort-Object FullName -Unique) } # Read-only: installed games per launcher, what could not be read, and runtime DLL candidates with @@ -1874,23 +1884,47 @@ function Do-Selftest { $vlock = [IO.File]::Open("$sp\steamapps\libraryfolders.vdf", 'Open', 'Read', 'None') try { $vf = Find-Games } finally { $vlock.Dispose() } Assert 'discover: a locked libraryfolders.vdf is reported and the main library still scans' (@($script:Unchecked | Where-Object { $_ -like 'Steam:*libraryfolders.vdf*unreadable*' }).Count -eq 1 -and @($vf | Where-Object name -eq 'Main Lib Game').Count -eq 1 -and -not @($vf | Where-Object name -eq 'Clean Game').Count) - # A library folder that cannot be listed is reported, not read as empty - $deny = [Security.AccessControl.FileSystemAccessRule]::new([Security.Principal.WindowsIdentity]::GetCurrent().User, 'ListDirectory', 'Deny') - $acl = Get-Acl -LiteralPath "$l2\steamapps"; $acl.AddAccessRule($deny); Set-Acl -LiteralPath "$l2\steamapps" -AclObject $acl + # A library folder that cannot be listed is reported, not read as empty. + # A Deny ACE does not stop an elevated runner from listing, even a Deny for Everyone. + # Hold the directory open with no sharing so the next listing fails with a sharing violation. + # pwsh on the runner has no FileOptions.BackupSemantics, so open the directory with CreateFileW. + $LockDir = { + param([string]$LiteralPath) + if (-not ('Dlss5DirHandle' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +public sealed class Dlss5DirHandle : IDisposable { + IntPtr handle; + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern IntPtr CreateFileW(string name, uint access, uint share, IntPtr sec, uint disp, uint flags, IntPtr template); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CloseHandle(IntPtr handle); + public Dlss5DirHandle(string path) { + handle = CreateFileW(path, 0x80000000, 0, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero); + if (handle == new IntPtr(-1)) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + public void Dispose() { + if (handle != IntPtr.Zero && handle != new IntPtr(-1)) { CloseHandle(handle); handle = IntPtr.Zero; } + } +} +'@ + } + [Dlss5DirHandle]::new($LiteralPath) + } + $lockSteam = & $LockDir "$l2\steamapps" $od = "$tmp\pd\Origin\LocalContent\Denied"; Put "$od\x.mfst" '?id=x' - $acl2 = Get-Acl -LiteralPath $od; $acl2.AddAccessRule($deny); Set-Acl -LiteralPath $od -AclObject $acl2 + $lockOd = & $LockDir $od try { $null = Find-Games } - finally { - $acl = Get-Acl -LiteralPath "$l2\steamapps"; [void]$acl.RemoveAccessRule($deny); Set-Acl -LiteralPath "$l2\steamapps" -AclObject $acl - $acl2 = Get-Acl -LiteralPath $od; [void]$acl2.RemoveAccessRule($deny); Set-Acl -LiteralPath $od -AclObject $acl2 - } + finally { $lockSteam.Dispose(); $lockOd.Dispose() } Assert 'discover: a library that cannot be listed is reported' (@($script:Unchecked | Where-Object { $_ -like "Steam: *lib2\steamapps unreadable*" }).Count -eq 1) Assert 'discover: a nested folder that cannot be listed is reported, and the rest still loads' (@($script:Unchecked | Where-Object { $_ -like 'Origin: *LocalContent\Denied unreadable*' }).Count -eq 1) Put "$tmp\rc\a\nvngx_dlssnr.dll" 'held'; Put "$tmp\rc\b\nvngx_dlssnr.dll" 'fakemodel'; Put "$tmp\rc\c\x.txt" 'x' $dlock = [IO.File]::Open("$tmp\rc\a\nvngx_dlssnr.dll", 'Open', 'Read', 'None') - $acl = Get-Acl -LiteralPath "$tmp\rc\c"; $acl.AddAccessRule($deny); Set-Acl -LiteralPath "$tmp\rc\c" -AclObject $acl + $lockRc = & $LockDir "$tmp\rc\c" try { $script:ScanRoots = @("$tmp\rc"); $rc = (Do-Discover) | ConvertFrom-Json } - finally { $dlock.Dispose(); $script:ScanRoots = $null; $acl = Get-Acl -LiteralPath "$tmp\rc\c"; [void]$acl.RemoveAccessRule($deny); Set-Acl -LiteralPath "$tmp\rc\c" -AclObject $acl } + finally { $dlock.Dispose(); $lockRc.Dispose(); $script:ScanRoots = $null } Assert 'discover: a game subfolder the runtime scan cannot list is reported' (@($rc.unchecked | Where-Object { $_ -like 'runtime scan:*rc\c*unreadable*' }).Count -eq 1) Assert 'discover: an unreadable runtime candidate fails alone and the next is still reported' (@($rc.runtimeCandidates | Where-Object { -not $_.passes -and $_.reason -like 'unreadable*' }).Count -eq 1 -and @($rc.runtimeCandidates | Where-Object passes).Count -eq 1) $dj = (Do-Discover) | ConvertFrom-Json @@ -1944,8 +1978,8 @@ function Do-Selftest { $cga = (Do-Assess $cg) | ConvertFrom-Json Assert 'Steam with nothing disclosed anywhere: none-disclosed, no acknowledgement, caveat stated' ($cga.antiCheat.status -eq 'none-disclosed' -and -not $cga.acknowledgementRequired -and $cga.antiCheat.note -like '*not proof of no anti-cheat*') New-Item -ItemType Directory -Force -Path "$cg\locked" | Out-Null - $acl = Get-Acl -LiteralPath "$cg\locked"; $acl.AddAccessRule($deny); Set-Acl -LiteralPath "$cg\locked" -AclObject $acl - try { $cgl = (Do-Assess $cg) | ConvertFrom-Json } finally { $acl = Get-Acl -LiteralPath "$cg\locked"; [void]$acl.RemoveAccessRule($deny); Set-Acl -LiteralPath "$cg\locked" -AclObject $acl; Remove-Item -LiteralPath "$cg\locked" -Force } + $lockCg = & $LockDir "$cg\locked" + try { $cgl = (Do-Assess $cg) | ConvertFrom-Json } finally { $lockCg.Dispose(); Remove-Item -LiteralPath "$cg\locked" -Force } Assert 'a game folder the on-disk scan cannot list makes the status unknown' ($cgl.antiCheat.status -eq 'unknown' -and @($cgl.antiCheat.unchecked | Where-Object { $_ -like 'on-disk scan:*locked*unreadable*' }).Count -eq 1) $script:SteamPages['444'] = '
Unlisted Game
' Put "$l2\steamapps\appmanifest_444.acf" (& $acf 444 'Unlisted Game' 'Unlisted') diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index 4d28e70b53..3243b4905f 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.61", + "version": "0.3.62", "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 2a616f7624..9ccb030cd7 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,12 @@ 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.62] - 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.3.61] - 2026-09-27 ### Changed diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 8ae1e9fd5c..53b0af2e7d 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -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 @@ -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 @@ -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 @@ -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 : fill hook::bash_parse_segments_uncached's `chars` +# with the bytes of , 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 @@ -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="" diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index bd7392d0d8..cddafca4b7 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -153,5 +153,5 @@ "min": 1 } }, - "version": "0.37.2" + "version": "0.38.0" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 3966b323b7..3afb3244f4 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,74 @@ 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.38.0] - 2026-09-27 + +### Changed + +- **`block-hook-bypass` prints what the blocked agent can act on, and nothing else** + ([#4679](https://github.com/melodic-software/claude-code-plugins/issues/4679)). A block printed about + 1,400 characters on stderr: the verdict, the Write/Edit remedy, advice to write under + `block_hook_bypass_scratch_roots`, the operator lever list, and a scope note listing the write forms + the guard does not inspect. The agent cannot use the lever list, the scratch-root advice was wrong on + the PowerShell and python lanes (neither consults a scratch root), and the scope note handed every + reader the list of unchecked forms. stderr is now: + - On the `cat`, `echo`/`printf` and staged-move lanes: the verdict; the Write/Edit remedy; one line + saying why the target was not scratch-exempt (a quoted or escaped target, a relative target after a + directory change or with no known cwd, a target holding `$`, a backtick, `~` or a glob, no root + configured and no project root, a temp-rooted project, a target outside every root, or one that + resolves outside it); the roots that exempt a bare target in this session, when any applies, + including the temp tree and the plugin data directory; and "If Write or Edit is refused for this + path, stop and tell the user; the operator can add a root with `block_hook_bypass_scratch_roots`." + The staged-move lane says "move destination" where the others say "target". + - On the PowerShell and python lanes: the verdict, the Write/Edit remedy, and "If Write or Edit is + refused for this path, stop and tell the user; this guard's switches are operator-only." + - On every lane, a last line pointing the operator at the guardrails README. It stays until a human confirms + interactively that an exit-2 `PreToolUse` `systemMessage` renders. +- **The operator levers moved to one `systemMessage` per session and agent.** They were on stderr and on + a `systemMessage` on every block. The notice now fires on the first block of a (session, agent) pair, + latched by `hook::notice_once`, and this guard declines that latch's every-8 renewal. It lists the + levers narrowest first and says the guard is a deterrent over one command string, not a sandbox, + with the README as the list of what it does not inspect. `hook-utils.sh` is unchanged, so the other + guards' notices still renew. Without `jq` the guard allows before it could block, so the latch is not + spent on a run that never delivered it. +- Exit code 2, the verdict text, the telemetry `form` strings and every pinned block are unchanged. + +### Fixed + +- **The "`systemMessage` is discarded on exit 2" claim is gone** from the README, the hook's comments and + its suite. The hooks reference says Claude Code "still reads any valid JSON output on stdout" on exit + 2, and lists `systemMessage` as a "Warning message shown to the user", with no `PreToolUse` exception + ([hooks: Exit code 2](https://code.claude.com/docs/en/hooks#exit-code-2), fetched 2026-09-27). + `docs/conventions/hook-observability/README.md` is corrected to match and admits this notice to its + carve-out list. + +## [0.37.3] - 2026-09-27 + +### Fixed + +- **A long Bash or PowerShell command no longer runs the guard row past its 60-second `timeout`** + ([#4528](https://github.com/melodic-software/claude-code-plugins/issues/4528)). Claude Code cancels a + command hook at its `timeout`, and on `PreToolUse` a cancelled command hook does not block the tool call + ([hooks: Timeouts](https://code.claude.com/docs/en/hooks#timeouts)), so a long enough command passed every + guard on the row unchecked. On `main` the row took 7.23 s for a 10 KB heredoc and 12.6 s for 16 KB, and a + ~70 KB one was still running at 120 s. Now it takes 132 ms, 203 ms and 64 ms. + - The row passes `--max-command-len 16384` to `run-guards.sh`. That is the `MAX_COMMAND_LEN` ceiling above + which five of its guards already refuse a command unread. Past it, the chain ends at the first guard that + blocks, which is `block-no-verify` at the head of the row, before any guard tokenizes the command. + Before, the dispatcher ran the other eight guards after that block, and the three with no ceiling that + tokenize (`block-hook-bypass`, `block-noncanonical-commit`, `block-convention-violation`) each spent about + 44 s of a 70 KB run tokenizing the whole command only to add a reason. Each guard keeps its kill switch: with `block-no-verify` disabled, + `block-dangerous-git` blocks next. At or below the ceiling every guard still runs and every reason still + shows. `run-guards.test.sh` holds the row's value equal to each guard's `MAX_COMMAND_LEN`. + - The event's command is tokenized once. Six guards on the row parse the same string; the first parse is + recorded and replayed to the other five, `HOOK_SEG_*` arrays included. A parse that a guard cut short with + `exit` is not kept, and a callback's re-parse of a substring is never cached. + - hook-utils.sh: the tokenizer is linear in the command's length (see Changed). + +### 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.37.2] - 2026-09-27 ### Changed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index fffb5e2d49..f1aadcc318 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -34,7 +34,15 @@ were eight, one per Write/Edit PreToolUse where there were three, one per Write/ PostToolUse where there were three), the exit code (2 if any guard blocks, and every guard still runs so a command that trips two guards shows both reasons), and the merge of several guards' `additionalContext` into the one JSON document a hook process may -emit. The [hook budget accounting](#hook-budget-accounting) carries the measurement. +emit. It also tokenizes the event's command once and hands every guard that parses it +the same segments. One exception to "every guard still runs": on the Bash/PowerShell +row, a command longer than `MAX_COMMAND_LEN` (16384 characters, passed to the dispatcher +as `--max-command-len`) ends the chain at the first guard that blocks it. The five +guards that carry that ceiling refuse such a command unread, and the row now answers a +~70 KB command in about 64 ms instead of running past its 60-second `timeout`, where +Claude Code would let the call through unchecked (#4528). Each guard keeps its kill +switch; with one disabled, the next guard carrying the ceiling blocks. +The [hook budget accounting](#hook-budget-accounting) carries the measurement. `workflow-resilience-check` is not always-on and is registered on its own. | Guard | Event / matcher | Behavior | What it catches | @@ -162,17 +170,23 @@ out of scope until such a signal exists. sandbox. Content invariants that must hold are enforced write-path-independently by the opt-in git `pre-commit` content-invariants hook (`/guardrails:setup apply install-pre-commit-content`) or an equivalent CI check. - The block message carries a lane-specific scope note so a reader does - not credit the guard with coverage it never claimed. -- **`block-hook-bypass` isolated-session remedy.** Write or Edit may be refused - for paths in the main checkout when the agent is in an isolated session or - worktree. That is not a dead end: write under a configured - `block_hook_bypass_scratch_roots` directory, or ask the operator for a - session-scoped disable via `claude --settings`. The user-global - `block_hook_bypass_enabled` switch is last resort. It persists across every - repository where guardrails is enabled. Those levers are printed on stderr - (Claude Code surfaces an exit-2 stderr reason; `systemMessage` is an exit-0 - field and is discarded on a block). + This entry is where that scope is stated. Since **0.38.0** the block message + no longer prints it, so a blocked agent is not handed the list of unchecked + write forms; the once-per-session operator notice points here instead. +- **`block-hook-bypass` block message and operator levers.** stderr is what the + blocked agent reads, so it carries only what the agent can act on: the + verdict, the Write/Edit remedy, and a remedy for when Write or Edit is refused + too ("stop and tell the user"). On the `cat`, `echo`/`printf` and staged-move + lanes it also says why the target was not scratch-exempt and lists the roots + that exempt a bare target in this session. The PowerShell and python lanes + never consult a scratch root, so their message names none. The operator's + levers, narrowest first, are `block_hook_bypass_scratch_roots` (Bash redirect + targets only), a session-scoped disable via `claude --settings`, and the + user-global `block_hook_bypass_enabled` switch, which persists across every + repository where guardrails is enabled. They arrive once per session and agent + as a `systemMessage`, which Claude Code reads on exit 2 as on exit 0. Until a + human has confirmed that notice renders on a block, stderr also ends with a + one-line pointer to this README. - **Every hook says so when it could not run.** A hook has three outcomes, not two: allow (exit 0), block (exit 2), and could-not-run. Every registered hook and the dispatcher install the shared abort boundary @@ -197,7 +211,12 @@ out of scope until such a signal exists. freeze the session, with the dual-channel notice so the allow is not silent. Stdin timeout and a NUL payload still fail closed. The 60s `hooks.json` `timeout` on this handler is a harness-level fail-open the plugin does not - override: if the process is killed at that bound, the tool call proceeds. + override: if the process is killed at that bound, the tool call proceeds + ([hooks: Timeouts](https://code.claude.com/docs/en/hooks#timeouts)). What the + plugin does instead is keep the row far from that bound: the command + tokenizer is linear in the command's length, one parse serves every guard, + and a command over `MAX_COMMAND_LEN` is refused by the first guard that + carries the ceiling before anything tokenizes it (#4528). - **`block-hook-bypass` option parse is strict.** Only the exact strings `true` and `false` are accepted (`unset` defaults to enabled). Any other value keeps the guard enabled and names the bad value. A typo must not silently disable @@ -415,6 +434,20 @@ out of scope until such a signal exists. ### Hook budget accounting +**0.37.3, a long command (#4528).** 2026-09-27, Linux 6.12, bash 5.2.21, +en_US.UTF-8. The Bash/PowerShell row on a heredoc of prose, wall time for the +whole row: 10 KB **7.23 s -> 132 ms**, 16 KB (just under the ceiling) +**12.6 s -> 203 ms**, ~70 KB **still running at 120 s -> 64 ms**. The shared +tokenizer read the command one `${cmd:i:1}` at a time, and bash measures the +whole string on every one of those, so each parse was quadratic, and six +guards parsed the same command. It now splits the command in 4096- and 64-byte +blocks under the C locale (one parse of a 10,000-character heredoc: +1.27 s -> 84 ms), one parse is +replayed to the other five, and a command over `MAX_COMMAND_LEN` ends the chain +at `block-no-verify`, the first guard, whose ceiling refuses it unread. No +reported segment changed: the parse is byte-identical to the old one under +en_US.UTF-8, C.UTF-8 and C, and every guard suite passes on both paths. + **0.34.0, the in-process guard chain.** 2026-09-15, Windows 11 + Git Bash (`usr\bin\bash.exe` as the hook shell), host idle. Process creations counted exactly with a Windows job object around the harness's own invocation diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 0682c7f128..f1f84f5759 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -804,18 +804,29 @@ _scratch_abs_target() { esac } +# Why the most recent scratch_target_exempt call refused its operand, read only +# by block_bypass: a code, empty after a grant. Reset on every call, so a block +# always reads the refusal of the target it blocked on, never an earlier lane's. +# Codes, in the order the function tests them, which is their precedence: +# opaque, quoted, no-root, relative, unnormalized, then the root compare's +# resolves-outside, plugin-data-unconfirmed and not-under-any-root. +# temp-default-off is not set here: it is a not-under-any-root refusal of a temp +# target, and block_bypass derives it, so a refusal that does not block (a +# staged-move destination with no staged source) pays no temp-root probe. +# _BBH_SCRATCH_REFUSED_AT holds the normalized target that derivation reads. +_BBH_SCRATCH_REFUSAL="" +_BBH_SCRATCH_REFUSED_AT="" + # 0 when the target <$1> lies strictly under a configured scratch root or a # shipped default. $2 is 1 when quoting or an escape produced that text, $3 when # the parse could not resolve it at all. Called only after a segment has already # matched a producer + real-file redirect, so it adds no work to the per-call hot -# path, and it returns on the first line when no root is configured. +# path. Sets _BBH_SCRATCH_REFUSAL on every refusal. scratch_target_exempt() { local target="$1" tgt_quoted="$2" tgt_opaque="$3" norm_target root roots abs - # Nothing to compare against: no configured root AND no usable project root, - # which is the only state in which the shipped default cannot fire either. - # Keeps the unconfigured, project-less path returning on the first line as it - # always did. - [[ -n "$_SCRATCH_ROOTS" || -n "$_BBH_PROJECT_NORM" ]] || return 1 + local lexical_miss="" + _BBH_SCRATCH_REFUSAL="" + _BBH_SCRATCH_REFUSED_AT="" # FAIL CLOSED on an operand whose pathname is not dependably what reaches the # compare, before anything else. All three tests are keyed on the OPERAND, from # the provenance the shared parse carries with it — not on the raw command. @@ -844,19 +855,43 @@ scratch_target_exempt() { # exemption where it was refused, and both land only on a target the parse # proves was bare — `echo x > /tmp/scratch/f && grep foo "notes.txt"` and # `echo "a > b" > /tmp/scratch/f` are exempt again. - ((tgt_opaque)) && return 1 - ((tgt_quoted)) && return 1 - [[ "$target" == *\\* ]] && return 1 + if ((tgt_opaque)); then + _BBH_SCRATCH_REFUSAL=opaque + return 1 + fi + if ((tgt_quoted)) || [[ "$target" == *\\* ]]; then + _BBH_SCRATCH_REFUSAL=quoted + return 1 + fi + # Nothing to compare against: no configured root AND no usable project root, + # which is the only state in which the shipped default cannot fire either. + if [[ -z "$_SCRATCH_ROOTS" && -z "$_BBH_PROJECT_NORM" ]]; then + _BBH_SCRATCH_REFUSAL="no-root" + return 1 + fi # Place the target absolutely before normalizing. Until #3719 this axis refused # every relative target outright; it now resolves one against the payload cwd # when — and only when — that cwd is the directory the redirect demonstrably # runs in. _scratch_abs_target owns that judgment and still refuses everything # it cannot place, so the fail-closed set only ever shrinks by targets proven # placeable. - abs=$(_scratch_abs_target "$target") || return 1 - [[ -n "$abs" ]] || return 1 - _norm_path "$abs" || return 1 - [[ -n "$_NORM_PATH" ]] || return 1 + if ! abs=$(_scratch_abs_target "$target"); then + # `> $f` is relative-shaped, but its reason is the `$`: placing it would not + # have exempted it. + case "$target" in + *'$'* | *'`'* | *'~'* | *'*'* | *'?'* | *'['*) _BBH_SCRATCH_REFUSAL=unnormalized ;; + *) _BBH_SCRATCH_REFUSAL=relative ;; + esac + return 1 + fi + if [[ -z "$abs" ]]; then + _BBH_SCRATCH_REFUSAL=opaque + return 1 + fi + if ! _norm_path "$abs" || [[ -z "$_NORM_PATH" ]]; then + _BBH_SCRATCH_REFUSAL=unnormalized + return 1 + fi # Case-folded once here rather than at each comparison: the configured roots are # lowercased below, the memory-tier default is lowercased at its assignment, and # a target that arrived absolute came off the lowercased command stream already. @@ -872,6 +907,7 @@ scratch_target_exempt() { if [[ -n "$_BBH_PROJECT_NORM" ]] && _bbh_temp_default_applies && hook::under_temp_root "$norm_target"; then _bbh_default_confirmed "$norm_target" && return 0 + lexical_miss="resolves-outside" fi # THE SECOND SHIPPED DEFAULT — the plugin data directory, once this session is # one it applies to. Lexically matched first, then confirmed through symlink @@ -881,6 +917,7 @@ scratch_target_exempt() { if [[ -n "$_BBH_PROJECT_NORM" ]] && _bbh_plugin_data_default_applies && [[ "$norm_target" == "$_BBH_PLUGIN_DATA_NORM"/* ]]; then _bbh_plugin_data_confirmed "$norm_target" && return 0 + [[ -n "$lexical_miss" ]] || lexical_miss="plugin-data-unconfirmed" fi roots="$_SCRATCH_ROOTS" while [[ -n "$roots" ]]; do @@ -896,6 +933,8 @@ scratch_target_exempt() { # is what makes this a component compare and not a string prefix. [[ "$norm_target" == "$_NORM_PATH"/* ]] && return 0 done + _BBH_SCRATCH_REFUSAL="${lexical_miss:-not-under-any-root}" + _BBH_SCRATCH_REFUSED_AT="$norm_target" return 1 } @@ -1152,55 +1191,93 @@ py_inline_invocation() { return 1 } -# The scope this guard actually has, stated where a reader meets it. Without it -# the block reads as "shell file writes are blocked" and is over-trusted in both -# directions: an agent contorts around a restriction a script file does not -# have, and a human credits the guard with coverage it never claimed. The guard -# is a speed bump against specific accidental write-workaround forms in one -# command string, not a boundary — and it is deliberately producer-scoped, so -# ordinary data-processing redirects (`sort f > out`, `curl … > page.html`) and -# other unmodeled Bash write utilities (POSIX `tee`, inline `node -e`, …) are -# allowed by design too, not only writes inside an invoked script. -# These notes state the ENFORCED surface to the operator, so they are part of the -# detector's contract, not commentary: understating it invites the "guard says it -# cannot see this" contortion the paragraph above describes, and overstating it is -# the false-assurance failure. #2217 widened the python lane from the literal -# `python3 -c` to the interpreter family plus a stdin heredoc, and left both notes -# saying `inline python3 -c only` — materially wrong about a safety guard's own -# reach. Restated at the shipped width, with the residuals named at theirs. -_BYPASS_SCOPE_NOTE_BASH="Scope: only this command string is inspected — known shell \ -file-write forms plus inline python code (python/python3/py/pypy with -c, or a \ -program read from stdin as python3 - <&2 echo "Use the Write or Edit tool instead of a shell file-write workaround." >&2 - echo "If Write or Edit is refused for a path in the main checkout (isolated session / worktree), write under a directory listed in block_hook_bypass_scratch_roots, or ask the operator for a session-scoped disable via claude --settings. The user-global block_hook_bypass_enabled switch is last resort — it persists across every repository." >&2 - echo "$operator_msg" >&2 - if [[ "$TOOL_NAME" == "PowerShell" ]]; then - echo "$_BYPASS_SCOPE_NOTE_PWSH" >&2 - else - echo "$_BYPASS_SCOPE_NOTE_BASH" >&2 + case "$form" in + cat-redirect | echo-redirect | staged-write-move) + [[ "$form" == staged-write-move ]] && noun="move destination" + code="$_BBH_SCRATCH_REFUSAL" + if [[ "$code" == not-under-any-root && -n "$_BBH_PROJECT_NORM" ]] && + ! _bbh_temp_default_applies && hook::under_temp_root "$_BBH_SCRATCH_REFUSED_AT"; then + code="temp-default-off" + fi + _bbh_refusal_line "$code" "$noun" + [[ -n "$_BBH_REFUSAL_LINE" ]] && echo "$_BBH_REFUSAL_LINE" >&2 + _bbh_exempt_roots + [[ -n "$_BBH_EXEMPT_ROOTS" ]] && + echo "A bare $noun under these roots is exempt: $_BBH_EXEMPT_ROOTS." >&2 + echo "If Write or Edit is refused for this path, stop and tell the user; the operator can add a root with block_hook_bypass_scratch_roots." >&2 + ;; + *) + echo "If Write or Edit is refused for this path, stop and tell the user; this guard's switches are operator-only." >&2 + ;; + esac + echo "Operator levers for this guard: the guardrails README, block-hook-bypass." >&2 + if hook::notice_once "guardrails-block-hook-bypass-levers" "$INPUT" && + [[ "$HOOK_NOTICE_KIND" == full ]]; then + hook::emit_channels PreToolUse "" "guardrails block-hook-bypass blocked a shell file-write. Its levers, narrowest first: (1) block_hook_bypass_scratch_roots, a target-scoped exemption for Bash redirect targets; (2) a session-scoped disable via claude --settings; (3) the user-global block_hook_bypass_enabled switch via /plugin configure, which persists in every repository where guardrails is enabled, so re-enable it once the bypass is no longer needed. This guard is a deterrent over one command string, not a sandbox; the guardrails README lists what it does not inspect." fi - hook::emit_channels PreToolUse "" "$operator_msg" emit_tel "blocked" "$form" exit 2 } diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 114de64099..59fba89621 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -1333,73 +1333,214 @@ run_pwsh "PS: module-qualified Write-Output > file (blocked)" \ run_pwsh "PS: module-qualified Write-Error 2> file (blocked)" \ "Microsoft.PowerShell.Utility\\Write-Error secret 2> f.txt" 2 # portability-ok: PowerShell module-qualified command string in a test fixture, not a regex/sed construct -# The block message is shell-agnostic (no 'Bash' assumption). -psout=$(bash "$HOOK" <<<"$(pwsh_command_json "Set-Content f.txt 'x'")" 2>&1) -assert_contains "PS write block names Write/Edit" "$psout" "Write or Edit tool" -assert_absent "PS write block message is shell-agnostic" "$psout" "Bash file-write" -assert_contains "PS write block names kill switch" "$psout" "block_hook_bypass_enabled" -assert_contains "PS write block names isolated Write/Edit refusal" "$psout" "main checkout" -assert_contains "PS write block names scratch_roots remedy" "$psout" "block_hook_bypass_scratch_roots" -assert_contains "PS write block names session --settings" "$psout" "--settings" -assert_contains "PS write block marks kill switch operator-only" "$psout" "not actionable by the blocked agent" -assert_contains "PS write block warns kill switch is user-scoped" "$psout" "user-scoped" -assert_contains "PS write block warns kill switch persists across repositories" "$psout" "every repository" -assert_contains "PS write block tells operator to re-enable kill switch" "$psout" "Re-enable it" - -# --- Enforcement-scope disclosure ------------------------------------------- -# The message asserted "use Write or Edit instead" with no scope, so it read as -# "shell file writes are blocked" when the guard is deliberately producer-scoped -# over one command string. Both lanes must carry the scope, and the behavior -# the scope describes is pinned below it so message and reality move together. -scopeout=$(bash "$HOOK" <<<"$(command_json "printf 'x' > out.log")" 2>&1) -assert_contains "bash block names kill switch" "$scopeout" "block_hook_bypass_enabled" -assert_contains "bash block names isolated Write/Edit refusal" "$scopeout" "main checkout" -assert_contains "bash block names scratch_roots remedy" "$scopeout" "block_hook_bypass_scratch_roots" -assert_contains "bash block names session --settings" "$scopeout" "--settings" -assert_contains "bash block marks kill switch operator-only" "$scopeout" \ - "not actionable by the blocked agent" -assert_contains "bash block warns kill switch is user-scoped" "$scopeout" "user-scoped" -assert_contains "bash block warns kill switch persists across repositories" "$scopeout" \ - "every repository" -assert_contains "bash block tells operator to re-enable kill switch" "$scopeout" "Re-enable it" -# Exit-2 hosts discard systemMessage; the operator levers must survive on stderr. -scope_err="$TEST_TMPDIR/block-stderr.txt" -bash "$HOOK" <<<"$(command_json "printf 'x' > out.log")" >/dev/null 2>"$scope_err" || true -scope_err_txt=$(cat "$scope_err") -assert_contains "stderr carries isolated Write/Edit refusal" "$scope_err_txt" "main checkout" -assert_contains "stderr carries scratch_roots remedy" "$scope_err_txt" "block_hook_bypass_scratch_roots" -assert_contains "stderr carries session --settings" "$scope_err_txt" "--settings" -assert_contains "stderr marks kill switch operator-only" "$scope_err_txt" \ - "not actionable by the blocked agent" -assert_contains "stderr warns kill switch is user-scoped" "$scope_err_txt" "user-scoped" -assert_contains "stderr tells operator to re-enable kill switch" "$scope_err_txt" "Re-enable it" -assert_contains "bash block states its scope" "$scopeout" \ - "only this command string is inspected" -assert_contains "bash block names the invoked-script gap" "$scopeout" \ - "inside an invoked script file" -# The note states the ENFORCED surface, so it is pinned at the shipped width, not -# at a remembered one. #2217 widened the python lane past the literal `python3 -c` -# and this assertion went on pinning the obsolete claim — the assertion is what -# should have caught the drift, so it now names both the family and the stdin form. -assert_contains "bash block names the interpreter family it covers" "$scopeout" \ - "python/python3/py/pypy with -c" -assert_contains "bash block names the stdin form it covers" "$scopeout" \ - "python3 - <&1) -assert_contains "powershell block states its scope" "$psscope" \ - "only this command string is inspected" -assert_contains "powershell block names Tee-Object coverage" "$psscope" "Tee-Object" -assert_contains "powershell block names the interpreter family it covers" "$psscope" \ - "python/python3/py/pypy with -c" - -# The behavior the scope note describes. A write inside an invoked script is -# not inspected, and a redirect whose producer is another program is allowed by -# the producer-scoped design — so the note must not promise either is blocked. +# --- The block message (#4679) ---------------------------------------------- +# stderr is the model channel on exit 2, so it carries what the blocked agent can +# act on and nothing else. Every assertion here reads stderr ALONE (GUARD_ERR): +# a 2>&1 capture also catches the stdout systemMessage and would pass on text +# the agent never receives. +MSG_USE="Use the Write or Edit tool instead of a shell file-write workaround." +MSG_REMEDY_SCRATCH="If Write or Edit is refused for this path, stop and tell the user; the operator can add a root with block_hook_bypass_scratch_roots." +MSG_REMEDY_SWITCHES="If Write or Edit is refused for this path, stop and tell the user; this guard's switches are operator-only." +MSG_POINTER="Operator levers for this guard: the guardrails README, block-hook-bypass." +MSG_PROJ=/srv/repo +MSG_CFG=/srv/cfg +# Pinned so the plugin data default has one spelling, and no data dir, so the +# once-per-session latch fails open and every block here emits its notice. +MSG_ENV=(-u CLAUDE_PLUGIN_DATA -u CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_SCRATCH_ROOTS + CLAUDE_PROJECT_DIR= CLAUDE_CONFIG_DIR="$MSG_CFG") + +# PowerShell and python lanes: never a scratch root, so no reason line, no root +# list, and a remedy that does not name block_hook_bypass_scratch_roots. +guard_invoke --tool PowerShell --command "Set-Content f.txt 'x'" -- "${MSG_ENV[@]}" +assert_exit "message: PowerShell write blocks" 2 "$GUARD_RC" +assert_eq "message: PowerShell write stderr is verdict, remedy, pointer" \ + "BLOCKED: PowerShell file-write cmdlet/redirect bypasses Write/Edit hooks +$MSG_USE +$MSG_REMEDY_SWITCHES +$MSG_POINTER" "$GUARD_ERR" +guard_invoke --tool PowerShell --command "python3 -c \"open('x','w').write('a')\"" \ + -- "${MSG_ENV[@]}" "CLAUDE_PROJECT_DIR=$MSG_PROJ" +assert_eq "message: PowerShell python write stderr is verdict, remedy, pointer" \ + "BLOCKED: python inline-code file write bypasses Write/Edit hooks +$MSG_USE +$MSG_REMEDY_SWITCHES +$MSG_POINTER" "$GUARD_ERR" +# The live report: a stdin heredoc on the Bash tool printed the scratch-root +# advice and the whole Bash scope note. +MSG_PY_HEREDOC=$(printf 'python3 - <<\x27EOF\x27\nopen("/tmp/claude-0/x/scratchpad/f","w").write("a")\nEOF') +guard_invoke --command "$MSG_PY_HEREDOC" -- "${MSG_ENV[@]}" "CLAUDE_PROJECT_DIR=$MSG_PROJ" +assert_exit "message: Bash python heredoc write blocks" 2 "$GUARD_RC" +assert_eq "message: Bash python heredoc stderr is verdict, remedy, pointer" \ + "BLOCKED: python inline-code file write bypasses Write/Edit hooks +$MSG_USE +$MSG_REMEDY_SWITCHES +$MSG_POINTER" "$GUARD_ERR" +assert_absent "message: python lane never names the scratch-roots option" \ + "$GUARD_ERR" "block_hook_bypass_scratch_roots" + +# Bash echo lane, quoted scratchpad target, project outside the temp tree: the +# quoted reason, then the roots that WOULD have exempted a bare target. +guard_invoke --command "echo x > \"/tmp/claude-0/-srv-repo/abc/scratchpad/probe.txt\"" \ + -- "${MSG_ENV[@]}" "CLAUDE_PROJECT_DIR=$MSG_PROJ" +assert_exit "message: quoted scratchpad target blocks" 2 "$GUARD_RC" +assert_eq "message: quoted scratchpad stderr is exactly lines 1 to 5 and the pointer" \ + "BLOCKED: echo/printf > file write bypasses Write/Edit hooks +$MSG_USE +A quoted or escaped target is never scratch-exempt. +A bare target under these roots is exempt: $MSG_CFG/plugins/data, the OS temp directory. +$MSG_REMEDY_SCRATCH +$MSG_POINTER" "$GUARD_ERR" +assert_contains "message: the latch-less block still emits the operator notice" \ + "$GUARD_OUT" '"systemMessage"' + +# One reason line per refusal code.