diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 020eea8670..6b461c7a92 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -2269,16 +2269,16 @@ rm -f "$bs_payload_file" "$bs_rc_file" "$bs_out_file" # above follow: an override may lie about a VERDICT, never skip the WORK. Here it # does not even lie; it only writes a line before returning what jq said. # -# Bash's dynamic scoping puts hook::buffer_stdin's own locals in scope for the -# override, which is what lets it record WHICH check called it. `chunk` is empty -# only at the empty-slice check (hook-utils.sh:917) and non-empty at the -# with-bytes one (:898), and `idle_slices` is how many idle slices had already -# been spent when the verdict landed. The pass shape is therefore exactly -# `idle=0 chunklen=0` — complete on the first idle slice, not after the bound. +# Bash's dynamic scoping puts hook::buffer_stdin_to's own locals in scope for the +# override, which is what lets it record WHICH check called it. `__hu_chunk` is +# empty only at the empty-slice check and non-empty at the with-bytes one, and +# `__hu_idle_slices` is how many idle slices had already been spent when the +# verdict landed. The pass shape is therefore exactly `idle=0 chunklen=0` — +# complete on the first idle slice, not after the bound. # # What each mutation does to that log: deleting the block leaves it EMPTY, since -# the trailing probe at hook-utils.sh:940 is an inline `printf | jq` and not this -# function, so the case fails. Moving the check later — an `idle_slices >= 3` +# the trailing probe is an inline `printf | jq` and not this +# function, so the case fails. Moving the check later — an `__hu_idle_slices >= 3` # guard, say — logs a non-zero idle count, so that fails too. Only SUCCESSFUL # verdicts are logged, the partial buffers probed on the way in returning # non-zero and writing nothing, which is what makes the last line the call that @@ -2312,7 +2312,7 @@ bs_probe_override='hook::json_complete() { local verdict=0 printf "%s" "$1" | jq -e . >/dev/null 2>&1 || verdict=$? if ((verdict == 0)); then - printf "idle=%s chunklen=%s\n" "${idle_slices-?}" "${#chunk}" >>"'"$bs_probe_file"'" + printf "idle=%s chunklen=%s\n" "${__hu_idle_slices-?}" "${#__hu_chunk}" >>"'"$bs_probe_file"'" fi return "$verdict" }' @@ -3220,6 +3220,82 @@ eval "$(declare -f __pin_timeout_print | sed '1s/^__pin_timeout_print/hook::reso eval "$(declare -f __pin_slice_print | sed '1s/^__pin_slice_print/hook::resolve_read_slice/')" rm -rf "$pin_dir" +# --- buffer_stdin_to writes in-process; fused filters are one jq ------------- +# Drive _to WITHOUT $( ): a command substitution is a subshell, so dest would +# be set only there and this case would "prove" the opposite of the helper. +bs_to="" +bs_to_file="$(mktemp)" +hook::buffer_stdin_to bs_to >"$bs_to_file" <<'EOF' +{"tool_name":"Bash","tool_input":{"command":"git status --short"}} +EOF +bs_to_rc=$? +if ((bs_to_rc == 0)) && [[ ! -s "$bs_to_file" ]] && [[ "$bs_to" == *$'"tool_name":"Bash"'* ]]; then + ok "buffer_stdin_to: writes dest, prints nothing" +else + fail "buffer_stdin_to dest: rc=$bs_to_rc outlen=$(wc -c <"$bs_to_file") dest=$(printf %q "$bs_to")" +fi +rm -f "$bs_to_file" + +bs_print_file="$(mktemp)" +bs_to_dump="$(mktemp)" +hook::buffer_stdin >"$bs_print_file" <<'EOF' +{"tool_name":"Bash","tool_input":{"command":"git status --short"}} +EOF +printf '%s' "$bs_to" >"$bs_to_dump" +# cmp, not $(cat): command substitution strips trailing newlines and would +# hide a dest-vs-stdout mismatch that is exactly the $() tax _to removes. +if cmp -s "$bs_print_file" "$bs_to_dump"; then + ok "buffer_stdin print form matches buffer_stdin_to dest" +else + fail "buffer_stdin print/to mismatch" +fi +rm -f "$bs_print_file" "$bs_to_dump" + +HOOK_JQ_FIELDS=() +HOOK_JQ_FIELDS_NUL=1 +bs_fused="" +hook::buffer_stdin_to bs_fused '.tool_input.command' '.tool_name' <<'EOF' +{"tool_name":"Bash","tool_input":{"command":"git status --short"}} +EOF +bs_fused_rc=$? +if ((bs_fused_rc == 0)) && [[ "$bs_fused" == "$bs_to" ]] && + [[ "${HOOK_JQ_FIELDS[0]}" == "git status --short" && "${HOOK_JQ_FIELDS[1]}" == "Bash" && "$HOOK_JQ_FIELDS_NUL" == "0" ]]; then + ok "buffer_stdin_to fused filters populate HOOK_JQ_FIELDS" +else + fail "buffer_stdin_to fused: rc=$bs_fused_rc dest=$(printf %q "$bs_fused") fields=(${HOOK_JQ_FIELDS[*]-}) nul=$HOOK_JQ_FIELDS_NUL" +fi + +bs_bad="" +bs_bad_err=$(hook::buffer_stdin_to bs_bad '.tool_name' <<<'{"incomplete":' 2>&1) +bs_bad_rc=$? +if ((bs_bad_rc == 2)) && [[ "$bs_bad_err" == *"not valid JSON"* ]]; then + ok "buffer_stdin_to fused malformed JSON fails closed" +else + fail "buffer_stdin_to fused malformed: rc=$bs_bad_rc err=$(printf %q "$bs_bad_err")" +fi + +# Dest names that match this helper's internals must still receive the payload. +# An unprefixed local would make printf -v write this frame and return 0 while +# the caller kept the sentinel (the `_to` helper convention). +input="sentinel" +read_timeout="sentinel" +fields_rc="sentinel" +hook::buffer_stdin_to input <<'EOF' +{"tool_name":"Bash","tool_input":{"command":"git status --short"}} +EOF +input_rc=$? +hook::buffer_stdin_to read_timeout <<'EOF' +{"tool_name":"Bash","tool_input":{"command":"git status --short"}} +EOF +hook::buffer_stdin_to fields_rc '.tool_input.command' '.tool_name' <<'EOF' +{"tool_name":"Bash","tool_input":{"command":"git status --short"}} +EOF +if ((input_rc == 0)) && [[ "$input" == "$bs_to" && "$read_timeout" == "$bs_to" && "$fields_rc" == "$bs_to" ]]; then + ok "buffer_stdin_to: dest names matching internals still receive the payload" +else + fail "buffer_stdin_to dest-collision: input=$(printf %q "$input") read_timeout=$(printf %q "$read_timeout") fields_rc=$(printf %q "$fields_rc")" +fi + # --- hook::json_str_object_to matches jq -nc --arg ... ----------------------- jso_want=$(jq -nc --arg tool Bash --arg subject "git status --short" --arg form "" \ '{tool:$tool,subject:$subject,form:$form}') diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index b4b3c2a3a7..b9404d0d34 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.38", + "version": "0.8.39", "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 9487a6b1c2..764845354d 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `actionlint` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.39] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.8.38] ### Changed diff --git a/plugins/actionlint/hooks/actionlint-check.sh b/plugins/actionlint/hooks/actionlint-check.sh index fdb2a34154..00c9339989 100755 --- a/plugins/actionlint/hooks/actionlint-check.sh +++ b/plugins/actionlint/hooks/actionlint-check.sh @@ -48,7 +48,7 @@ emit_tel() { hook::emit_telemetry "actionlint-check" "PostToolUse" "$1" "$start" "$(build_data_json "$2")" "$REPO_ROOT" } -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq-free applicability pre-filter: never emit the jq notice for an edit this # hook would not lint anyway (the Write|Edit matcher is broader than the diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 06d75c129e..7d4838f469 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.22.29", + "version": "0.22.30", "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 ad08037ec4..7d543ab20e 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -3,6 +3,19 @@ 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.22.30] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.22.29] ### Changed diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/autonomy/hooks/lane-stop-gate.sh b/plugins/autonomy/hooks/lane-stop-gate.sh index 19cd8cc2e3..e0e4d8bc08 100755 --- a/plugins/autonomy/hooks/lane-stop-gate.sh +++ b/plugins/autonomy/hooks/lane-stop-gate.sh @@ -147,7 +147,7 @@ gate_maybe_configured || exit 0 # Buffer stdin. Empty (rc 1) or timed-out (rc 2) → allow the stop (fail-open: a # gate that cannot read the payload must not trap the lane). -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq parses the payload and the trusted config. Absent → visible once-per-session # notice, then allow the stop (fail-open). Stop supports additionalContext, so diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index 7e5e27899e..e9b059305a 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.39", + "version": "0.7.40", "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 e6764a3bf8..d2fc4dcac3 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `bash-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.40] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.7.39] ### Changed diff --git a/plugins/bash-format/hooks/bash-format.sh b/plugins/bash-format/hooks/bash-format.sh index 5bba170092..d75d877a09 100755 --- a/plugins/bash-format/hooks/bash-format.sh +++ b/plugins/bash-format/hooks/bash-format.sh @@ -53,7 +53,7 @@ emit_tel() { hook::emit_telemetry "bash-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2")" "$REPO_ROOT" } -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq-free applicability pre-filter: never emit the jq notice for an edit this # hook would not process anyway (the Write|Edit matcher is broader than the diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index 80b0284b48..4dc7aa6582 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.37", + "version": "0.6.38", "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 3a1bdca6fe..68cec8a314 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `biome-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.38] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.6.37] ### Changed diff --git a/plugins/biome-format/hooks/biome-format.sh b/plugins/biome-format/hooks/biome-format.sh index 577098db02..fd2adf99cd 100755 --- a/plugins/biome-format/hooks/biome-format.sh +++ b/plugins/biome-format/hooks/biome-format.sh @@ -56,7 +56,7 @@ emit_tel() { hook::emit_telemetry "biome-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2")" "$REPO_ROOT" } -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq-free applicability pre-filter: never emit the jq notice for an edit this # hook would not process anyway (the Write|Edit matcher is broader than the diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index dd545c76d4..4b3f026f4a 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.42.20", + "version": "0.42.21", "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 \u2014 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 \u2014 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 \u2014 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-labelled 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 \u2014 built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills \u2014 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 \u2014 OTEL store, collector, the per-session hook event log and hook-event JSONL, ccusage \u2014 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 \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry, 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 \u2014 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 39b8e8a627..d1c344ebae 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.42.21] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.42.20] ### Changed diff --git a/plugins/claude-ops/hooks/api-error-audit.sh b/plugins/claude-ops/hooks/api-error-audit.sh index 3ccd57966b..da39ef8ffa 100755 --- a/plugins/claude-ops/hooks/api-error-audit.sh +++ b/plugins/claude-ops/hooks/api-error-audit.sh @@ -26,7 +26,7 @@ hook::telemetry_enabled || exit 0 START=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # data.session_id (additive, hook-telemetry rule 1): the sink routes an # envelope carrying one into the per-session log beside session-event-log.sh. diff --git a/plugins/claude-ops/hooks/config-change-audit.sh b/plugins/claude-ops/hooks/config-change-audit.sh index 89d4eb468a..020f203ac9 100755 --- a/plugins/claude-ops/hooks/config-change-audit.sh +++ b/plugins/claude-ops/hooks/config-change-audit.sh @@ -25,7 +25,7 @@ hook::telemetry_enabled || exit 0 START=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # data.session_id (additive, hook-telemetry rule 1): the sink routes an # envelope carrying one into the per-session log beside session-event-log.sh. diff --git a/plugins/claude-ops/hooks/hook-failure-audit.sh b/plugins/claude-ops/hooks/hook-failure-audit.sh index 1be09c67fb..8c24ad2931 100755 --- a/plugins/claude-ops/hooks/hook-failure-audit.sh +++ b/plugins/claude-ops/hooks/hook-failure-audit.sh @@ -63,7 +63,7 @@ hook::check_enabled "HOOK_FAILURE_AUDIT" START=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # Advisory finding -> fail open, with the standard once-per-session notice. hook::require_jq Stop claude-ops "$INPUT" diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/claude-ops/hooks/instructions-loaded-audit.sh b/plugins/claude-ops/hooks/instructions-loaded-audit.sh index b4c21e9afe..0b12a9283a 100755 --- a/plugins/claude-ops/hooks/instructions-loaded-audit.sh +++ b/plugins/claude-ops/hooks/instructions-loaded-audit.sh @@ -35,7 +35,7 @@ hook::telemetry_enabled || exit 0 START=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # data.session_id (additive, hook-telemetry rule 1): the sink routes an # envelope carrying one into the per-session log beside session-event-log.sh. diff --git a/plugins/claude-ops/hooks/permission-denied-audit.sh b/plugins/claude-ops/hooks/permission-denied-audit.sh index dc1d68d60e..3d7e72043b 100755 --- a/plugins/claude-ops/hooks/permission-denied-audit.sh +++ b/plugins/claude-ops/hooks/permission-denied-audit.sh @@ -30,7 +30,7 @@ hook::telemetry_enabled || exit 0 START=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # data.session_id (additive, hook-telemetry rule 1): the sink routes an # envelope carrying one into the per-session log beside session-event-log.sh. diff --git a/plugins/claude-ops/hooks/pre-compact-audit.sh b/plugins/claude-ops/hooks/pre-compact-audit.sh index c2ffbceaed..a9c0f3e3a2 100755 --- a/plugins/claude-ops/hooks/pre-compact-audit.sh +++ b/plugins/claude-ops/hooks/pre-compact-audit.sh @@ -25,7 +25,7 @@ hook::telemetry_enabled || exit 0 START=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # data.session_id (additive, hook-telemetry rule 1): the sink routes an # envelope carrying one into the per-session log beside session-event-log.sh. diff --git a/plugins/claude-ops/hooks/skill-usage-audit.sh b/plugins/claude-ops/hooks/skill-usage-audit.sh index 14a3df1096..cc3414f084 100755 --- a/plugins/claude-ops/hooks/skill-usage-audit.sh +++ b/plugins/claude-ops/hooks/skill-usage-audit.sh @@ -35,7 +35,7 @@ source "$HOOK_DIR/hook-utils.sh" source "$HOOK_DIR/claude-ops-paths.sh" START=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # data.session_id (additive, hook-telemetry rule 1): the sink routes an # envelope carrying one into the per-session log beside session-event-log.sh. diff --git a/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh b/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh index a6e91c494d..23b449f6f8 100755 --- a/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh +++ b/plugins/claude-ops/hooks/skill-usage-expansion-audit.sh @@ -45,7 +45,7 @@ hook::check_enabled "SKILL_USAGE_AUDIT" START=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # data.session_id (additive, hook-telemetry rule 1): the sink routes an # envelope carrying one into the per-session log beside session-event-log.sh. diff --git a/plugins/claude-ops/hooks/tool-failure-audit.sh b/plugins/claude-ops/hooks/tool-failure-audit.sh index d98cbe19f7..24c6a3e65d 100755 --- a/plugins/claude-ops/hooks/tool-failure-audit.sh +++ b/plugins/claude-ops/hooks/tool-failure-audit.sh @@ -27,7 +27,7 @@ hook::telemetry_enabled || exit 0 START=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # data.session_id (additive, hook-telemetry rule 1): the sink routes an # envelope carrying one into the per-session log beside session-event-log.sh. diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index 7a3e579cd3..18e77b6ec3 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.45", + "version": "0.7.46", "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 \u2014 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 5da6a6a254..84dd925c3b 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,16 @@ 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.46] + +### Changed + +- **Synced `hooks/hook-utils.sh`.** `hook::buffer_stdin_to` captures the + hook payload in-process (no command-substitution subshell) and can fuse + the JSON completeness check with field extraction so a caller that was + about to run `jq` twice spends one process. This plugin's own hook + behavior is unchanged. + ## [0.7.45] ### Changed diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index 9635f9ecee..5cae5a89f4 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.32", + "version": "0.6.33", "description": "Alert you when Claude Code needs input \u2014 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 6ba05d0e96..68c991f943 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `desktop-notification` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.33] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.6.32] ### Changed diff --git a/plugins/desktop-notification/hooks/desktop-notification.sh b/plugins/desktop-notification/hooks/desktop-notification.sh index c668695a90..761c097f8e 100755 --- a/plugins/desktop-notification/hooks/desktop-notification.sh +++ b/plugins/desktop-notification/hooks/desktop-notification.sh @@ -49,7 +49,7 @@ start=${EPOCHREALTIME:-} # hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read. stdin is # read ONCE here and both fields are parsed from the buffered value; reading fd0 # twice would drain the pipe. -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq is load-bearing for parsing and for the terminalSequence emission; absent → # visible once-per-session notice instead of silently dropping every diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index 835b74147c..393c316c2e 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.37", + "version": "0.6.38", "description": "Normalize a written file's working-tree line endings to its .gitattributes eol value on edit \u2014 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 bc3e7c7320..6235958594 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.38] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.6.37] ### Changed diff --git a/plugins/eol-normalizer/hooks/eol-normalizer.sh b/plugins/eol-normalizer/hooks/eol-normalizer.sh index 639d107d1b..55262e817a 100755 --- a/plugins/eol-normalizer/hooks/eol-normalizer.sh +++ b/plugins/eol-normalizer/hooks/eol-normalizer.sh @@ -64,7 +64,7 @@ emit_tel() { # shellcheck source=normalize-eol.sh source "$HOOK_DIR/normalize-eol.sh" -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq is load-bearing for input parsing; absent → visible once-per-session skip # notice instead of silently disabling the whole hook (dim-9 doctrine). diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index e9c08c7a27..0ec361bfc1 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.41", + "version": "0.3.42", "description": "Auto-fix Go formatting and import management on edit via goimports \u2014 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 82342f2002..1cbc4e76c0 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `go-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.42] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.3.41] ### Changed diff --git a/plugins/go-format/hooks/go-format.sh b/plugins/go-format/hooks/go-format.sh index 5cd5d11926..cfa7a7d0c3 100755 --- a/plugins/go-format/hooks/go-format.sh +++ b/plugins/go-format/hooks/go-format.sh @@ -71,7 +71,7 @@ emit_tel() { hook::emit_telemetry "go-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2")" "$REPO_ROOT" } -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq-free applicability pre-filter: never emit the jq notice for an edit this # hook would not process anyway (the Write|Edit matcher is broader than the diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index bf24b3c539..fe4c7a24e0 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.32.10" + "version": "0.32.11" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 964910b189..fd17d0a274 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,28 @@ 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.32.11] + +### Changed + +- **The always-on Bash dispatcher spends one `jq` process, not two, and + captures stdin in-process.** `hook::buffer_stdin_to` writes the payload + into a caller variable with `printf -v` so `INPUT=$(hook::buffer_stdin)` + is no longer a command-substitution subshell (GNU Bash forks even when + the body is only builtins: Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). Passing the dispatcher's + prime filters fuses the JSON completeness check with field extraction: + the remaining two PATH-visible execs on a benign `git status --short` + were `jq -e .` plus `jq_fields`; they are now one `jq`. Isolation + `$(source …)` forks are unchanged. Spawn census through a stable PATH + shim (`plugins/performance/scripts/spawn-census.sh`), `HOOK_TELEMETRY_SINK` + unset, this repository as cwd: `git status --short` **2 → 1** (`2 jq` → + `1 jq`); `echo hello` **2 → 1**. Guards and formatter hooks that used + the print form now call `_to` so the capture fork is gone there too. + `_to` locals (library and the dispatcher's override) use a `__hu_` / + `__rg_` prefix so a caller dest named `input` or `dest` still receives + the payload. + ## [0.32.10] ### Changed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 94128c23c4..8109023491 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -379,6 +379,32 @@ out of scope until such a signal exists. ### Hook budget accounting +**0.32.11, fused stdin completeness and field extract.** 2026-09-06, +Linux CI host. The 0.32.10 table still carries two PATH-visible `jq` +execs on a benign Bash call: `jq -e .` (stdin JSON-complete check) plus +the dispatcher's primed `jq_fields`. After: `hook::buffer_stdin_to` +captures the payload in-process (`printf -v`, no command-substitution +subshell) and, when given the prime filters, uses `hook::jq_fields` as +the completeness check, so those two execs are one. Isolation +`$(source …)` forks are unchanged (#3685). Neither guard's decision +changed. + +*Method.* Spawn census via a stable PATH shim (`plugins/performance/scripts/spawn-census.sh`), +`HOOK_TELEMETRY_SINK` unset, this repository as cwd. Host `spawn_probe` +characterised as measurable. Wall clock is p50/p95 of 20 samples after 2 +warmup. + +| Counter | before | after | +|---|---|---| +| `git status --short` PATH-shim spawns | 2 (`2 jq`) | 1 (`1 jq`) | +| `echo hello` PATH-shim spawns | 2 (`2 jq`) | 1 (`1 jq`) | +| Write of in-repo `.md` PATH-shim spawns | 5 (`3 git`, `2 jq`) | 4 (`3 git`, `1 jq`) | + +The milliseconds are context on this cheap-spawn host. The durable figure +is the one `jq` process that disappeared. The remaining exec is the fused +payload parse. The three git processes on a Write are the +`hardcoded-path-check` probes previously measured and not folded. + **0.32.10, git probes that cannot change a benign Bash verdict.** 2026-09-06, Linux CI host. The 0.32.9 table still carries five PATH-visible execs on `git status --short` (`3 git` + `2 jq`). This entry is the three git diff --git a/plugins/guardrails/hooks/block-convention-violation.sh b/plugins/guardrails/hooks/block-convention-violation.sh index f2db563c3b..85056cc4cc 100755 --- a/plugins/guardrails/hooks/block-convention-violation.sh +++ b/plugins/guardrails/hooks/block-convention-violation.sh @@ -67,7 +67,7 @@ source "$_HOOK_SELF/hook-utils.sh" start=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || { +hook::buffer_stdin_to INPUT || { rc=$? ((rc == 2)) && exit 2 exit 0 diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index e33de4ff93..e4e7dfb999 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -98,7 +98,7 @@ start=${EPOCHREALTIME:-} # needs the buffered input only when the fail-open sibling would scope a notice; # this guard denies instead, so the buffer is for jq_fields below, not for a skip # notice. -INPUT=$(hook::buffer_stdin) || { +hook::buffer_stdin_to INPUT || { rc=$? ((rc == 2)) && exit 2 exit 0 diff --git a/plugins/guardrails/hooks/block-exported-msys-pathconv.sh b/plugins/guardrails/hooks/block-exported-msys-pathconv.sh index cbaf6a29c3..6adf1b702c 100755 --- a/plugins/guardrails/hooks/block-exported-msys-pathconv.sh +++ b/plugins/guardrails/hooks/block-exported-msys-pathconv.sh @@ -108,7 +108,7 @@ start=${EPOCHREALTIME:-} # out before a complete payload) FAILS CLOSED — the guard cannot evaluate the # tool call, and a silent skip would pass exactly the traffic this guard exists # to stop. buffer_stdin already printed the BLOCKED reason to stderr. -INPUT=$(hook::buffer_stdin) || { +hook::buffer_stdin_to INPUT || { rc=$? ((rc == 2)) && exit 2 exit 0 diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 2bf9653eaf..9dadc35760 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -109,7 +109,7 @@ start=${EPOCHREALTIME:-} # does not require jq (hook::buffer_stdin's own JSON-completeness check is # jq-optional), so it runs before the jq gate below — hook::require_jq needs # the buffered input for its once-per-session notice scoping. -INPUT=$(hook::buffer_stdin) || { +hook::buffer_stdin_to INPUT || { rc=$? ((rc == 2)) && exit 2 exit 0 diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh index 17f0cb95ff..277320a8c3 100755 --- a/plugins/guardrails/hooks/block-no-verify.sh +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -67,7 +67,7 @@ start=${EPOCHREALTIME:-} # stop. buffer_stdin already printed the BLOCKED reason to stderr. Buffering # does not require jq (hook::buffer_stdin's own JSON-completeness check is # jq-optional), so it runs before the jq gate below. -INPUT=$(hook::buffer_stdin) || { +hook::buffer_stdin_to INPUT || { rc=$? ((rc == 2)) && exit 2 exit 0 diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index 78d968d501..314bbdb879 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -139,7 +139,7 @@ start=${EPOCHREALTIME:-} # (hook::buffer_stdin's own JSON-completeness check is jq-optional), so it runs # before the jq gate below — hook::require_jq needs the buffered input for its # once-per-session notice scoping. -INPUT=$(hook::buffer_stdin) || { +hook::buffer_stdin_to INPUT || { rc=$? ((rc == 2)) && exit 2 exit 0 diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index baf84d8752..73a1196b3e 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -125,7 +125,7 @@ start=${EPOCHREALTIME:-} # stop. buffer_stdin already printed the BLOCKED reason to stderr. Buffering # does not require jq (hook::buffer_stdin's own JSON-completeness check is # jq-optional), so it runs before the jq gate below. -INPUT=$(hook::buffer_stdin) || { +hook::buffer_stdin_to INPUT || { rc=$? ((rc == 2)) && exit 2 exit 0 diff --git a/plugins/guardrails/hooks/cli-flag-verify.sh b/plugins/guardrails/hooks/cli-flag-verify.sh index 6a11e1d0dc..1118141361 100755 --- a/plugins/guardrails/hooks/cli-flag-verify.sh +++ b/plugins/guardrails/hooks/cli-flag-verify.sh @@ -60,7 +60,7 @@ VERIFIER="$PLUGIN_ROOT/lib/verification/verify-cli-flag.sh" # runs before the jq gate below — hook::require_jq needs the buffered input # for its once-per-session notice scoping, and hook::read_file_path (next) # itself parses with jq. -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq is required to parse the tool payload. hook::require_jq fails OPEN # (this hook never blocks) but makes the degraded state visible to both the diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index 432d3275af..bf450fe6a8 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -82,7 +82,7 @@ start=${EPOCHREALTIME:-} # (hook::buffer_stdin's own JSON-completeness check is jq-optional), so it # runs before the jq gate below — hook::require_jq needs the buffered input # for its once-per-session notice scoping. -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq is required both to parse the tool payload and to read enabledPlugins. # hook::require_jq fails OPEN (this hook never blocks either way) but makes diff --git a/plugins/guardrails/hooks/hardcoded-path-check.sh b/plugins/guardrails/hooks/hardcoded-path-check.sh index 758ed8c9c2..83a7a6cd29 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.sh @@ -60,7 +60,7 @@ start=${EPOCHREALTIME:-} # own JSON-completeness check is jq-optional), so it runs before the jq gate # below — hook::require_jq needs the buffered input for its once-per-session # notice scoping. -INPUT=$(hook::buffer_stdin) || { +hook::buffer_stdin_to INPUT || { rc=$? ((rc == 2)) && exit 2 exit 0 diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/guardrails/hooks/run-guards.sh b/plugins/guardrails/hooks/run-guards.sh index afacb71fc6..126033b1fe 100755 --- a/plugins/guardrails/hooks/run-guards.sh +++ b/plugins/guardrails/hooks/run-guards.sh @@ -103,25 +103,13 @@ while (($#)); do done ((${#GUARDS[@]})) || exit 0 -# --- stdin once --------------------------------------------------------------- -RUN_GUARDS_STDIN_RC=0 -RUN_GUARDS_INPUT=$(hook::buffer_stdin) || RUN_GUARDS_STDIN_RC=$? -# Nothing arrived: every guard would take its empty-stdin skip. Take it once. -((RUN_GUARDS_STDIN_RC == 1)) && exit 0 - -# shellcheck disable=SC2329 # invoked by every guard sourced below -hook::buffer_stdin() { - ((RUN_GUARDS_STDIN_RC == 0)) || return "$RUN_GUARDS_STDIN_RC" - printf '%s' "$RUN_GUARDS_INPUT" -} - -# --- jq once ------------------------------------------------------------------ -# Keep the library's implementation reachable under another name so the cache -# miss path is the library's own code, not a re-implementation of it. -# `declare -f` is a builtin; wrapping it in $( ) is one subshell. Piping that -# through `sed` was an extra exec on every dispatcher fire. Parameter expansion -# renames the first occurrence — the `name ()` header — and leaves the body -# untouched. +# --- stdin once, fields once -------------------------------------------------- +# Keep the library's jq_fields reachable under another name so the cache-miss +# path is the library's own code. `declare -f` is a builtin; wrapping it in +# $( ) is one subshell. Piping that through `sed` was an extra exec on every +# dispatcher fire. Parameter expansion renames the first occurrence — the +# `name ()` header — and leaves the body untouched. Copied BEFORE the fused +# stdin read so the miss path is ready when hook::jq_fields is overridden. _rg_jq_def=$(declare -f hook::jq_fields) eval "${_rg_jq_def/hook::jq_fields ()/hook::jq_fields_uncached ()}" unset _rg_jq_def @@ -141,7 +129,43 @@ PRIME_FILTERS=( '.tool_input.file_path' '.tool_input.notebook_path' '.tool_input.path' '.tool_input.content' '.tool_input.new_string' '.tool_input.new_source' ) -if ((RUN_GUARDS_STDIN_RC == 0)) && hook::jq_fields_uncached "$RUN_GUARDS_INPUT" "${PRIME_FILTERS[@]}" && + +# Fused capture: GNU Bash forks a subshell for INPUT=$(hook::buffer_stdin) +# even when the body is builtins (Command Substitution; +# https://mywiki.wooledge.org/CommandSubstitution). Passing PRIME_FILTERS +# makes the completeness check and the field extract one jq process instead +# of `jq -e .` plus a second jq_fields spawn. +# +# Dest is initialized here so ShellCheck SC2154 sees the assignment. +# printf -v through a nameref inside hook::buffer_stdin_to is a dynamic +# assignment the checker does not track +# (https://www.shellcheck.net/wiki/SC2154, Exceptions: "explicitly +# initialize/declare it with var="" or declare var"). +RUN_GUARDS_INPUT="" +RUN_GUARDS_STDIN_RC=0 +hook::buffer_stdin_to RUN_GUARDS_INPUT "${PRIME_FILTERS[@]}" || RUN_GUARDS_STDIN_RC=$? +# Nothing arrived: every guard would take its empty-stdin skip. Take it once. +((RUN_GUARDS_STDIN_RC == 1)) && exit 0 + +# shellcheck disable=SC2329 # invoked by every guard sourced below +hook::buffer_stdin() { + ((RUN_GUARDS_STDIN_RC == 0)) || return "$RUN_GUARDS_STDIN_RC" + printf '%s' "$RUN_GUARDS_INPUT" +} + +# shellcheck disable=SC2329 # invoked by every guard sourced below +hook::buffer_stdin_to() { + # `__rg_dest`, not `dest`: an unprefixed local would collide with a guard + # that called `hook::buffer_stdin_to dest` and `printf -v` would write + # this frame's local, return 0, and leave the guard's dest unset + # (the `_to` helper convention at lib/hook-utils.sh). + local __rg_dest="$1" + ((RUN_GUARDS_STDIN_RC == 0)) || return "$RUN_GUARDS_STDIN_RC" + printf -v "$__rg_dest" '%s' "$RUN_GUARDS_INPUT" +} + +if ((RUN_GUARDS_STDIN_RC == 0)) && + ((${#HOOK_JQ_FIELDS[@]} == ${#PRIME_FILTERS[@]})) && ((HOOK_JQ_FIELDS_NUL == 0)); then RUN_GUARDS_PRIMED=1 RUN_GUARDS_FILTERS=("${PRIME_FILTERS[@]}") diff --git a/plugins/guardrails/hooks/run-guards.test.sh b/plugins/guardrails/hooks/run-guards.test.sh index 157b8c9951..4bd82dd6ff 100755 --- a/plugins/guardrails/hooks/run-guards.test.sh +++ b/plugins/guardrails/hooks/run-guards.test.sh @@ -41,18 +41,21 @@ SEEN="$TEST_TMPDIR/seen" # The stub bodies are written verbatim into the stub scripts, so the `$` in them # must NOT expand here. # shellcheck disable=SC2016 -stub allow.sh 'INPUT=$(hook::buffer_stdin) || { rc=$?; ((rc == 2)) && exit 2; exit 0; } +stub allow.sh 'hook::buffer_stdin_to INPUT || { rc=$?; ((rc == 2)) && exit 2; exit 0; } hook::jq_fields "$INPUT" ".tool_input.command" ".tool_name" || exit 0 printf "%s\n" "${HOOK_JQ_FIELDS[@]}" >>"'"$SEEN"'" exit 0' +stub dest.sh 'hook::buffer_stdin_to dest || { rc=$?; ((rc == 2)) && exit 2; exit 0; } +printf "%s\n" "$dest" >>"'"$SEEN"'" +exit 0' stub block.sh 'echo "BLOCKED: stub" >&2; exit 2' stub ctx1.sh 'hook::emit_channels PreToolUse "ctx one" ""; exit 0' stub ctx2.sh 'hook::emit_channels PreToolUse "ctx two" "sys two"; exit 0' stub crash.sh 'exit 3' -stub nul.sh 'INPUT=$(hook::buffer_stdin) || exit 0 +stub nul.sh 'hook::buffer_stdin_to INPUT || exit 0 hook::jq_fields "$INPUT" ".tool_input.command" || exit 0 printf "nul=%s cmd=%s\n" "$HOOK_JQ_FIELDS_NUL" "$HOOK_JQ_FIELDS" >>"'"$SEEN"'"' -stub miss.sh 'INPUT=$(hook::buffer_stdin) || exit 0 +stub miss.sh 'hook::buffer_stdin_to INPUT || exit 0 hook::jq_fields "$INPUT" ".session_id" ".tool_name" || exit 0 printf "%s\n" "${HOOK_JQ_FIELDS[@]}" >>"'"$SEEN"'"' stub lib.sh 'printf "ps=%s\n" "${_GUARDRAILS_PS_COMMAND_LOADED:-unset}" >>"'"$SEEN"'"' @@ -80,6 +83,12 @@ assert_silent "allow-only prints nothing" "$OUT$ERR" assert_eq "guard read its fields from the shared cache" \ $'git status --short\nBash' "$(cat "$SEEN")" +# Dest named `dest` must still receive the payload through the dispatcher +# override (an unprefixed local dest would swallow the printf -v). +run "$PAYLOAD" "$TEST_TMPDIR/dest.sh" +assert_exit "dest-named dest exits 0" 0 "$RC" +assert_contains "dest-named dest received the payload" "$(cat "$SEEN")" 'tool_name' + # --- a block does not stop the later guards, and wins the exit code ---------- run "$PAYLOAD" "$TEST_TMPDIR/block.sh" "$TEST_TMPDIR/allow.sh" assert_exit "block wins the exit code" 2 "$RC" @@ -174,7 +183,7 @@ assert_exit "bare block-no-verify.sh from hooks/ exits 0" 0 "$bare_guard_rc" SHIM="$TEST_TMPDIR/spawn-shim" mkdir -p "$SHIM" SPAWN_LOG="$SHIM/spawns.log" -for tool in dirname sed; do +for tool in dirname sed jq; do real=$(type -P "$tool") if [[ -z "$real" ]]; then bad "need $tool on PATH to pin its absence from the dispatcher" @@ -184,14 +193,14 @@ for tool in dirname sed; do printf '#!/usr/bin/env bash\nprintf "%%s\\n" %q >>%q\nexec %q "$@"\n' "$tool" "$SPAWN_LOG" "$real" >"$SHIM/$tool" chmod +x "$SHIM/$tool" done -if [[ -x "$SHIM/dirname" && -x "$SHIM/sed" ]]; then +if [[ -x "$SHIM/dirname" && -x "$SHIM/sed" && -x "$SHIM/jq" ]]; then : >"$SPAWN_LOG" PATH="$SHIM:$PATH" bash "$DISPATCH" --lib lib/powershell/ps-command.sh \ block-no-verify.sh block-dangerous-git.sh block-hook-bypass.sh \ flag-commit-pr-skill-bypass.sh block-noncanonical-commit.sh \ block-convention-violation.sh block-windows-drive-tmp.sh \ block-exported-msys-pathconv.sh <<<"$PAYLOAD" >/dev/null - assert_eq "benign Bash dispatcher execs neither dirname nor sed" "" "$(cat "$SPAWN_LOG")" + assert_eq "benign Bash dispatcher spends one jq and neither dirname nor sed" "jq" "$(cat "$SPAWN_LOG")" fi DISPATCH_XTRACE=$(bash -x "$DISPATCH" --lib lib/powershell/ps-command.sh \ block-no-verify.sh block-dangerous-git.sh block-hook-bypass.sh \ diff --git a/plugins/guardrails/hooks/secret-pattern-detection.sh b/plugins/guardrails/hooks/secret-pattern-detection.sh index 0a42113add..ffcb2a9df2 100755 --- a/plugins/guardrails/hooks/secret-pattern-detection.sh +++ b/plugins/guardrails/hooks/secret-pattern-detection.sh @@ -61,7 +61,7 @@ start=${EPOCHREALTIME:-} # own JSON-completeness check is jq-optional), so it runs before the jq gate # below — hook::require_jq needs the buffered input for its once-per-session # notice scoping. -INPUT=$(hook::buffer_stdin) || { +hook::buffer_stdin_to INPUT || { rc=$? ((rc == 2)) && exit 2 exit 0 diff --git a/plugins/guardrails/hooks/skill-reference-verify.sh b/plugins/guardrails/hooks/skill-reference-verify.sh index ad8cfd8d46..299938da78 100755 --- a/plugins/guardrails/hooks/skill-reference-verify.sh +++ b/plugins/guardrails/hooks/skill-reference-verify.sh @@ -57,7 +57,7 @@ source "$_HOOK_SELF/hook-utils.sh" hook::ctx_reset -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 hook::require_jq "PostToolUse" "guardrails-skill-reference-verify" "$INPUT" diff --git a/plugins/guardrails/hooks/stale-path-verify.sh b/plugins/guardrails/hooks/stale-path-verify.sh index 780ead64cf..762379c532 100755 --- a/plugins/guardrails/hooks/stale-path-verify.sh +++ b/plugins/guardrails/hooks/stale-path-verify.sh @@ -61,7 +61,7 @@ source "$_HOOK_SELF/hook-utils.sh" hook::ctx_reset -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 hook::require_jq "PostToolUse" "guardrails-stale-path-verify" "$INPUT" diff --git a/plugins/guardrails/hooks/workflow-resilience-check.sh b/plugins/guardrails/hooks/workflow-resilience-check.sh index 36ffb9c77d..78264c18f8 100755 --- a/plugins/guardrails/hooks/workflow-resilience-check.sh +++ b/plugins/guardrails/hooks/workflow-resilience-check.sh @@ -49,7 +49,7 @@ start=${EPOCHREALTIME:-} # (hook::buffer_stdin's own JSON-completeness check is jq-optional), so it # runs before the jq gate below — hook::require_jq needs the buffered input # for its once-per-session notice scoping. -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq parses the tool payload and builds the additionalContext JSON. # hook::require_jq fails OPEN (this hook never blocks) but makes the degraded diff --git a/plugins/instruction-placement/.claude-plugin/plugin.json b/plugins/instruction-placement/.claude-plugin/plugin.json index 6e972c48ea..417e793f64 100644 --- a/plugins/instruction-placement/.claude-plugin/plugin.json +++ b/plugins/instruction-placement/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "instruction-placement", - "version": "0.11.29", + "version": "0.11.30", "description": "Routes agent-instruction content to the surface that loads it at the right moment. The audit skill sweeps a repository's instruction layer and its ordinary markdown for content whose scope is narrower than the surface carrying it \u2014 conventions keyed to one file type or one subtree sitting in an always-loaded CLAUDE.md or AGENTS.md \u2014 and for normative conventions stranded in documentation Claude never loads at all, then classifies each against a routing rubric and proposes a destination whose `paths:` glob is machine-validated before it is ever offered. Safety-class content (irreversible actions, secrets, data integrity, external publication, compliance, agent authority) is hard-denied from demotion and reported as held back rather than proposed, because demotion trades guaranteed presence for conditional presence and deferred surfaces are invisible inside subagents and absent after compaction until re-triggered. Every accepted move regenerates an always-loaded index of deferred surfaces, which is what keeps a demoted rule reachable from a subagent that never receives its injection. The audit is read-only and emits a diffable findings artifact; realignment is a separate skill gated per item with no blanket-approve path; a deterministic check skill gates that every rule glob still resolves and the index is current; and a setup skill verifies the one thing no other gate can see \u2014 that the index target is a file Claude Code will actually read, since it reads CLAUDE.md and not AGENTS.md.", "author": { "name": "Melodic Software", diff --git a/plugins/instruction-placement/CHANGELOG.md b/plugins/instruction-placement/CHANGELOG.md index 1d0d7156a7..1211d811cc 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to the `instruction-placement` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.30] + +### Changed + +- **Synced `hooks/hook-utils.sh`.** `hook::buffer_stdin_to` captures the + hook payload in-process (no command-substitution subshell) and can fuse + the JSON completeness check with field extraction so a caller that was + about to run `jq` twice spends one process. This plugin's own hook + behavior is unchanged. + ## [0.11.29] ### Changed diff --git a/plugins/instruction-placement/hooks/hook-utils.sh b/plugins/instruction-placement/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/instruction-placement/hooks/hook-utils.sh +++ b/plugins/instruction-placement/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index 105239834a..8626be0d8e 100644 --- a/plugins/markdown-format/.claude-plugin/plugin.json +++ b/plugins/markdown-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "markdown-format", - "version": "0.11.47", + "version": "0.11.48", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2 \u2014 only in repos that carry their own markdownlint config.", "author": { "name": "Melodic Software", diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 288fd0a269..053e27159b 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `markdown-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.48] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.11.47] ### Changed diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/markdown-format/hooks/markdown-format.sh b/plugins/markdown-format/hooks/markdown-format.sh index 7efa9f15a1..a53331584b 100755 --- a/plugins/markdown-format/hooks/markdown-format.sh +++ b/plugins/markdown-format/hooks/markdown-format.sh @@ -59,7 +59,7 @@ emit_tel() { hook::emit_telemetry "markdown-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2")" "$REPO_ROOT" } -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq-free applicability pre-filter: never emit the jq notice for an edit this # hook would not process anyway. hooks.json already gates launch with diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index 9ea73a9e4f..12e62b257a 100644 --- a/plugins/powershell-format/.claude-plugin/plugin.json +++ b/plugins/powershell-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "powershell-format", - "version": "0.7.40", + "version": "0.7.41", "description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo \u2014 using the consuming repo's own analyzer settings.", "author": { "name": "Melodic Software", diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index 62f1bfe3b0..c061335949 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `powershell-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.41] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.7.40] ### Changed diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/powershell-format/hooks/powershell-format.sh b/plugins/powershell-format/hooks/powershell-format.sh index 8026c574b4..06b341ee8c 100755 --- a/plugins/powershell-format/hooks/powershell-format.sh +++ b/plugins/powershell-format/hooks/powershell-format.sh @@ -62,7 +62,7 @@ emit_tel() { hook::emit_telemetry "powershell-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2")" "$REPO_ROOT" } -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq-free applicability pre-filter: never emit the jq notice for an edit this # hook would not process anyway (the Write|Edit matcher is broader than the diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index 0fba2308d9..7ab58dd80b 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "rate-limit-guard", - "version": "0.8.1", + "version": "0.8.2", "description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index 599ecb9464..1d2a5f01bf 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.2] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.8.1] ### Changed diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/rate-limit-guard/hooks/record-rate-limit-stop.sh b/plugins/rate-limit-guard/hooks/record-rate-limit-stop.sh index 8a76d2c391..efcfbd059f 100755 --- a/plugins/rate-limit-guard/hooks/record-rate-limit-stop.sh +++ b/plugins/rate-limit-guard/hooks/record-rate-limit-stop.sh @@ -44,7 +44,7 @@ hook::check_enabled "RATE_LIMIT_GUARD" # Buffer stdin once (Win32-pipe-safe bounded read). A missing or incomplete # payload degrades the record, never suppresses it. -INPUT=$(hook::buffer_stdin) || INPUT="" +hook::buffer_stdin_to INPUT || INPUT="" SESSION="" if [[ "$INPUT" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]]; then diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index 57eae92a11..f2c0313552 100644 --- a/plugins/ruff-format/.claude-plugin/plugin.json +++ b/plugins/ruff-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "ruff-format", - "version": "0.6.38", + "version": "0.6.39", "description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo \u2014 using the consuming repo's own Ruff config.", "author": { "name": "Melodic Software", diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index defb0d6955..9ba4308499 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `ruff-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.39] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.6.38] ### Changed diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/ruff-format/hooks/ruff-format.sh b/plugins/ruff-format/hooks/ruff-format.sh index 69348f5e93..4e97192a08 100755 --- a/plugins/ruff-format/hooks/ruff-format.sh +++ b/plugins/ruff-format/hooks/ruff-format.sh @@ -64,7 +64,7 @@ emit_tel() { hook::emit_telemetry "ruff-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2")" "$REPO_ROOT" } -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # jq-free applicability pre-filter: never emit the jq notice for an edit this # hook would not process anyway (the Write|Edit matcher is broader than the diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 43e0ef27b6..7a768168de 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.55.58", + "version": "0.55.59", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-authored-by trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop \u2014 safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and standing merge-rung raises binding from the team-tracked layer only \u2014 with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply \u2014 interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep \u2014 never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index 08a74e88fa..227ef3a91b 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.55.59] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.55.58] ### Changed diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/source-control/hooks/hook-utils.sh +++ b/plugins/source-control/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/source-control/hooks/pr-body-linkage-gate.sh b/plugins/source-control/hooks/pr-body-linkage-gate.sh index 82e77aac99..ac248afe3a 100755 --- a/plugins/source-control/hooks/pr-body-linkage-gate.sh +++ b/plugins/source-control/hooks/pr-body-linkage-gate.sh @@ -116,7 +116,7 @@ HOOK_DIR="${BASH_SOURCE[0]%/*}" source "$HOOK_DIR/hook-utils.sh" start=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 hook::require_jq "PreToolUse" "source-control-pr-body-linkage-gate" "$INPUT" diff --git a/plugins/source-control/hooks/pr-linkage-mcp-gate.sh b/plugins/source-control/hooks/pr-linkage-mcp-gate.sh index 9cdc168f88..782fb489ae 100755 --- a/plugins/source-control/hooks/pr-linkage-mcp-gate.sh +++ b/plugins/source-control/hooks/pr-linkage-mcp-gate.sh @@ -72,7 +72,7 @@ HOOK_DIR="${BASH_SOURCE[0]%/*}" source "$HOOK_DIR/hook-utils.sh" start=${EPOCHREALTIME:-} -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 [[ -n "$INPUT" ]] || exit 0 hook::require_jq "PreToolUse" "source-control-pr-linkage-mcp-gate" "$INPUT" diff --git a/plugins/source-control/hooks/worktree-add-claim-gate.sh b/plugins/source-control/hooks/worktree-add-claim-gate.sh index 532dbbc3f5..4ee69a0ce1 100755 --- a/plugins/source-control/hooks/worktree-add-claim-gate.sh +++ b/plugins/source-control/hooks/worktree-add-claim-gate.sh @@ -46,7 +46,7 @@ HOOK_DIR="${BASH_SOURCE[0]%/*}" # shellcheck source=hook-utils.sh source "$HOOK_DIR/hook-utils.sh" -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 hook::require_jq "PostToolUse" "source-control-worktree-add-claim-gate" "$INPUT" diff --git a/plugins/source-control/hooks/worktree-add-containment-gate.sh b/plugins/source-control/hooks/worktree-add-containment-gate.sh index 20d1c4b3aa..eaf99ca36d 100755 --- a/plugins/source-control/hooks/worktree-add-containment-gate.sh +++ b/plugins/source-control/hooks/worktree-add-containment-gate.sh @@ -73,7 +73,7 @@ HOOK_DIR="${BASH_SOURCE[0]%/*}" # shellcheck source=hook-utils.sh source "$HOOK_DIR/hook-utils.sh" -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 hook::require_jq "PreToolUse" "source-control-worktree-add-containment-gate" "$INPUT" diff --git a/plugins/source-control/hooks/worktree-create-gate.sh b/plugins/source-control/hooks/worktree-create-gate.sh index 1b7c859ec5..27412c6539 100755 --- a/plugins/source-control/hooks/worktree-create-gate.sh +++ b/plugins/source-control/hooks/worktree-create-gate.sh @@ -129,7 +129,14 @@ fi # The status matters: an empty or unreadable buffer is a DIFFERENT failure from a # payload that parsed but carried no `.name`, and reporting the second for the # first sent readers hunting a harness that had in fact sent nothing. -if ! payload="$(hook::buffer_stdin)"; then +# +# Dest is initialized here so ShellCheck SC2154 sees the assignment. +# printf -v through a nameref inside hook::buffer_stdin_to is a dynamic +# assignment the checker does not track +# (https://www.shellcheck.net/wiki/SC2154, Exceptions: "explicitly +# initialize/declare it with var="" or declare var"). +payload="" +if ! hook::buffer_stdin_to payload; then gate::refuse \ 'rerun the worktree creation; if it repeats, run with hook debugging on to capture the WorktreeCreate payload' \ 'nothing readable arrived on stdin — the WorktreeCreate payload was empty or could not be buffered' \ diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json index 61a509bac4..7993f0a597 100644 --- a/plugins/typos-format/.claude-plugin/plugin.json +++ b/plugins/typos-format/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "typos-format", - "version": "0.6.44", + "version": "0.6.45", "description": "Spell-check on edit via typos-cli, unconditionally \u2014 report-only by default, honoring the consuming repo's own typos configuration when one is present.", "author": { "name": "Melodic Software", diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md index 83d062510b..fe96d3479c 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `typos-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.45] + +### Changed + +- **The hook captures stdin with `hook::buffer_stdin_to`.** GNU Bash forks + a subshell for `INPUT=$(hook::buffer_stdin)` even when the body is only + builtins (Command Substitution, Bash Reference Manual; + https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash + that fork is a process. The `_to` form writes the payload in-process + with `printf -v`. Synced `hooks/hook-utils.sh` also fuses an optional + JSON completeness check with field extraction so a caller that was about + to run `jq` twice spends one process. What the hook checks is unchanged. + ## [0.6.44] ### Changed diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 2fc1daf7ac..599e17f23d 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -1351,7 +1351,8 @@ hook::repo_relative_path() { # rationale as plugins/context-guard/scripts/statusline-tee.sh. The re-arming # loop wraps both forms, so 3.2 gets the progress semantics too — just in # byte-at-a-time-sized steps. -# INPUT=$(hook::buffer_stdin) || exit 0 +# hook::buffer_stdin_to INPUT || exit 0 +# INPUT=$(hook::buffer_stdin) || exit 0 # print form; the $() is a subshell # The `read -N` availability guard, split out as its own predicate so the # pre-4.1 path stays reachable in tests on a modern host: BASH_VERSINFO is @@ -1506,53 +1507,79 @@ hook::resolve_read_slice() { printf '%s %s' "$slice" "$count" } -hook::buffer_stdin() { - local input="" chunk="" read_rc=0 stalled=0 idle_slices=0 validated=0 - local read_timeout read_slice slice_count +# hook::buffer_stdin_to [jq-filter...] +# Write the buffered payload into in THIS shell. GNU Bash forks a +# subshell for every command substitution even when the body is only +# builtins (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, paid on every hook that captures via +# INPUT=$(hook::buffer_stdin) before the payload is parsed. The _to form is +# the in-process capture, matching hook::resolve_read_timeout_to. +# +# Optional jq filters fuse the completeness check with hook::jq_fields so a +# caller that was about to extract fields anyway spends one jq process, not +# two (`jq -e .` plus a second jq for fields). On success HOOK_JQ_FIELDS is +# populated, index-parallel to the filters, with the same NUL-flag contract +# as a direct hook::jq_fields call. jq absent still fails open (return 0, +# empty fields), matching the print form's completeness check. +# +# Return codes match hook::buffer_stdin: 0 payload, 1 empty, 2 stalled or +# malformed. The print form below is the compatibility wrapper. +hook::buffer_stdin_to() { + local __hu_dest="$1" + shift + # Every local is `__hu_`-prefixed so a caller dest named `input`, + # `read_timeout`, `fields_rc`, or any other ordinary name cannot collide + # with this function's own variables (the `_to` helper convention at the + # path helpers above). An unprefixed local would make `printf -v` write + # the payload into THIS frame and return 0 while the caller kept stale data. + local __hu_input="" __hu_chunk="" __hu_read_rc=0 __hu_stalled=0 + local __hu_idle_slices=0 __hu_validated=0 + local __hu_read_timeout __hu_read_slice __hu_slice_count # _to, not $( ) / process substitution: GNU Bash forks a subshell for both, # even when the body is builtins only. Those two forks were the documented # buffer_stdin startup cost (lib/hook-utils.test.sh). The slice probe inside # resolve_read_slice_to still uses $(read) — that is the remaining, smaller # fork, paid only when the computed slice needs a live `read -t` probe. - hook::resolve_read_timeout_to read_timeout - hook::resolve_read_slice_to "$read_timeout" read_slice slice_count - local -a read_opts=(-r -t "$read_slice") + hook::resolve_read_timeout_to __hu_read_timeout + hook::resolve_read_slice_to "$__hu_read_timeout" __hu_read_slice __hu_slice_count + local -a __hu_read_opts=(-r -t "$__hu_read_slice") if hook::read_supports_nchars; then - read_opts+=(-N 65536) + __hu_read_opts+=(-N 65536) else - read_opts+=(-d '') + __hu_read_opts+=(-d '') fi while :; do - chunk="" - read_rc=0 - # shellcheck disable=SC2162 # -r is in read_opts; shellcheck cannot see through the array - IFS= read "${read_opts[@]}" chunk || read_rc=$? - input+="$chunk" + __hu_chunk="" + __hu_read_rc=0 + # shellcheck disable=SC2162 # -r is in __hu_read_opts; shellcheck cannot see through the array + IFS= read "${__hu_read_opts[@]}" __hu_chunk || __hu_read_rc=$? + __hu_input+="$__hu_chunk" # Any byte at all resets the idle count — that, not the read's exit status, # is what makes this an idle timer rather than a per-read deadline. - [[ -n "$chunk" ]] && idle_slices=0 - if ((read_rc == 0)); then + [[ -n "$__hu_chunk" ]] && __hu_idle_slices=0 + if ((__hu_read_rc == 0)); then # A full chunk (or a delimiter) — more may still be coming. A SUCCESSFUL # read that consumed nothing, however, cannot make progress, so continuing # would spin: break instead. hook::resolve_read_timeout already excludes # the only known way to reach that (`read -t 0`, which returns success # without consuming); this keeps loop termination a structural property # rather than a consequence of validation staying correct. - [[ -n "$chunk" ]] || break + [[ -n "$__hu_chunk" ]] || break continue fi - if ((read_rc > 128)); then + if ((__hu_read_rc > 128)); then # A slice expired. Bytes in it mean the producer is alive: keep them and # read on. Only slice_count CONSECUTIVE empty slices — one whole # stdin_read_timeout with nothing arriving — is the stall this guard # exists to catch, which is why the count is not reset here. - if [[ -n "$chunk" ]]; then + if [[ -n "$__hu_chunk" ]]; then # ... but stop immediately if what we already hold is a whole JSON # document. That is the Win32 late-EOF case — the payload arrived, the # pipe just never closed — and reading on there would spend the rest of # the bound waiting for an EOF that is not coming. - if hook::json_complete "${input//$'\r'/}"; then - validated=1 + if hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi continue @@ -1570,13 +1597,13 @@ hook::buffer_stdin() { # a jq process per slice to re-derive the same answer — enough overhead on # a slow-spawning host to cost more than slicing saves. idle_slices resets # the moment a byte lands, so the next quiet period checks again. - if ((idle_slices == 0)) && hook::json_complete "${input//$'\r'/}"; then - validated=1 + if ((__hu_idle_slices == 0)) && hook::json_complete "${__hu_input//$'\r'/}"; then + __hu_validated=1 break fi - ((idle_slices++)) - ((idle_slices >= slice_count)) || continue - stalled=1 + ((__hu_idle_slices++)) + ((__hu_idle_slices >= __hu_slice_count)) || continue + __hu_stalled=1 fi break # EOF (rc 1), a full idle bound with no bytes, or a read error done @@ -1584,34 +1611,52 @@ hook::buffer_stdin() { # fork AND an exec (~280 ms together on Windows Git Bash) to delete one byte # class from a string bash can already rewrite in place. Same bytes either way # — which is what lets the completeness verdict below be reused. - input="${input//$'\r'/}" - [[ -n "$input" ]] || return 1 - local jq_rc=0 + __hu_input="${__hu_input//$'\r'/}" + [[ -n "$__hu_input" ]] || return 1 + local __hu_jq_rc=0 # The loop above breaks on hook::json_complete only when jq PARSED this exact # CR-stripped buffer as a whole document, so re-probing it here would spend a # second jq process to re-derive an answer already in hand. jq's absence or # failure never sets that flag (json_complete returns non-zero for both), so # the fail-open path below is unchanged: an unvalidated buffer still gets the # probe, and a host without jq still reaches the 127 branch. - if ((validated == 0)) && command -v jq >/dev/null 2>&1; then + # + # Filters fuse that remaining probe with hook::jq_fields: a valid payload + # that the caller was going to parse anyway is extracted in the same jq + # process that would have been `jq -e .`. jq_fields rc 2 is the malformed + # path (parse failure or cardinality mismatch); rc 1 is jq absent, which + # fails open like jq -e's 127. + if (($#)); then + local __hu_fields_rc=0 + hook::jq_fields "$__hu_input" "$@" || __hu_fields_rc=$? + if ((__hu_fields_rc == 2)); then + __hu_jq_rc=2 + fi + elif ((__hu_validated == 0)) && command -v jq >/dev/null 2>&1; then # `printf | jq`, not a here-string — see hook::json_complete: a here-string # at or above the pipe capacity deadlocks the shell before jq is exec'd, and # a hook payload routinely exceeds it. - printf '%s' "$input" | jq -e . >/dev/null 2>&1 || jq_rc=$? + printf '%s' "$__hu_input" | jq -e . >/dev/null 2>&1 || __hu_jq_rc=$? fi - if ((jq_rc != 0 && jq_rc != 127)); then - if ((stalled)); then + if ((__hu_jq_rc != 0 && __hu_jq_rc != 127)); then + if ((__hu_stalled)); then echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2 return 2 fi # Whitespace-only stdin (e.g. `<<<""` sends a lone newline) is an empty # payload, not a malformed one — keep the silent rc=1 path advisory hooks # treat as a no-op. - [[ -n "${input//[[:space:]]/}" ]] || return 1 + [[ -n "${__hu_input//[[:space:]]/}" ]] || return 1 echo "BLOCKED: hook stdin is not valid JSON." >&2 return 2 fi - printf '%s' "$input" + printf -v "$__hu_dest" '%s' "$__hu_input" +} + +hook::buffer_stdin() { + local __hu_buf + hook::buffer_stdin_to __hu_buf || return $? + printf '%s' "$__hu_buf" } # Extract a single jq field from a buffered input string. CR-stripped. Returns 1 diff --git a/plugins/typos-format/hooks/typos-format.sh b/plugins/typos-format/hooks/typos-format.sh index 39ff94d174..bd96a8025d 100755 --- a/plugins/typos-format/hooks/typos-format.sh +++ b/plugins/typos-format/hooks/typos-format.sh @@ -109,7 +109,7 @@ emit_tel() { hook::emit_telemetry "typos-format" "PostToolUse" "$1" "$start" "$(build_data_json "$2" "${3:-[]}" "${4:-}")" "$REPO_ROOT" } -INPUT=$(hook::buffer_stdin) || exit 0 +hook::buffer_stdin_to INPUT || exit 0 # NotebookEdit carries its target as tool_input.notebook_path, NOT file_path # (verified against the tool's own input schema), so every path-reading step