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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 80 additions & 35 deletions lib/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <var> [jq-filter...]
# Write the buffered payload into <var> 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
Expand All @@ -1570,48 +1597,66 @@ 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
# CR-stripped in the shell, not through `printf | tr`: that pipeline cost a
# 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
Expand Down
94 changes: 85 additions & 9 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
}'
Expand Down Expand Up @@ -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}')
Expand Down
2 changes: 1 addition & 1 deletion plugins/actionlint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "actionlint",
"version": "0.8.38",
"version": "0.8.39",
"description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.",
"author": {
"name": "Melodic Software",
Expand Down
13 changes: 13 additions & 0 deletions plugins/actionlint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugins/actionlint/hooks/actionlint-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading