From 952befe1106481931f0658253fc9fd898fc24fbd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:51:39 -0400 Subject: [PATCH 1/6] perf(guardrails): run the guard chain in-process and answer payload fields without jq run-guards.sh sources its guards into its own shell instead of one command-substitution subshell per guard: `exit` is a dispatcher function that records the guard's status and runs the next guard from inside the call, stdout documents are collected through hook::emit_document, and a guard that dies of a hard error hands its status to the abort boundary's new chain slot (_GAB_CONTINUE), which runs the guards still owed in one subshell. Per-invocation analysis state (the alias memo) is reset before each guard, so a later guard's alias walk is not answered by an earlier guard's memo. lib/hook-utils.sh answers a well-formed payload's plain-string fields with its builtin JSON parser (hook::_fast_fields) and runs jq only for a shape it cannot prove; hook::jq_fields_uncached names the same body for the dispatcher's cache, hook::emit_document is the one stdout path, and hook::extract_bash_subject_to is the in-shell telemetry subject. The 17 carrying plugins take the synced copy with a patch bump. Measured on Windows 11 + Git Bash with an exact job-object census of the harness's own invocation: the Bash-lane chain went from 23 process creations to 3 and 880 ms to 285 ms isolated p50; the PowerShell lane from 100 to 80 creations and 3.3 s to 2.7 s. Decisions are byte-identical (rc, stdout, stderr) against 0.33.11 over the perf baseline's 17-command corpus in both tool modes, over 653 commands harvested from the guard suites on the Bash lane and 200 on the PowerShell lane, and over the Write, Edit and drive-root-tmp lanes. Handoff item: 20260913-034031 (program 20260915-153000). Co-authored-by: Claude Fable 5.1 --- lib/hook-utils.sh | 212 ++++++++++- lib/hook-utils.test.sh | 145 ++++++++ plugins/actionlint/.claude-plugin/plugin.json | 2 +- plugins/actionlint/CHANGELOG.md | 6 + plugins/actionlint/hooks/hook-utils.sh | 212 ++++++++++- plugins/autonomy/.claude-plugin/plugin.json | 2 +- plugins/autonomy/CHANGELOG.md | 6 + plugins/autonomy/hooks/hook-utils.sh | 212 ++++++++++- .../bash-format/.claude-plugin/plugin.json | 2 +- plugins/bash-format/CHANGELOG.md | 6 + plugins/bash-format/hooks/hook-utils.sh | 212 ++++++++++- .../biome-format/.claude-plugin/plugin.json | 2 +- plugins/biome-format/CHANGELOG.md | 6 + plugins/biome-format/hooks/hook-utils.sh | 212 ++++++++++- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 6 + plugins/claude-ops/hooks/hook-utils.sh | 212 ++++++++++- .../context-guard/.claude-plugin/plugin.json | 2 +- plugins/context-guard/CHANGELOG.md | 6 + plugins/context-guard/hooks/hook-utils.sh | 212 ++++++++++- .../.claude-plugin/plugin.json | 2 +- plugins/desktop-notification/CHANGELOG.md | 6 + .../desktop-notification/hooks/hook-utils.sh | 212 ++++++++++- .../eol-normalizer/.claude-plugin/plugin.json | 2 +- plugins/eol-normalizer/CHANGELOG.md | 6 + plugins/eol-normalizer/hooks/hook-utils.sh | 212 ++++++++++- plugins/go-format/.claude-plugin/plugin.json | 2 +- plugins/go-format/CHANGELOG.md | 6 + plugins/go-format/hooks/hook-utils.sh | 212 ++++++++++- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 7 + plugins/guardrails/README.md | 21 ++ plugins/guardrails/hooks/abort-boundary.sh | 52 ++- .../hooks/block-convention-violation.sh | 2 +- .../guardrails/hooks/block-dangerous-git.sh | 2 +- .../hooks/block-exported-msys-pathconv.sh | 2 +- plugins/guardrails/hooks/block-hook-bypass.sh | 2 +- plugins/guardrails/hooks/block-no-verify.sh | 2 +- .../hooks/block-noncanonical-commit.sh | 2 +- .../hooks/block-windows-drive-tmp.sh | 59 ++-- .../hooks/flag-commit-pr-skill-bypass.sh | 2 +- plugins/guardrails/hooks/hook-utils.sh | 212 ++++++++++- plugins/guardrails/hooks/run-guards.sh | 330 +++++++++++++----- plugins/guardrails/hooks/run-guards.test.sh | 106 +++++- .../.claude-plugin/plugin.json | 2 +- plugins/instruction-placement/CHANGELOG.md | 6 + .../instruction-placement/hooks/hook-utils.sh | 212 ++++++++++- .../.claude-plugin/plugin.json | 2 +- plugins/markdown-format/CHANGELOG.md | 6 + plugins/markdown-format/hooks/hook-utils.sh | 212 ++++++++++- .../.claude-plugin/plugin.json | 2 +- plugins/powershell-format/CHANGELOG.md | 6 + plugins/powershell-format/hooks/hook-utils.sh | 212 ++++++++++- .../.claude-plugin/plugin.json | 2 +- plugins/rate-limit-guard/CHANGELOG.md | 6 + plugins/rate-limit-guard/hooks/hook-utils.sh | 212 ++++++++++- .../ruff-format/.claude-plugin/plugin.json | 2 +- plugins/ruff-format/CHANGELOG.md | 6 + plugins/ruff-format/hooks/hook-utils.sh | 212 ++++++++++- .../source-control/.claude-plugin/plugin.json | 2 +- plugins/source-control/CHANGELOG.md | 6 + plugins/source-control/hooks/hook-utils.sh | 212 ++++++++++- .../typos-format/.claude-plugin/plugin.json | 2 +- plugins/typos-format/CHANGELOG.md | 6 + plugins/typos-format/hooks/hook-utils.sh | 212 ++++++++++- 65 files changed, 4357 insertions(+), 323 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 80d099bc97..9df1ba8918 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -4454,6 +4454,151 @@ fi rm -rf "$WU_WORK" +# --- Test 22: hook::_fast_fields answers exactly what the jq program answers -- +# hook::jq_fields_uncached tries the builtin parser first and runs jq only when +# it cannot prove the answer. Every payload shape below is put through both: +# the fast path's proven answer must equal the jq path's, field by field and on +# the NUL flag; a fall-back is always allowed. The jq path is reached by +# disabling the fast path inside a subshell, so nothing here depends on the +# order the two are tried in. +FF_FILTERS=('.tool_input.command' '.tool_name' '.cwd' '.tool_input.file_path' '.tool_input.content' '.hook_event_name') +fast_fields_is_jq() { # + local desc="$1" payload="$2" rc=0 src=0 i same=1 + local -a fast=() slow=() + hook::_fast_fields "$payload" "${FF_FILTERS[@]}" || rc=$? + if ((rc == 2)); then + ok "fast fields: $desc (falls back to jq)" + return + fi + if ((rc != 0)); then + fail "fast fields ($desc): rc=$rc" + return + fi + fast=("${HOOK_JQ_FIELDS[@]}") + local fnul=$HOOK_JQ_FIELDS_NUL + mapfile -d '' slow < <( + hook::_fast_fields() { return 2; } + hook::jq_fields_uncached "$payload" "${FF_FILTERS[@]}" || exit $? + printf '%s\0' "$HOOK_JQ_FIELDS_NUL" "${HOOK_JQ_FIELDS[@]}" + ) || src=$? + if ((src != 0)); then + fail "fast fields ($desc): proven by the fast path but the jq path returned $src" + return + fi + [[ "${slow[0]}" == "$fnul" ]] || same=0 + ((${#slow[@]} - 1 == ${#fast[@]})) || same=0 + for ((i = 0; i < ${#fast[@]}; i++)); do + [[ "${fast[i]}" == "${slow[i + 1]-}" ]] || same=0 + done + if ((same)); then + ok "fast fields: $desc (proven)" + else + fail "fast fields ($desc): fast [$(printf '%q ' "${fast[@]}")] jq [$(printf '%q ' "${slow[@]:1}")]" + fi +} +fast_fields_is_jq "Bash payload" '{"session_id":"s","cwd":"C:\\Users\\me","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","description":"probe"}}' +# shellcheck disable=SC2016 # the $_ is PowerShell's, inside a JSON payload +fast_fields_is_jq "PowerShell payload with braces and backslashes" '{"tool_name":"PowerShell","cwd":"C:\\Dev","tool_input":{"command":"Get-ChildItem C:\\Dev | Where-Object { $_.Name -like \"*x*\" }"}}' +fast_fields_is_jq "escapes in the command" '{"tool_name":"Bash","tool_input":{"command":"git commit -m \"a\\nb\" && echo \"\\t\"\\\\x"}}' +fast_fields_is_jq "CR inside a value is stripped like jq's output" '{"tool_name":"Bash","tool_input":{"command":"a\r\nb"}}' +fast_fields_is_jq "number and boolean siblings" '{"tool_name":"Bash","tool_input":{"command":"ls","timeout":600000,"run_in_background":true}}' +fast_fields_is_jq "empty tool_input" '{"tool_name":"Bash","tool_input":{}}' +fast_fields_is_jq "no tool_input" '{"tool_name":"Bash"}' +fast_fields_is_jq "null tool_input" '{"tool_name":"Bash","tool_input":null}' +fast_fields_is_jq "null command" '{"tool_name":"Bash","tool_input":{"command":null}}' +fast_fields_is_jq "empty command" '{"tool_name":"Bash","tool_input":{"command":""}}' +fast_fields_is_jq "Write payload" '{"tool_name":"Write","tool_input":{"file_path":"C:\\repo\\a.md","content":"line1\nline2 {\"x\":[1]}"}}' +fast_fields_is_jq "pretty-printed payload" "$(jq -n '{tool_name:"Bash",cwd:"/x",tool_input:{command:"git status"}}')" +fast_fields_is_jq "keys spelled with unicode escapes" '{"tool\u005fname":"Bash","tool_input":{"comm\u0061nd":"x"}}' +fast_fields_is_jq "non-ASCII value" '{"tool_name":"Bash","tool_input":{"command":"echo é 日本"}}' +fast_fields_is_jq "tool_input is a string" '{"tool_name":"Bash","tool_input":"x"}' +fast_fields_is_jq "nested object inside tool_input" '{"tool_name":"Bash","tool_input":{"command":"ls","meta":{"a":1}}}' +fast_fields_is_jq "key spelled as a value elsewhere" '{"tool_name":"command","tool_input":{"command":"ls"}}' +fast_fields_is_jq "duplicate key" '{"tool_name":"Bash","tool_input":{"command":"a","command":"b"}}' +fast_fields_is_jq "same key at the root and inside" '{"command":"root","tool_name":"Bash","tool_input":{"command":"in"}}' +fast_fields_is_jq "non-ASCII unicode escape" '{"tool_name":"Bash","tool_input":{"command":"\u00e9"}}' +fast_fields_is_jq "NUL escape" '{"tool_name":"Bash","tool_input":{"command":"a\u0000b"}}' +fast_fields_is_jq "number tool_name" '{"tool_name":5,"tool_input":{"command":"x"}}' +fast_fields_is_jq "false command" '{"tool_name":"Bash","tool_input":{"command":false}}' +fast_fields_is_jq "truncated payload" '{"tool_name":"Bash","tool_input":{"command":"x"' +fast_fields_is_jq "array root" '[{"tool_name":"Bash"}]' +# The shapes that must NOT be proven, pinned by verdict: a NUL escape (the +# flag is jq's), a duplicate key (jq takes the last), a non-string value (jq's +# tostring), a parent that is not an object (a jq error the caller reads as +# rc 2). +for ff_case in '{"tool_name":"Bash","tool_input":{"command":"a\u0000b"}}' \ + '{"tool_name":"Bash","tool_input":{"command":"a","command":"b"}}' \ + '{"tool_name":"Bash","tool_input":{"command":7}}' \ + '{"tool_name":"Bash","tool_input":"x"}'; do + ff_rc=0 + hook::_fast_fields "$ff_case" '.tool_input.command' || ff_rc=$? + if ((ff_rc == 2)); then + ok "fast fields: not proven, jq runs: ${ff_case:19:40}" + else + fail "fast fields: rc=$ff_rc on a shape only jq may answer: $ff_case" + fi +done +# An unusual filter shape is never the fast path's to answer. +ff_rc=0 +hook::_fast_fields '{"tool_input":{"files":[1,2]}}' '.tool_input.files | length' || ff_rc=$? +if ((ff_rc == 2)); then ok "fast fields: a non-path filter falls back to jq"; else fail "fast fields: non-path filter rc=$ff_rc"; fi +# The absence proof: a key nobody spells is "" without jq, and that is jq's +# answer too (`null // ""`). +hook::_fast_fields '{"tool_name":"Bash","tool_input":{"command":"ls"}}' '.cwd' '.tool_input.file_path' '.tool_input.command' +if [[ "${HOOK_JQ_FIELDS[0]}" == "" && "${HOOK_JQ_FIELDS[1]}" == "" && "${HOOK_JQ_FIELDS[2]}" == "ls" ]]; then + ok "fast fields: absent keys are proven empty beside a present one" +else + fail "fast fields: absent keys: [$(printf '%q ' "${HOOK_JQ_FIELDS[@]}")]" +fi +# The public entry point takes the fast path on the common payload: no jq on +# PATH is needed for it, but the jq presence check still precedes it, so the +# spawn count is what the dispatcher suite pins; here the answer is pinned. +hook::jq_fields '{"tool_name":"Bash","tool_input":{"command":"true"}}' '.tool_input.command' '.tool_name' +if [[ "${HOOK_JQ_FIELDS[0]}" == "true" && "${HOOK_JQ_FIELDS[1]}" == "Bash" && "$HOOK_JQ_FIELDS_NUL" == 0 ]]; then + ok "jq_fields: the common Bash payload is answered" +else + fail "jq_fields common payload: [$(printf '%q ' "${HOOK_JQ_FIELDS[@]}")] nul=$HOOK_JQ_FIELDS_NUL" +fi +unset ff_case ff_rc + +# --- Test 23: hook::emit_document is the one stdout path -------------------- +ed_out=$(hook::emit_channels PreToolUse "ctx" "sys") +ed_doc=$(hook::emit_document '{"a":1}') +if [[ "$ed_out" == '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"ctx"},"systemMessage":"sys"}' && "$ed_doc" == '{"a":1}' ]]; then + ok "emit_document prints one document with a trailing newline, and emit_channels goes through it" +else + fail "emit_document: channels=[$ed_out] doc=[$ed_doc]" +fi +ed_seen=$( + hook::emit_document() { printf '<%s>' "$1"; } + hook::emit_channels PostToolUse "c1" "" + hook::emit_channels PostToolUse "" "s2" +) +if [[ "$ed_seen" == '<{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"c1"}}><{"systemMessage":"s2"}>' ]]; then + ok "an override of emit_document collects every document emit_channels builds" +else + fail "emit_document override saw: $ed_seen" +fi +unset ed_out ed_doc ed_seen + +# --- Test 24: hook::extract_bash_subject_to equals the print form ------------- +for es_case in 'Bash|git status' 'Bash|sudo git push' 'Bash|FOO=1 make all' 'Bash|TOKEN="a b" curl x' 'Bash|TOKEN=secret' 'Bash|/usr/bin/env' 'Bash|' 'PowerShell|git status' 'Write|'; do + es_tool="${es_case%%|*}" + es_cmd="${es_case#*|}" + es_to="" + hook::extract_bash_subject_to es_to "$es_tool" "$es_cmd" + es_print=$(hook::extract_bash_subject "$es_tool" "$es_cmd") + if [[ "$es_to" == "$es_print" ]]; then + ok "extract_bash_subject_to matches the print form: $es_case -> $es_to" + else + fail "extract_bash_subject_to [$es_to] vs print form [$es_print] for $es_case" + fi +done +es_to="" +hook::extract_bash_subject_to es_to Bash 'TOKEN="a b" curl x' +if [[ "$es_to" == Bash ]]; then ok "subject: a quoted assignment value never reaches the subject"; else fail "subject leaked: $es_to"; fi +unset es_case es_tool es_cmd es_to es_print + echo echo "PASS=$PASS FAIL=$FAIL" [[ $FAIL -eq 0 ]] diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index b759b03eea..02824cf106 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.50", + "version": "0.8.51", "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 8a11a562db..31cbb049f1 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `actionlint` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.51] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.8.50] ### Changed diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 32d73d2242..a07028de8f 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.23.12", + "version": "0.23.13", "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 abb39da8d8..a865072cad 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `autonomy` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.23.13] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.23.12] ### Changed diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index e9ccaee45b..a8e64e4578 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.50", + "version": "0.7.51", "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 439e7a301d..1923836384 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `bash-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.51] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.7.50] ### Changed diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index c818f3321a..720332e938 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.48", + "version": "0.6.49", "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 37d94e3ad4..78efee231f 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `biome-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.49] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.6.48] ### Changed diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 85fbc7078f..699277718a 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.56.13", + "version": "0.56.14", "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used: a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which sheds descriptions lowest-score-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface: every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json: full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-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, namely built-in CLI commands, bundled skills, plugin-backed built-ins, and session-provided skills, against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry from the OTEL store, the collector, the per-session hook event log and hook-event JSONL, and ccusage, with trend reports, a per-session report of what fired, what was blocked and the event timeline, and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand: marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view: queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action, an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry, the skill-usage log and the hook log root live, places the root's self-ignoring guard, and detects retired conventions. Plus an opt-in, default-off per-session hook event log (one JSON line per hook event on every event the generated registry marks observable, written to /sessions/.jsonl, with SessionEnd retention by session count or age and an optional detached pre-prune command), a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures. The last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that routes envelopes under the same root: per session when the envelope carries a session id, else into the shared hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index e6445863bb..12b45389ba 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.56.14] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.56.13] ### Changed diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index 5513bdaa34..266f5ad25e 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.63", + "version": "0.7.64", "description": "Per-session context-window observability plus the first shipped consumer: a statusline wrapper tees each session's context_window fields to a per-session snapshot file, a zone resolver classifies usage into smart/acceptable/dumb bands (percentage bands plus window-class token bands, conservative-min combination, zones.json SSOT with shipped defaults), a reader contract fixes how consuming sessions interpret the snapshots, and zone-crossing hooks report once per transition into a worse zone across two channels: the continuation menu to the operator, who owns that choice, and to the model only the zone determination plus the counter-steer that a zone word is not a decay signal (advisory by default; an optional blocking mode gates new mutating work on a fresh dumb-zone snapshot with handoff-writing exempt), with a PostCompact hook persisting an evidence-degraded marker.", "author": { "name": "Melodic Software", diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index c27952db4e..cb5ab66268 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `context-guard` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.7.64] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.7.63] ### Changed diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index 2c5edaad89..f06926cc5c 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.43", + "version": "0.6.44", "description": "Alert you when Claude Code needs input: an audible terminal bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts.", "author": { "name": "Melodic Software", diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index e8f3b9ca43..8592d9fe5d 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `desktop-notification` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.44] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.6.43] ### Changed diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index c6343a8833..f2eaf4e2d5 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.49", + "version": "0.6.50", "description": "Normalize a written file's working-tree line endings to its .gitattributes eol value on edit: symmetric CRLF/LF driven by git check-attr, advisory and never blocking.", "author": { "name": "Melodic Software", diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index 65b5d39aa4..ae84a2d84d 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.50] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.6.49] ### Changed diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index 76170156d4..f2e056afde 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.53", + "version": "0.3.54", "description": "Auto-fix Go formatting and import management on edit via goimports. Runs unconditionally (no consumer-config gate), skipping generated files.", "author": { "name": "Melodic Software", diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index d48121727a..7def03a1f0 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `go-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.54] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.3.53] ### Changed diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index e352a30d9e..5e47e41b6f 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.33.11" + "version": "0.34.0" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 6c13b1de71..ab6a246b80 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,13 @@ 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.34.0] + +### Changed + +- run-guards.sh runs its guards inside its own shell instead of one command-substitution subshell per guard. A sourced guard's `exit` is a dispatcher function that records the status and runs the next guard from inside the call; a guard's stdout document is collected through `hook::emit_document` rather than captured from a subshell; a guard that dies of a hard error hands its status to the abort boundary's new chain slot (`_GAB_CONTINUE`, abort-boundary.sh), which settles that guard's posture and runs the guards still owed in one subshell. With the library's builtin field parser answering the primed fields, a benign Bash tool call's chain spawns nothing: on Windows Git Bash the chain went from 23 process creations to 3 (the harness's `bash -c`, the `env` of the shebang, and bash itself), 880 ms to 297 ms isolated p50; the PowerShell lane from 100 to 80 creations, 3.3 s to 2.4 s, with its remaining forks inside `lib/powershell/ps-command.sh`. Every deny and allow is byte-identical before and after (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over the commands harvested from the guard suites. block-windows-drive-tmp masks quoted redirects in-shell (`mask_quoted_redirect_ops_to`); block-no-verify and flag-commit-pr-skill-bypass resolve their telemetry subject in-shell. +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value); `hook::jq_fields_uncached` names the same body for the dispatcher's cache; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. + ## [0.33.11] ### Changed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index f92548c14b..c22b0fa6a1 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -403,6 +403,27 @@ out of scope until such a signal exists. ### Hook budget accounting +**0.34.0, the in-process guard chain.** 2026-09-15, Windows 11 + Git Bash +(`usr\bin\bash.exe` as the hook shell), host idle. Process creations counted +exactly with a Windows job object around the harness's own invocation +(`bash -c ""`, PreToolUse payload on stdin), which counts +every fork and exec alike: on this host every command substitution is a +process, and an external command is two (the fork, then Cygwin's exec). Chain +of eight Bash guards, benign `true`: creations **23 -> 3**, isolated p50 +**880 ms -> 297 ms** (n=5). The three that remain are the harness's `bash -c`, +the `env` its shebang goes through, and bash itself: the guards spawn nothing. +Same chain, PowerShell `exit 0`: **100 -> 80** creations, **3.3 s -> 2.4 s**; +the eighty are the `$(…)` captures and `printf | sed` pipelines inside +`lib/powershell/ps-command.sh`, which is the next cut. What was removed: the +eight isolation subshells (the guards are sourced into the dispatcher's shell +and `exit` is a function there), the `$(declare -f)` copy of `hook::jq_fields`, +the `printf | jq` process substitution that primed the fields (the library now +proves the common payload's fields with builtins and runs jq only when it +cannot), and the seven telemetry-subject captures. Decisions: byte-identical +rc, stdout and stderr against 0.33.11 over the perf baseline's 17-command +corpus in both tool modes (34 cases) and over every command harvested from the +eight guard suites. + **0.32.20, forks with no exec in `block-dangerous-git`.** 2026-09-06, Linux CI host. A PATH shim counts execs, and a fork that never execs is invisible to it. On every Bash and PowerShell call this guard created three such diff --git a/plugins/guardrails/hooks/abort-boundary.sh b/plugins/guardrails/hooks/abort-boundary.sh index 17779e9cd5..05a8ed2a6b 100644 --- a/plugins/guardrails/hooks/abort-boundary.sh +++ b/plugins/guardrails/hooks/abort-boundary.sh @@ -61,12 +61,13 @@ # never touches the trap), with a suite case beside the others, in the same # change. A release on purpose goes through guard::abort_boundary_release. # -# Under run-guards.sh each guard is sourced inside its own command-substitution -# subshell. The trap a guard installs there runs at that subshell's exit; its -# stderr passes straight through and its stdout document is merged with the -# other guards' output like any notice. A subshell does not inherit the -# dispatcher's own EXIT trap (Bash Reference Manual, Command Execution -# Environment), so the two boundaries never fire for the same exit. +# Under run-guards.sh the guards are sourced into the dispatcher's own shell, +# and `exit` there is a function of the dispatcher's. A guard that ends through +# it, or by falling off its end, never reaches this trap: the dispatcher applies +# guard::_abort_settle to the status itself and merges the notice document with +# the other guards' output. The trap fires only for a guard that dies of a hard +# error, when the shell is exiting, and then hands the status to the chain slot +# below (_GAB_CONTINUE), which the dispatcher fills for the span of its guards. [[ -n "${_GUARDRAILS_ABORT_BOUNDARY_LOADED:-}" ]] && return 0 readonly _GUARDRAILS_ABORT_BOUNDARY_LOADED=1 @@ -117,15 +118,46 @@ guard::_abort_json_escape_to() { printf -v "$1" '%s' "$__gab_s" } +# The chain slot. run-guards.sh runs its guards inside its own process, so a +# guard that dies of a hard error takes the dispatcher's shell down with it +# and this handler is what runs. With a function name here the handler hands +# that guard's status to it instead of deciding the process's fate itself; the +# dispatcher settles the guard's boundary, runs the guards still owed, and +# exits on the aggregate. The function must not return. Empty (every guard +# run alone), the handler decides as documented above. +_GAB_CONTINUE="" + guard::_abort_on_exit() { local rc=$? trap - EXIT + if [[ -n "$_GAB_CONTINUE" ]]; then + "$_GAB_CONTINUE" "$rc" + fi + guard::_abort_settle "$rc" && return 0 + [[ -n "$_GAB_DOC" ]] && printf '%s\n' "$_GAB_DOC" + exit "$_GAB_RC" +} + +# guard::_abort_settle : the decision, apart from the exit. Returns 0 +# when is one the hook chose, and nothing else happens. Otherwise +# writes the notice line to stderr, leaves the stdout document (empty for the +# closed posture) in _GAB_DOC and the status the posture maps to in _GAB_RC, +# and returns 1. The trap handler above exits on those; run-guards.sh, which +# runs each guard in its own process, records them for that guard and carries +# on with the next. +_GAB_DOC="" +_GAB_RC=0 +guard::_abort_settle() { + local rc="$1" + _GAB_DOC="" + _GAB_RC=0 [[ "$_GAB_CHOSEN" == *" $rc "* ]] && return 0 local msg if [[ "$_GAB_POSTURE" == closed ]]; then msg="guardrails ${_GAB_NAME}: guard did not run (internal error, rc=${rc}); fail-closed: this tool call is denied because the guard could not check it. The failing line is on the hook's stderr." printf '%s\n' "$msg" >&2 - exit 2 + _GAB_RC=2 + return 1 fi msg="guardrails ${_GAB_NAME}: guard did not run (internal error, rc=${rc}); fail-open: this tool call was not checked by this guard. The failing line is on the hook's stderr (claude --debug)." printf '%s\n' "$msg" >&2 @@ -133,9 +165,9 @@ guard::_abort_on_exit() { guard::_abort_json_escape_to esc "$msg" if [[ -n "$_GAB_EVENT" ]]; then guard::_abort_json_escape_to ev "$_GAB_EVENT" - printf '{"hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"},"systemMessage":"%s"}\n' "$ev" "$esc" "$esc" + _GAB_DOC='{"hookSpecificOutput":{"hookEventName":"'"$ev"'","additionalContext":"'"$esc"'"},"systemMessage":"'"$esc"'"}' else - printf '{"systemMessage":"%s"}\n' "$esc" + _GAB_DOC='{"systemMessage":"'"$esc"'"}' fi - exit 0 + return 1 } diff --git a/plugins/guardrails/hooks/block-convention-violation.sh b/plugins/guardrails/hooks/block-convention-violation.sh index e92f1c3ab5..630f853ca2 100755 --- a/plugins/guardrails/hooks/block-convention-violation.sh +++ b/plugins/guardrails/hooks/block-convention-violation.sh @@ -278,7 +278,7 @@ emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 local data subject - subject=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") + hook::extract_bash_subject_to subject "$TOOL_NAME" "$COMMAND" hook::json_str_object_to data tool "$TOOL_NAME" subject "$subject" form "$2" hook::emit_telemetry "block-convention-violation" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index b705b8e74d..18b5614436 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -192,7 +192,7 @@ emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 local data subject - subject=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") + hook::extract_bash_subject_to subject "$TOOL_NAME" "$COMMAND" hook::json_str_object_to data tool "$TOOL_NAME" subject "$subject" form "$2" hook::emit_telemetry "block-dangerous-git" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } diff --git a/plugins/guardrails/hooks/block-exported-msys-pathconv.sh b/plugins/guardrails/hooks/block-exported-msys-pathconv.sh index 3bcb4b6b95..86e363f69f 100755 --- a/plugins/guardrails/hooks/block-exported-msys-pathconv.sh +++ b/plugins/guardrails/hooks/block-exported-msys-pathconv.sh @@ -167,7 +167,7 @@ emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 local subject data - subject=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") + hook::extract_bash_subject_to subject "$TOOL_NAME" "$COMMAND" hook::json_str_object_to data tool "$TOOL_NAME" subject "$subject" form "$2" hook::emit_telemetry "block-exported-msys-pathconv" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index b835123ba3..0dd6a76f8a 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -186,7 +186,7 @@ emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 local data subject - subject=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") + hook::extract_bash_subject_to subject "$TOOL_NAME" "$COMMAND" hook::json_str_object_to data tool "$TOOL_NAME" subject "$subject" form "$2" hook::emit_telemetry "block-hook-bypass" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh index bd48b0f0b7..d42160391f 100755 --- a/plugins/guardrails/hooks/block-no-verify.sh +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -137,7 +137,7 @@ TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}" # commands are well under it). The linear parser keeps normal commands cheap. MAX_COMMAND_LEN=16384 -SUBJECT=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") +hook::extract_bash_subject_to SUBJECT "$TOOL_NAME" "$COMMAND" # Hook-manager env-var disable prefixes, built once into a regex alternation. # The default set covers the common managers; a consumer extends it via the diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index 4dce717f94..881e282dce 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -174,7 +174,7 @@ emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 local data subject - subject=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") + hook::extract_bash_subject_to subject "$TOOL_NAME" "$COMMAND" hook::json_str_object_to data tool "$TOOL_NAME" subject "$subject" form "$2" hook::emit_telemetry "block-noncanonical-commit" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index 60c961bdc9..4d6fe5fb73 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -184,14 +184,11 @@ MAX_COMMAND_LEN=16384 emit_tel() { [[ -n "$start" ]] || return 0 hook::telemetry_enabled || return 0 - # Resolved HERE, not at top level: hook::extract_bash_subject runs in a - # command substitution, and that fork was being paid on every tool call even - # when no telemetry sink is wired — which is the default, and now on the - # per-Write surface too, where the helper returns the bare tool name and the - # fork buys a constant. Same shape as the plugin's other lazily-resolved - # telemetry fields. + # Resolved HERE, not at top level, and in this shell: the subject is a + # telemetry field, so it is computed only when a sink is wired. Same shape + # as the plugin's other lazily-resolved telemetry fields. local SUBJECT data - SUBJECT=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") + hook::extract_bash_subject_to SUBJECT "$TOOL_NAME" "$COMMAND" hook::json_str_object_to data tool "$TOOL_NAME" subject "$SUBJECT" form "$2" hook::emit_telemetry "block-windows-drive-tmp" "PreToolUse" "$1" "$start" "$data" "${CLAUDE_PROJECT_DIR:-}" } @@ -211,7 +208,7 @@ block() { # Above this length the command is not parsed — fail closed (same ceiling as the # other argv-faithful Bash guards). The ceiling exists because the COMMAND lane -# below walks the string character by character twice (mask_quoted_redirect_ops, +# below walks the string character by character twice (mask_quoted_redirect_ops_to, # split_shell_segments) before it matches anything. NO EQUIVALENT CEILING GUARDS # THE FILE-PATH LANE, and that is a decision rather than an omission: that lane # runs three EREs against one string with no tokenization, so length buys no @@ -300,37 +297,39 @@ has_drive_root_tmp() { # Replace `>` that sit inside single- or double-quoted spans so a prose mention # such as `git commit -m "echo x > /tmp/x"` is not treated as a redirect, while # a real redirect whose *target* is quoted (`echo x > "/tmp/x"`) still matches. -mask_quoted_redirect_ops() { - local s="$1" out="" i=0 c quote="" - local -i len=${#s} - while ((i < len)); do - c="${s:i:1}" - if [[ -n "$quote" ]]; then - if [[ "$c" == "$quote" ]]; then - quote="" - out+="$c" - elif [[ "$c" == '>' ]]; then - out+='#' +mask_quoted_redirect_ops_to() { # : the mask, in this shell + # Locals under a `__dt_` prefix so the caller's variable name (`s`) cannot + # collide with them and take the assignment (the `_to` helper convention). + local __dt_dest="$1" __dt_s="$2" __dt_out="" __dt_i=0 __dt_c __dt_quote="" + local -i __dt_len=${#__dt_s} + while ((__dt_i < __dt_len)); do + __dt_c="${__dt_s:__dt_i:1}" + if [[ -n "$__dt_quote" ]]; then + if [[ "$__dt_c" == "$__dt_quote" ]]; then + __dt_quote="" + __dt_out+="$__dt_c" + elif [[ "$__dt_c" == '>' ]]; then + __dt_out+='#' else - out+="$c" + __dt_out+="$__dt_c" fi else - if [[ "$c" == "'" || "$c" == '"' ]]; then - quote="$c" + if [[ "$__dt_c" == "'" || "$__dt_c" == '"' ]]; then + __dt_quote="$__dt_c" fi - out+="$c" + __dt_out+="$__dt_c" fi - i=$((i + 1)) + __dt_i=$((__dt_i + 1)) done - printf '%s' "$out" + printf -v "$__dt_dest" '%s' "$__dt_out" } # Write-shaped signal: a redirect whose target word is a drive-root tmp path. # Covers `> /tmp/x`, `>/tmp/x`, `>>/tmp/x`, `2>/tmp/err`, `&>/tmp/x`. -# Redirect operators inside quotes are ignored (see mask_quoted_redirect_ops). +# Redirect operators inside quotes are ignored (see mask_quoted_redirect_ops_to). has_redirect_to_drive_root_tmp() { local s - s=$(mask_quoted_redirect_ops "$1") + mask_quoted_redirect_ops_to s "$1" # Optional fd digits and optional & (&>), then > or >>, optional space/quotes, # then a drive-root tmp path. Angle brackets in the right-boundary class are # literal characters (not GNU \< \> word-boundaries) — keep them unescaped so @@ -495,9 +494,9 @@ if [[ -n "$FILE_PATH" ]]; then fi # --- Command lane: Bash / PowerShell ----------------------------------------- -# Skipped outright on a file-path payload: has_redirect_to_drive_root_tmp runs -# mask_quoted_redirect_ops in a command substitution, and forking the shell to -# scan an empty string would be per-Write budget spent to reach a foregone `no`. +# Skipped outright on a file-path payload: has_redirect_to_drive_root_tmp walks +# the command character by character, and scanning an empty string would be +# per-Write budget spent to reach a foregone `no`. if [[ -n "$COMMAND" ]]; then if has_redirect_to_drive_root_tmp "$NORM"; then block "redirect" diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index d64fc9621b..4dc97e3402 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -124,7 +124,7 @@ fi # keep an assignment VALUE out of the subject — a quoted value spanning the # whitespace the tokenizer splits on, and a bare/trailing `NAME=value` no # following command consumed — hold here too (#3372). -SUBJECT=$(hook::extract_bash_subject "$TOOL_NAME" "$COMMAND") +hook::extract_bash_subject_to SUBJECT "$TOOL_NAME" "$COMMAND" # Emit one telemetry envelope per run. Advisory guards always report status # "ok" (they never block); the finding signal rides in `data.forms` — category diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/guardrails/hooks/run-guards.sh b/plugins/guardrails/hooks/run-guards.sh index 5f1fb75c5f..723b632a0a 100755 --- a/plugins/guardrails/hooks/run-guards.sh +++ b/plugins/guardrails/hooks/run-guards.sh @@ -10,7 +10,8 @@ # (#1403, hook-budget convention): on this fleet the per-call cost that shows # up as typing lag is process creation, not any one slow classifier. Eight # always-on Bash guards meant eight bash processes, eight parses of the shared -# hook library, and eight jq spawns per Bash tool call. This runs them as one. +# hook library, and eight jq spawns per Bash tool call. This runs them as one +# process, and on the common payload that process spawns nothing at all. # # HOW A GUARD RUNS UNCHANGED INSIDE ONE PROCESS # @@ -20,18 +21,46 @@ # 2 stalled/malformed), so each guard's fail-open / fail-closed posture on # bad stdin is exercised exactly as when it runs alone. # * The payload fields the guards read (`.tool_input.command`, `.tool_name`, -# `.cwd`, the Write/Edit content fields, ...) are extracted with ONE jq -# process; `hook::jq_fields` answers from that cache when every requested -# filter is in it and the payload carried no NUL, and falls through to the -# library's own jq path (byte-identical, saved under another name) otherwise. -# A NUL-bearing payload therefore still reaches each guard's own NUL -# handling through the real jq call. -# * Each guard is `source`d in a command-substitution subshell. Its `exit` -# ends that subshell only; its `source hook-utils.sh` returns at once on the -# library's double-source guard, so the overrides above stay in force; its -# `trap ... EXIT` runs at the subshell's exit; `BASH_SOURCE[0]` is the guard's -# own path, so sibling libraries resolve as before. stdout is captured, -# stderr passes straight through, unbuffered. +# `.cwd`, the Write/Edit content fields, ...) are extracted ONCE, by the +# library's builtin parser when it can prove the answer and by one jq +# process otherwise; `hook::jq_fields` answers from that cache when every +# requested filter is in it and the payload carried no NUL, and falls +# through to the library's own hook::jq_fields_uncached otherwise. A +# NUL-bearing payload therefore still reaches each guard's own NUL handling +# through the real jq call. +# * Each guard is `source`d in THIS shell, not in a subshell: on Windows Git +# Bash every fork is a Win32 process creation, so eight command-substitution +# subshells were eight processes per tool call. A sourced guard ends by +# calling `exit`, which here is a shell function: it records the guard's +# status and runs the NEXT guard from inside that call, so the chain never +# returns into a guard that has exited. A guard that falls off its end +# returns from `source` and is recorded the same way. Inside a real subshell +# (`$(...)`, `( )`, a pipeline, `&`) the function sees a different BASHPID +# and exits that subshell as the builtin would. Its `source hook-utils.sh` +# returns at once on the library's double-source guard, so the overrides +# stay in force; `BASH_SOURCE[0]` is the guard's own path, so sibling +# libraries resolve as before. stderr passes straight through, unbuffered. +# * A guard's stdout is a hook JSON document, and every document a guard +# emits goes through hook::emit_document (hook::emit_channels included). +# That one function is overridden here to collect the documents; nothing +# is captured from the process's stdout. A guard that printed a document +# with its own printf would bypass the collection: keep to the helper. +# * A guard's abort boundary (abort-boundary.sh) still decides what a status +# the guard did not choose means. When the guard ends through `exit` or by +# falling off its end, guard::_abort_settle is applied to its status here, +# and the notice document it builds joins the collected output. When the +# guard dies of a hard error (an unbound variable under `set -u`, a failed +# `source`), the shell itself is exiting and bash runs the guard's EXIT +# trap, which is this file's handler: the same settle, and the guards that +# have not run yet run in ONE subshell from inside that handler, because a +# second hard error in a dying shell would end the process with no result. +# That subshell reports its documents and status back on its stdout; a +# further hard error there costs one more subshell, never the result. +# * Guards share one process, so a guard's functions and globals remain +# defined while the later guards run, and a guard that exits from inside a +# function leaves that function's locals visible to them. Every guard +# assigns what it reads before it reads it; a guard must not read a global +# it did not set expecting it unset. # # AGGREGATION (the one deliberate delta from N separate hooks) # @@ -39,10 +68,9 @@ # guard returned; else 0. Every guard runs even after one has blocked, so a # command that trips two guards still shows both reasons, as it did when # the guards were separate hooks. -# * stdout: a guard's stdout is a hook JSON document (`hookSpecificOutput` / -# `systemMessage`). One emitter passes through verbatim. Several are merged -# into one document (contexts joined by a blank line) because Claude Code -# reads exactly one JSON document per hook process; as separate hooks each +# * stdout: one emitter passes through verbatim. Several are merged into one +# document (contexts joined by a blank line) because Claude Code reads +# exactly one JSON document per hook process; as separate hooks each # document was delivered on its own. When jq is absent, or the merge fails, # the documents are never concatenated (two documents on stdout is invalid # hook output): the one carrying a blocking decision (`"decision":"block"`, @@ -74,14 +102,13 @@ _RG_DIR="${BASH_SOURCE[0]%/*}" # shellcheck source=abort-boundary.sh source "$_RG_DIR/abort-boundary.sh" # Could-not-run posture (#3528): fail-open with a visible notice. This covers -# the dispatcher's own code, before and after the guards: stdin, the jq +# the dispatcher's own code, before and after the guards: stdin, the field # priming, the classifier load, the merge. An abort here would otherwise skip -# EVERY guard of the event with a bare status and no line of its own. Each -# guard installs its own boundary inside its isolation subshell, which does not -# inherit this one. The event is filled in once the payload is read (it names -# the additionalContext block; until then the notice is systemMessage only), -# and the trap is released right before the deliberate aggregated exit, so a -# non-block status a guard returned still surfaces exactly as it did. +# EVERY guard of the event with a bare status and no line of its own. The +# event is filled in once the payload is read (it names the additionalContext +# block; until then the notice is systemMessage only), and the trap is +# released right before the deliberate aggregated exit, so a non-block status +# a guard returned still surfaces exactly as it did. guard::abort_boundary run-guards "" open 0 2 # shellcheck source=hook-utils.sh source "$_RG_DIR/hook-utils.sh" || exit 70 # not a chosen status: the boundary reports it @@ -101,8 +128,7 @@ while (($#)); do # Bash payload the classifier's first real statement is # `[[ "$tool" == "PowerShell" ]] || return 0`, so loading ~41 KB here # was a pure tax on the common path. The include guard still makes every - # later `source` a no-op, so the parse is paid once per PowerShell fire - # instead of once per isolation subshell. + # later `source` a no-op, so the parse is paid once per PowerShell fire. LIBS+=("$2") shift 2 ;; @@ -115,16 +141,6 @@ done ((${#GUARDS[@]})) || exit 0 # --- 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` would add an 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 - RUN_GUARDS_PRIMED=0 declare -A RUN_GUARDS_FIELD=() # The union of every field the guards of this plugin declared in @@ -135,10 +151,10 @@ declare -A RUN_GUARDS_FIELD=() # # Both directions cost. The cached hook::jq_fields below is all-or-nothing per # call, so ONE declared field missing here sends every call of that lane to an -# uncached jq spawn — `.tool_input.path` (the GitHub MCP write lane's file +# uncached extraction — `.tool_input.path` (the GitHub MCP write lane's file # path) was measured doing exactly that, two extra spawns per Write/Edit, # 50 ms to 60 ms on the reference host. A field here that no guard declares is -# the opposite: jq work on every payload of every lane that nothing reads. +# the opposite: parse work on every payload of every lane that nothing reads. PRIME_FILTERS=( '.tool_input.command' '.tool_name' '.cwd' '.tool_input.file_path' '.tool_input.notebook_path' '.tool_input.path' @@ -149,8 +165,8 @@ 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. +# makes the completeness check and the field extract one step: the library's +# builtin parser on the common payload, one jq process on one it cannot prove. # # Dest is initialized here so ShellCheck SC2154 sees the assignment. # printf -v through a nameref inside hook::buffer_stdin_to is a dynamic @@ -206,12 +222,14 @@ if ((RUN_GUARDS_STDIN_RC == 0)) && RUN_GUARDS_FIELD["${PRIME_FILTERS[_rg_i]}"]="${HOOK_JQ_FIELDS[_rg_i]}" done unset _rg_i - # The event name rides in the same jq process and lets the dispatcher's own + # The event name rides in the same extraction and lets the dispatcher's own # abort notice name the event. [[ "${RUN_GUARDS_FIELD['.hook_event_name']-}" =~ ^[A-Za-z]+$ ]] && _GAB_EVENT="${RUN_GUARDS_FIELD['.hook_event_name']}" fi +# The cache in front of the library's extractor. The miss path is the +# library's own function under its second name, so nothing is copied here. # shellcheck disable=SC2329 # invoked by every guard sourced below hook::jq_fields() { local input="$1" @@ -237,7 +255,7 @@ hook::jq_fields() { # --- PowerShell classifier, once, and only on that tool ----------------------- # A Bash payload must not parse ps-command.sh at all; an unprimed payload still -# loads it so a PowerShell command whose jq cache missed cannot reach a guard +# loads it so a PowerShell command whose field cache missed cannot reach a guard # with `ps::` unbound. # # `--lib` in hooks.json is the wiring's cue that this event's guards declare a @@ -266,33 +284,150 @@ unset _rg_tool # --- run ---------------------------------------------------------------------- RC=0 OUTS=() -for guard in "${GUARDS[@]}"; do - case "$guard" in - */*) path="$guard" ;; - *) path="$HOOK_DIR/$guard" ;; - esac - if [[ ! -f "$path" ]]; then - echo "run-guards: guard not found: $path" >&2 - ((RC < 1)) && RC=1 - continue +# The guard being run: an index into GUARDS, or -1 while the dispatcher's own +# code runs (before the first guard, and again from the merge onward). The +# EXIT-trap handler below reads it to tell a guard's hard error from the +# dispatcher's own abort. +RUN_GUARDS_CUR=-1 +# The process the chain runs in. `exit` below compares BASHPID against it: a +# guard's `exit` inside a real subshell must end that subshell, not run the +# next guard there. +RUN_GUARDS_PID=$BASHPID +RUN_GUARDS_T0="" + +# Every document a guard emits is collected, not printed (hook-utils.sh, +# hook::emit_document). +# shellcheck disable=SC2329 # invoked by every guard sourced below +hook::emit_document() { + OUTS+=("$1") +} + +# `exit` for the sourced guards. The status a guard exits with is recorded +# and the chain continues from inside this call, so control never returns to +# the guard. No argument means the status of the guard's last command, as the +# builtin does. +# shellcheck disable=SC2329 # invoked by every guard sourced below +exit() { + local __rg_rc="${1:-$?}" + [[ "$BASHPID" == "$RUN_GUARDS_PID" ]] || builtin exit "$__rg_rc" + ((RUN_GUARDS_CUR >= 0)) || builtin exit "$__rg_rc" + run_guards::guard_done "$__rg_rc" +} + +# A guard died of a hard error: the shell is exiting, and the EXIT trap the +# guard installed through guard::abort_boundary handed the status here (the +# library's chain slot, _GAB_CONTINUE, set right before the first guard runs +# and cleared before the merge). A deliberate `exit` in a guard never comes +# this way; that is the function above. Never returns. +# shellcheck disable=SC2329 # invoked through _GAB_CONTINUE by the trap handler +run_guards::guard_died() { + RUN_GUARDS_DYING=1 + run_guards::guard_done "$1" +} +RUN_GUARDS_DYING=0 + +# run_guards::record : the guard at RUN_GUARDS_CUR ended with . Applies +# its abort boundary (when it installed one: a stub that never called +# guard::abort_boundary keeps its raw status, as it did in its own subshell, +# which had no trap), collects the notice document, prints the profile line, +# and folds the status into RC. +run_guards::record() { + local __rg_rc="$1" __rg_path="${GUARDS[RUN_GUARDS_CUR]}" + if [[ -n "$_GAB_NAME" ]] && ! guard::_abort_settle "$__rg_rc"; then + [[ -n "$_GAB_DOC" ]] && OUTS+=("$_GAB_DOC") + __rg_rc=$_GAB_RC fi - rc=0 - t0=${EPOCHREALTIME:-0} - # shellcheck disable=SC1090 - guard_out=$(source "$path" &2 + if [[ -n "${RUN_GUARDS_PROFILE:-}" && -n "${EPOCHREALTIME:-}" && -n "$RUN_GUARDS_T0" ]]; then + local __rg_t1=$EPOCHREALTIME + printf 'run-guards: %5d ms rc=%d %s\n' "$(((${__rg_t1/./} - ${RUN_GUARDS_T0/./}) / 1000))" "$__rg_rc" "${__rg_path##*/}" >&2 fi - [[ -n "$guard_out" ]] && OUTS+=("$guard_out") - if ((rc == 2)); then + if ((__rg_rc == 2)); then RC=2 - elif ((rc != 0 && RC != 2 && rc > RC)); then - RC=$rc + elif ((__rg_rc != 0 && RC != 2 && __rg_rc > RC)); then + RC=$__rg_rc fi -done +} + +# run_guards::guard_done : record the guard that just ended and run the +# rest. Never returns. +run_guards::guard_done() { + run_guards::record "$1" + if ((RUN_GUARDS_DYING)); then + run_guards::run_rest_in_subshell "$((RUN_GUARDS_CUR + 1))" + run_guards::finish + fi + run_guards::run_from "$((RUN_GUARDS_CUR + 1))" +} + +# run_guards::run_from : run the guards from on, then finish. +# Never returns: a guard's `exit` continues the chain from inside the call, +# and a guard that falls off its end is recorded right here. +run_guards::run_from() { + local __rg_i + for ((__rg_i = $1; __rg_i < ${#GUARDS[@]}; __rg_i++)); do + RUN_GUARDS_CUR=$__rg_i + local __rg_path + case "${GUARDS[__rg_i]}" in + */*) __rg_path="${GUARDS[__rg_i]}" ;; + *) __rg_path="$HOOK_DIR/${GUARDS[__rg_i]}" ;; + esac + if [[ ! -f "$__rg_path" ]]; then + echo "run-guards: guard not found: $__rg_path" >&2 + ((RC < 1)) && RC=1 + continue + fi + # The guard's own boundary sets this; a guard that never installs one is + # recorded on its raw status. + _GAB_NAME="" + # A fresh hook process has no alias memo; the guard before this one may + # have armed it on the same command, and a memo hit is "already analyzed". + hook::reset_analysis_state + RUN_GUARDS_T0=${EPOCHREALTIME:-} + # shellcheck disable=SC1090 + source "$__rg_path" RS ... RSRS RC=. RS (0x1e) cannot occur inside a document (JSON +# escapes every control byte). Inside the subshell the chain is its own +# process, so RUN_GUARDS_PID moves with it and a further hard error there +# costs one more subshell from its own trap, never the result. +run_guards::run_rest_in_subshell() { + local __rg_rest="" __rg_doc + # shellcheck disable=SC2030 # the subshell's own OUTS and RC come back on its stdout + __rg_rest=$( + RUN_GUARDS_PID=$BASHPID + RUN_GUARDS_DYING=0 + RUN_GUARDS_REPORT=1 + OUTS=() + RC=0 + run_guards::run_from "$1" + ) + while [[ "$__rg_rest" == *$'\x1e'* ]]; do + __rg_doc=${__rg_rest%%$'\x1e'*} + __rg_rest=${__rg_rest#*$'\x1e'} + # shellcheck disable=SC2031 # this is the parent's OUTS, fed from the report + OUTS+=("$__rg_doc") + done + if [[ "$__rg_rest" =~ ^RC=([0-9]+)$ ]]; then + __rg_doc=${BASH_REMATCH[1]} + if ((__rg_doc == 2)); then + RC=2 + elif ((__rg_doc != 0 && RC != 2 && __rg_doc > RC)); then + RC=$__rg_doc + fi + else + echo "run-guards: the guards after a hard error reported no status; their verdict is lost" >&2 + ((RC < 1)) && RC=1 + fi +} +RUN_GUARDS_REPORT=0 # Several documents and no way to merge them: a hook process may emit exactly # ONE JSON document, so pick one. A blocking decision must not be lost, so a @@ -320,32 +455,51 @@ run_guards::emit_one() { done } -if ((${#OUTS[@]} == 1)); then - printf '%s\n' "${OUTS[0]}" -elif ((${#OUTS[@]} > 1)); then - if ! command -v jq >/dev/null 2>&1; then - run_guards::emit_one "without jq" - else - merged=$(printf '%s\n' "${OUTS[@]}" | jq -cs ' - { hookSpecificOutput: { - hookEventName: (map(.hookSpecificOutput.hookEventName // empty) | .[0] // ""), - additionalContext: (map(.hookSpecificOutput.additionalContext // empty) | join("\n\n")) }, - systemMessage: (map(.systemMessage // empty) | join("\n\n")) } - | if .systemMessage == "" then del(.systemMessage) else . end - | if .hookSpecificOutput.additionalContext == "" then del(.hookSpecificOutput) else . end - | if . == {} then empty else . end' 2>/dev/null) - # The Windows jq build writes CRLF; a raw CR never belongs in a JSON document. - merged="${merged//$'\r'/}" - if [[ -n "$merged" ]]; then - printf '%s\n' "$merged" +# run_guards::finish: emit the aggregated result and exit. Never returns. +run_guards::finish() { + RUN_GUARDS_CUR=-1 + _GAB_CONTINUE="" + if ((RUN_GUARDS_REPORT)); then + local __rg_doc + for __rg_doc in ${OUTS[@]+"${OUTS[@]}"}; do + printf '%s\x1e' "$__rg_doc" + done + printf 'RC=%d' "$RC" + builtin exit 0 + fi + # The dispatcher's own boundary again, for the merge below: the last guard's + # boundary is what the trap holds at this point. + guard::abort_boundary run-guards "$_GAB_EVENT" open 0 2 + if ((${#OUTS[@]} == 1)); then + printf '%s\n' "${OUTS[0]}" + elif ((${#OUTS[@]} > 1)); then + if ! command -v jq >/dev/null 2>&1; then + run_guards::emit_one "without jq" else - run_guards::emit_one "(merge failed)" + local merged + merged=$(printf '%s\n' "${OUTS[@]}" | jq -cs ' + { hookSpecificOutput: { + hookEventName: (map(.hookSpecificOutput.hookEventName // empty) | .[0] // ""), + additionalContext: (map(.hookSpecificOutput.additionalContext // empty) | join("\n\n")) }, + systemMessage: (map(.systemMessage // empty) | join("\n\n")) } + | if .systemMessage == "" then del(.systemMessage) else . end + | if .hookSpecificOutput.additionalContext == "" then del(.hookSpecificOutput) else . end + | if . == {} then empty else . end' 2>/dev/null) + # The Windows jq build writes CRLF; a raw CR never belongs in a JSON document. + merged="${merged//$'\r'/}" + if [[ -n "$merged" ]]; then + printf '%s\n' "$merged" + else + run_guards::emit_one "(merge failed)" + fi fi fi -fi + # The aggregated status is the dispatcher's deliberate answer, whatever number + # it is (a guard-not-found 1 is already loud on stderr above; a stub's 3 is the + # contract test's). Release the boundary so it is not reported as an abort. + guard::abort_boundary_release + builtin exit "$RC" +} -# The aggregated status is the dispatcher's deliberate answer, whatever number -# it is (a guard-not-found 1 is already loud on stderr above; a stub's 3 is the -# contract test's). Release the boundary so it is not reported as an abort. -guard::abort_boundary_release -exit "$RC" +_GAB_CONTINUE=run_guards::guard_died +run_guards::run_from 0 diff --git a/plugins/guardrails/hooks/run-guards.test.sh b/plugins/guardrails/hooks/run-guards.test.sh index 129fd757bf..47c09394dd 100755 --- a/plugins/guardrails/hooks/run-guards.test.sh +++ b/plugins/guardrails/hooks/run-guards.test.sh @@ -69,8 +69,8 @@ stub lib.sh 'printf "ps=%s\n" "${_GUARDRAILS_PS_COMMAND_LOADED:-unset}" >>"'"$SE stub dirname.sh 'printf "%s %s\n" "$(type -t dirname)" "$(dirname /foo)" >>"'"$SEEN"'"' # Raw documents (no library call) so the no-jq merge fallback is exercised on # the shapes it has to recognise, not on what hook::emit_channels happens to build. -stub deny.sh 'printf "%s\n" "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"stub deny\"}}"' -stub ask.sh 'printf "%s\n" "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\": \"ask\"}}"' +stub deny.sh 'hook::emit_document "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"stub deny\"}}"' +stub ask.sh 'hook::emit_document "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\": \"ask\"}}"' PAYLOAD=$(jq -n '{session_id:"s-1",tool_name:"Bash",cwd:"/x",tool_input:{command:"git status --short"}}') @@ -255,12 +255,14 @@ bare_guard_rc=0 (cd "$HOOK_DIR" && bash block-no-verify.sh <<<"$PAYLOAD" >/dev/null) || bare_guard_rc=$? assert_exit "bare block-no-verify.sh from hooks/ exits 0" 0 "$bare_guard_rc" -# --- benign Bash lane: no dirname/sed exec on the dispatched hot path ---------- +# --- benign Bash lane: no exec and no fork on the dispatched hot path --------- # 0.32.6: every always-on Bash guard used `source "$(dirname …)/hook-utils.sh"` # and the dispatcher copied hook::jq_fields through sed. Those were 7 dirname # execs plus one sed on a benign `git status --short` (flag-commit-pr-skill-bypass -# is default-off and exits before source). PATH shims count execs; function -# forks are invisible to them, which is the same instrument as spawn-census.sh. +# is default-off and exits before source). The one jq that remained went with +# the library's builtin field parser. PATH shims count execs; function forks +# are invisible to them, which is the same instrument as spawn-census.sh, so +# the fork count is pinned separately below through BASHPID in the xtrace. SHIM="$TEST_TMPDIR/spawn-shim" mkdir -p "$SHIM" SPAWN_LOG="$SHIM/spawns.log" @@ -280,18 +282,25 @@ if [[ -x "$SHIM/dirname" && -x "$SHIM/sed" && -x "$SHIM/jq" ]]; then 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 spends one jq and neither dirname nor sed" "jq" "$(cat "$SPAWN_LOG")" + assert_eq "benign Bash dispatcher spends no jq, dirname or sed" "" "$(cat "$SPAWN_LOG")" fi -DISPATCH_XTRACE=$(bash -x "$DISPATCH" --lib lib/powershell/ps-command.sh \ +DISPATCH_XTRACE=$(PS4='+PID=$BASHPID ' bash -x "$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" 2>&1 >/dev/null) || true assert_absent "benign Bash dispatcher never sources ps-command.sh" \ "$DISPATCH_XTRACE" "ps-command.sh" +# Every traced command ran under ONE BASHPID: no guard ran in a subshell, and +# no helper on the path forked for a capture. Every distinct PID in the trace +# is a process creation on Windows Git Bash. +assert_eq "benign Bash dispatcher forks no subshell (one BASHPID in the xtrace)" \ + "1" "$(grep -o 'PID=[0-9]*' <<<"$DISPATCH_XTRACE" | sort -u | wc -l | tr -d ' ')" DISPATCH_SRC=$(cat "$DISPATCH") -assert_absent "dispatcher copies jq_fields without a sed pipeline" "$DISPATCH_SRC" '| sed' -assert_contains "dispatcher copies jq_fields via parameter expansion" "$DISPATCH_SRC" 'hook::jq_fields_uncached ()' +assert_absent "dispatcher does not copy the library's jq_fields (the lib names its uncached form)" \ + "$DISPATCH_SRC" 'declare -f' +assert_absent "dispatcher sources no guard in a command substitution" \ + "$DISPATCH_SRC" '$(source' for g in block-no-verify block-dangerous-git block-hook-bypass \ flag-commit-pr-skill-bypass block-noncanonical-commit \ block-convention-violation block-windows-drive-tmp block-exported-msys-pathconv \ @@ -381,6 +390,85 @@ assert_exit "missing guard surfaces as rc 1" 1 "$RC" assert_contains "missing guard is named" "$ERR" "guard not found" assert_eq "the other guard still ran" $'git status --short\nBash' "$(cat "$SEEN")" +# --- in-process chain: a guard's exit ends the guard, never the dispatcher --- +# The guards are sourced into the dispatcher's own shell, so `exit` is a +# function there. These pin the shapes that function must get right: an exit +# from inside nested functions, an exit that runs in a real subshell (which +# must end that subshell only), a guard that falls off its end, `exit` with no +# argument, and a guard that dies of a hard error with its boundary installed, +# once and twice in one event. +stub nested.sh 'g() { echo nested >>"'"$SEEN"'"; exit 2; } +f() { g; echo NOTREACHED >>"'"$SEEN"'"; } +f +echo NOTREACHED2 >>"'"$SEEN"'"' +run "$PAYLOAD" "$TEST_TMPDIR/nested.sh" "$TEST_TMPDIR/allow.sh" +assert_exit "exit from a nested function ends that guard with its status" 2 "$RC" +assert_eq "nothing after a nested exit runs, and the next guard still does" \ + $'nested\ngit status --short\nBash' "$(cat "$SEEN")" + +stub subexit.sh 'v=$(exit 5); printf "sub=%s\n" "$?" >>"'"$SEEN"'" +( exit 6 ); printf "grp=%s\n" "$?" >>"'"$SEEN"'" +echo x | { read -r _; exit 7; }; printf "pipe=%s\n" "$?" >>"'"$SEEN"'" +exit 0' +run "$PAYLOAD" "$TEST_TMPDIR/subexit.sh" "$TEST_TMPDIR/allow.sh" +assert_exit "exit inside a subshell ends the subshell only" 0 "$RC" +assert_eq "subshell exits keep their status and run the chain nowhere else" \ + $'sub=5\ngrp=6\npipe=7\ngit status --short\nBash' "$(cat "$SEEN")" + +stub fall.sh 'echo fall >>"'"$SEEN"'"; false' +run "$PAYLOAD" "$TEST_TMPDIR/fall.sh" "$TEST_TMPDIR/allow.sh" +assert_exit "a guard that falls off its end is recorded on its last status" 1 "$RC" +assert_eq "the guard after a fall-through still ran" $'fall\ngit status --short\nBash' "$(cat "$SEEN")" + +stub noarg.sh 'false; exit' +run "$PAYLOAD" "$TEST_TMPDIR/noarg.sh" +assert_exit "exit with no argument carries the last command's status" 1 "$RC" + +hard_stub() { # : a guard with its boundary that dies of an unbound variable + stub "$1" 'source "'"$HOOK_DIR"'/abort-boundary.sh" +guard::abort_boundary '"${1%.sh}"' PreToolUse '"$2"' 0 2 +echo '"${1%.sh}"' >>"'"$SEEN"'" +: "${RUN_GUARDS_TEST_UNBOUND?forced abort}" +echo NOTREACHED >>"'"$SEEN"'"' +} +hard_stub hard.sh open +hard_stub hard2.sh open +hard_stub hardc.sh closed +run "$PAYLOAD" "$TEST_TMPDIR/hard.sh" "$TEST_TMPDIR/allow.sh" "$TEST_TMPDIR/block.sh" +assert_exit "hard error: the guards after it still run and a block still wins" 2 "$RC" +assert_contains "hard error: the boundary names the guard on stderr" "$ERR" "guardrails hard: guard did not run" +assert_eq "hard error: the later guards ran once each" $'hard\ngit status --short\nBash' "$(cat "$SEEN")" +assert_contains "hard error: the notice document is emitted" "$(jq -r '.systemMessage' <<<"$OUT")" "guardrails hard:" +run "$PAYLOAD" "$TEST_TMPDIR/hard.sh" "$TEST_TMPDIR/hard2.sh" "$TEST_TMPDIR/allow.sh" +assert_exit "two hard errors: the open posture exits 0" 0 "$RC" +assert_eq "two hard errors: exactly one JSON document on stdout" "1" "$(jq -s 'length' <<<"$OUT")" +two_hard=$(jq -r '.systemMessage' <<<"$OUT") +assert_contains "two hard errors: the first notice is in the merged document" "$two_hard" "guardrails hard:" +assert_contains "two hard errors: the second notice is in the merged document" "$two_hard" "guardrails hard2:" +assert_eq "two hard errors: every guard ran once" $'hard\nhard2\ngit status --short\nBash' "$(cat "$SEEN")" +run "$PAYLOAD" "$TEST_TMPDIR/hardc.sh" "$TEST_TMPDIR/allow.sh" +assert_exit "hard error, closed posture: denies" 2 "$RC" +assert_contains "hard error, closed posture: the fail-closed line is on stderr" "$ERR" "fail-closed" +assert_silent "hard error, closed posture: no stdout document" "$OUT" +assert_eq "hard error, closed posture: the next guard still ran" $'hardc\ngit status --short\nBash' "$(cat "$SEEN")" + +# Two real guards walk the same alias chain in one process. The alias memo in +# hook::git_alias_admit is per invocation: a memo hit means "already analyzed, +# skip". Left armed from block-dangerous-git's walk, it answered +# block-noncanonical-commit's walk of the same chain and that guard's +# --config-env refusal never fired. The dispatcher resets the analysis state +# before each guard, and this pins that both reasons reach stderr, as they did +# when each guard had its own process. +ALIAS_CMD='git -c "alias.sh=!git --config-env=alias.c=AV c --allow-empty -m x" sh' +alias_alone_rc=0 +alias_alone_err=$(bash "$HOOK_DIR/block-noncanonical-commit.sh" <<<"$(command_json "$ALIAS_CMD")" 2>&1 >/dev/null) || alias_alone_rc=$? +assert_exit "alias chain: block-noncanonical-commit alone denies" 2 "$alias_alone_rc" +run "$(command_json "$ALIAS_CMD")" block-dangerous-git.sh block-noncanonical-commit.sh +assert_exit "alias chain: dispatched pair denies" 2 "$RC" +assert_contains "alias chain: the first guard's reason is on stderr" "$ERR" "block_dangerous_git_enabled" +assert_contains "alias chain: the second guard's reason is on stderr too (its memo was reset)" \ + "$ERR" "$(head -1 <<<"$alias_alone_err")" + # --- a real guard decides the same inside the dispatcher as alone ------------ bypass=$(command_json 'git commit --no-verify -m x') alone_rc=0 diff --git a/plugins/instruction-placement/.claude-plugin/plugin.json b/plugins/instruction-placement/.claude-plugin/plugin.json index 974265d889..da711dcea4 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.13.10", + "version": "0.13.11", "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, meaning conventions keyed to one file type or one subtree sitting in an always-loaded CLAUDE.md or AGENTS.md, 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: a deferred surface is absent until a read matches it, absent after a compaction until that trigger recurs, and never inherited by a subagent, which re-acquires it only by reading a covered path itself. Every accepted move regenerates an always-loaded index of deferred surfaces, which is what keeps a demoted rule discoverable from any context that has not happened to touch a path it covers. 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: 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 c56cd1f233..1560240f49 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -3,6 +3,12 @@ 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.13.11] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.13.10] ### Changed diff --git a/plugins/instruction-placement/hooks/hook-utils.sh b/plugins/instruction-placement/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/instruction-placement/hooks/hook-utils.sh +++ b/plugins/instruction-placement/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index 375ebf26e4..0c341e6c2c 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.58", + "version": "0.11.59", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2, 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 2e44dad1a8..55c356aeab 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,12 @@ 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.59] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.11.58] ### Changed diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index 514e7cbe85..49e5018448 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.51", + "version": "0.7.52", "description": "Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo, 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 a3bc46588e..f7f0139c7a 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,12 @@ 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.52] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.7.51] ### Changed diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index 2bf692e1c8..4ad05d340c 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.19", + "version": "0.8.20", "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 263ca40158..14c99d2cca 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,12 @@ 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.20] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.8.19] ### Changed diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index 6408f2708a..81d84ca8a2 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.49", + "version": "0.6.50", "description": "Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo, 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 e99a5a1946..d13fb0d612 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,12 @@ 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.50] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.6.49] ### Changed diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 13071d6e50..a5032fae76 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.88", + "version": "0.55.89", "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, 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, 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, which interviews the repo and writes the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep, 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 b84eb6ec6c..9324215eec 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,12 @@ 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.89] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.55.88] ### Changed diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/source-control/hooks/hook-utils.sh +++ b/plugins/source-control/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process diff --git a/plugins/typos-format/.claude-plugin/plugin.json b/plugins/typos-format/.claude-plugin/plugin.json index fdba2e5919..9a79928bc7 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.54", + "version": "0.6.55", "description": "Spell-check on edit via typos-cli, unconditionally. 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 121392721e..6b4a691691 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -3,6 +3,12 @@ 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.55] + +### Changed + +- hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. + ## [0.6.54] ### Changed diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index ebe48a4648..0b66f9e041 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -126,7 +126,19 @@ hook::emit_channels() { out+='"systemMessage":"'"$__hu_es"'"' fi out+="}" - printf '%s\n' "$out" + hook::emit_document "$out" +} + +# hook::emit_document : write ONE hook JSON document to stdout. Every +# document a hook emits goes through here, hook::emit_channels included, so a +# dispatcher that runs several hooks in one process (guardrails run-guards.sh) +# can override this one function to collect the documents and merge them, +# instead of capturing each hook's stdout in a subshell. A hook that prints a +# document with its own printf bypasses that collection: under such a +# dispatcher its document reaches stdout unmerged, which is invalid hook +# output when another hook emitted too. +hook::emit_document() { + printf '%s\n' "$1" } # Visible skip notice: the same message on both channels. The caller must exit 0 @@ -811,8 +823,19 @@ hook::_json_split() { # byte, which jq rejects. _HOOK_JSON_SK="" _HOOK_JSON_OFF=() +# The last text and its verdict. A hook that primes several fields and then +# reads its file path asks for the same payload's skeleton twice in one +# process; the parts, offsets and skeleton are still those of that text, so the +# second ask is a string comparison rather than a second walk. +_HOOK_JSON_SK_TEXT="" +_HOOK_JSON_SK_RC="" hook::_json_skeleton() { local __hu_s="$1" __hu_i __hu_n __hu_part __hu_off=0 __hu_sk="" __hu_rest __hu_tok __hu_stack="" __hu_expect=value __hu_top __hu_esc __hu_c + if [[ -n "$_HOOK_JSON_SK_RC" && "$__hu_s" == "$_HOOK_JSON_SK_TEXT" ]]; then + return "$_HOOK_JSON_SK_RC" + fi + _HOOK_JSON_SK_TEXT=$__hu_s + _HOOK_JSON_SK_RC=1 hook::_json_split "$__hu_s" || return 1 __hu_n=${#_HOOK_JSON_PARTS[@]} _HOOK_JSON_OFF=() @@ -912,6 +935,7 @@ hook::_json_skeleton() { done [[ "$__hu_expect" == end ]] || return 1 _HOOK_JSON_SK=${__hu_sk//[$' \t\n\r']/} + _HOOK_JSON_SK_RC=0 return 0 } @@ -1030,6 +1054,134 @@ hook::_fast_file_path_to() { return 0 } +# hook::_fast_fields ... +# The builtin answer to hook::jq_fields' jq program for the filters that +# program is usually given: `.key` and `.key.sub`, identifier keys only. +# Returns +# 0 proven: HOOK_JQ_FIELDS holds, per filter, exactly what jq prints for +# `(() // "" | tostring)` with CR stripped, and HOOK_JQ_FIELDS_NUL +# is 0 (a NUL escape in any requested value is a proof failure) +# 2 not proven: run jq +# Any other filter shape is not proven. Proof, on top of hook::_json_skeleton's +# structural checks (which include every escape jq accepts and no raw control +# byte): the root is an object; every key named by a filter decodes from +# exactly ONE string in the whole payload, so neither a duplicate key nor a +# same-named key in another object nor a value spelled like the key can change +# jq's answer; a top-level key is a direct member of the root; a nested key's +# parent is a flat object (no container inside it, else jq); and each +# requested value is a plain string or null, or the key is absent. A number, +# boolean, object or array value is handed to jq for its `tostring`, a string +# whose escapes hook::json_unescape_to does not decode (a NUL, a \u past +# U+007F) likewise. Key strings are compared after decoding, so a key spelled +# with \u escapes is still recognized; a body longer than any escaped spelling +# of a key name is skipped without decoding. +hook::_fast_fields() { + local __hu_s="$1" + shift + local -a __hu_k1=() __hu_k2=() + local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j + local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" + for __hu_f in "$@"; do + [[ "$__hu_f" =~ $__hu_re ]] || return 2 + __hu_k1+=("${BASH_REMATCH[1]}") + __hu_k2+=("${BASH_REMATCH[3]}") + done + hook::_json_skeleton "$__hu_s" || return 2 + [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 + # Every string body that decodes to a requested key name, by name: the part + # index, or -1 once a second body decodes to the same name. + local -A __hu_idx=() + local -A __hu_want=() + for __hu_f in "${__hu_k1[@]}" "${__hu_k2[@]}"; do + [[ -n "$__hu_f" ]] && __hu_want[$__hu_f]=1 + done + __hu_n=${#_HOOK_JSON_PARTS[@]} + for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do + __hu_part=${_HOOK_JSON_PARTS[__hu_i]} + ((${#__hu_part} <= 60)) || continue + __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} + if [[ "$__hu_body" == *\\* ]]; then + hook::json_unescape_to __hu_body "$__hu_body" || continue + fi + [[ -n "$__hu_body" && -n "${__hu_want[$__hu_body]+x}" ]] || continue + if [[ -n "${__hu_idx[$__hu_body]+x}" ]]; then + __hu_idx[$__hu_body]=-1 + else + __hu_idx[$__hu_body]=$__hu_i + fi + done + local -a __hu_vals=() + local __hu_ti __hu_fi __hu_pre __hu_o1 __hu_o2 __hu_c1 __hu_c2 __hu_depth __hu_tok + for ((__hu_j = 0; __hu_j < ${#__hu_k1[@]}; __hu_j++)); do + __hu_ti=${__hu_idx[${__hu_k1[__hu_j]}]--2} + ((__hu_ti != -1)) || return 2 + if ((__hu_ti == -2)); then + __hu_vals+=("") # no string in the payload spells the key: absent + continue + fi + # The key must be a direct member of the root: a key position at depth + # exactly one. Anywhere else (a value, a deeper key) the root has no such + # member, and the unique spelling means nothing else could be one. + __hu_re="(^|[{,])\"#$__hu_ti\":(\"#[0-9]+\"|\\{[^][{}]*\\}|null|true|false|[-0-9][^,}]*|\\[|\\{)" + if ! [[ "$_HOOK_JSON_SK" =~ $__hu_re ]]; then + __hu_vals+=("") + continue + fi + __hu_tok=${BASH_REMATCH[2]} + __hu_pre=${_HOOK_JSON_SK%%\"#"$__hu_ti"\"*} + __hu_o1=${__hu_pre//\{/} + __hu_o2=${__hu_pre//\[/} + __hu_c1=${__hu_pre//\}/} + __hu_c2=${__hu_pre//\]/} + __hu_depth=$(((${#__hu_pre} - ${#__hu_o1}) + (${#__hu_pre} - ${#__hu_o2}) - (${#__hu_pre} - ${#__hu_c1}) - (${#__hu_pre} - ${#__hu_c2}))) + if ((__hu_depth != 1)); then + __hu_vals+=("") + continue + fi + if [[ -z "${__hu_k2[__hu_j]}" ]]; then + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + else + case "$__hu_tok" in + null) __hu_vals+=("") && continue ;; + \{*\}) ;; # a flat object: its members are the only place the key can be + *) return 2 ;; # a string, number, boolean, array, or an object with a container inside + esac + __hu_fi=${__hu_idx[${__hu_k2[__hu_j]}]--2} + ((__hu_fi != -1)) || return 2 + if ((__hu_fi == -2)); then + __hu_vals+=("") + continue + fi + __hu_body=${__hu_tok:1:${#__hu_tok}-2} + __hu_re="(^|,)\"#$__hu_fi\":(\"#[0-9]+\"|null|true|false|[-0-9][^,]*)(,|\$)" + if ! [[ "$__hu_body" =~ $__hu_re ]]; then + __hu_vals+=("") # not a key of the parent; unique, so not one anywhere + continue + fi + __hu_tok=${BASH_REMATCH[2]} + case "$__hu_tok" in + \"#*) __hu_raw=${__hu_tok:2:${#__hu_tok}-3} ;; + null) __hu_vals+=("") && continue ;; + *) return 2 ;; + esac + fi + __hu_val=${__hu_s:${_HOOK_JSON_OFF[__hu_raw]}:${#_HOOK_JSON_PARTS[__hu_raw]}} + if [[ "$__hu_val" == *\\* ]]; then + hook::json_unescape_to __hu_val "$__hu_val" || return 2 + fi + __hu_val=${__hu_val//$'\r'/} + __hu_vals+=("$__hu_val") + done + HOOK_JQ_FIELDS=("${__hu_vals[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 +} + # hook::dirname_to : dirname with builtins for the resolver's # answer. Strips the last segment; a bare name lives in `.`, a root-level file # in `/`. The path comes from realpath, so the trailing-slash and doubled-slash @@ -1935,14 +2087,34 @@ hook::jq_field() { # hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 # if ((HOOK_JQ_FIELDS_NUL)); then echo "BLOCKED: …" >&2; exit 2; fi # COMMAND="${HOOK_JQ_FIELDS[0]}" TOOL_NAME="${HOOK_JQ_FIELDS[1]}" +# +# The jq process is the last resort, not the first. hook::_fast_fields answers +# the common shape (a well-formed payload whose requested fields are plain +# strings under unique keys) with builtins and hands anything it cannot prove +# to jq, so a hook that reads `.tool_input.command` and `.tool_name` from an +# ordinary Bash payload spawns nothing. The two entry points are one function: +# hook::jq_fields is the name every hook calls, and hook::jq_fields_uncached is +# the same body under the name a dispatcher that puts a per-event cache in +# front of it (guardrails run-guards.sh) falls through to on a miss. Overriding +# hook::jq_fields alone therefore never loses the library's own path. # shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file hook::jq_fields() { + hook::jq_fields_uncached "$@" +} + +# shellcheck disable=SC2034 # result globals are consumed by the sourcing hook, not this file +hook::jq_fields_uncached() { local input="$1" shift HOOK_JQ_FIELDS=() HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 + if hook::_fast_fields "$input" "$@"; then + return 0 + fi + HOOK_JQ_FIELDS=() + HOOK_JQ_FIELDS_NUL=0 local prog="" filter for filter in "$@"; do [[ -n "$prog" ]] && prog+="," @@ -2418,11 +2590,18 @@ hook::finish() { # (e.g. the whole command is `TOKEN=ghp_…`) is likewise a value the subject must # not carry, so a resolved token still shaped like a NAME=value assignment aborts # to the bare "Bash" subject too. -# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") +# hook::extract_bash_subject_to SUBJECT "$TOOL" "$CMD" # in this shell +# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD") # print form: a fork hook::extract_bash_subject() { - local tool="$1" cmd="${2:-}" + local __hu_subject + hook::extract_bash_subject_to __hu_subject "$1" "${2:-}" + printf '%s' "$__hu_subject" +} + +hook::extract_bash_subject_to() { + local __hu_dest="$1" tool="$2" cmd="${3:-}" if [[ "$tool" != "Bash" ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # Trim leading whitespace so the first token is real. @@ -2433,7 +2612,7 @@ hook::extract_bash_subject() { # A quote in the prefix token means a quoted value spans the next whitespace; # we cannot tokenize it safely — bail rather than leak a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi cmd="${cmd#*[[:space:]]}" @@ -2443,7 +2622,7 @@ hook::extract_bash_subject() { # The resolved command token itself must not carry a quote (e.g. a value that # ended here), which would likewise be a value fragment. if [[ "$first_token" == *[\"\']* ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi # A resolved token still shaped like a bare/trailing assignment (no following @@ -2456,14 +2635,14 @@ hook::extract_bash_subject() { # no-close-bracket class would miss. This runs BEFORE the basename strip so # a path-valued assignment (TOKEN=/a/b/secret) cannot lose its "=" first. if [[ "$first_token" =~ ^[a-zA-Z_][a-zA-Z0-9_]*(\[.*\])?\+?= ]]; then - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" return 0 fi first_token="${first_token##*/}" if [[ -n "$first_token" ]]; then - printf 'Bash:%s' "$first_token" + printf -v "$__hu_dest" 'Bash:%s' "$first_token" else - printf '%s' "$tool" + printf -v "$__hu_dest" '%s' "$tool" fi } @@ -3606,6 +3785,21 @@ hook::git_alias_admit() { return 2 } +# hook::reset_analysis_state: forget the per-invocation analysis state, so the +# next hook to run in this process starts as a fresh hook process would. That +# state is the alias memo and budget above (armed on first use, and a memo hit +# means "already analyzed: skip"), the two seen-sets, and the effective base +# the alias walk carries. A hook run alone never needs this; a dispatcher that +# sources several hooks into one shell (guardrails run-guards.sh) calls it +# before each, because otherwise the second hook to walk the same alias chain +# is answered by the first hook's memo and skips the analysis it owes. The +# keyed caches (physical paths, the JSON skeleton) are not analysis state: +# they answer the same question the same way for every hook, and stay. +hook::reset_analysis_state() { + unset HOOK_ALIAS_ADMIT_ARMED HOOK_ALIAS_MEMO HOOK_ALIAS_WORK + unset HOOK_ALIAS_SEEN HOOK_SHELL_ALIAS_SEEN HOOK_EFFECTIVE_BASE +} + # Mark the redirection still waiting for an operand as OPAQUE: the operator is # real, the path it names never arrived. The four places that discover an # orphaned operand (a second operator, a here-doc opener, a process From df80eb822e772d6e4bc3d127c3ee3db53a68ff17 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:41:20 -0400 Subject: [PATCH 2/6] fix(guardrails): predeclare the nameref-assigned SUBJECT for shellcheck hook::extract_bash_subject_to assigns SUBJECT through a nameref, which shellcheck 0.11 cannot follow; CI's lint lane failed SC2154 on the two guards that read it in emit_tel. Declaring the variable empty first is the idiom block-hook-bypass already uses. block-no-verify 256/0 and flag-commit-pr-skill-bypass 35/0 unchanged. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/hooks/block-no-verify.sh | 1 + plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh index d42160391f..2365c8402d 100755 --- a/plugins/guardrails/hooks/block-no-verify.sh +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -137,6 +137,7 @@ TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}" # commands are well under it). The linear parser keeps normal commands cheap. MAX_COMMAND_LEN=16384 +SUBJECT="" # predeclared: the _to helper assigns through a nameref (SC2154) hook::extract_bash_subject_to SUBJECT "$TOOL_NAME" "$COMMAND" # Hook-manager env-var disable prefixes, built once into a regex alternation. diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index 4dc97e3402..cbab8bc223 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -124,6 +124,7 @@ fi # keep an assignment VALUE out of the subject — a quoted value spanning the # whitespace the tokenizer splits on, and a bare/trailing `NAME=value` no # following command consumed — hold here too (#3372). +SUBJECT="" # predeclared: the _to helper assigns through a nameref (SC2154) hook::extract_bash_subject_to SUBJECT "$TOOL_NAME" "$COMMAND" # Emit one telemetry envelope per run. Advisory guards always report status From 04fc869f892d4209334c32bb30355783fe78e67a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:00:40 -0400 Subject: [PATCH 3/6] test(lib): use a portable cwd in the fast-fields payload fixture CI's machine-specific-paths hygiene check refuses a Windows user path in a fixture. The value is opaque to the parser under test; hook-utils 504/0 unchanged. Co-Authored-By: Claude Fable 5.1 --- lib/hook-utils.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 9df1ba8918..6c4d5c0685 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -4496,7 +4496,7 @@ fast_fields_is_jq() { # fail "fast fields ($desc): fast [$(printf '%q ' "${fast[@]}")] jq [$(printf '%q ' "${slow[@]:1}")]" fi } -fast_fields_is_jq "Bash payload" '{"session_id":"s","cwd":"C:\\Users\\me","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","description":"probe"}}' +fast_fields_is_jq "Bash payload" '{"session_id":"s","cwd":"C:\\work\\repo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","description":"probe"}}' # shellcheck disable=SC2016 # the $_ is PowerShell's, inside a JSON payload fast_fields_is_jq "PowerShell payload with braces and backslashes" '{"tool_name":"PowerShell","cwd":"C:\\Dev","tool_input":{"command":"Get-ChildItem C:\\Dev | Where-Object { $_.Name -like \"*x*\" }"}}' fast_fields_is_jq "escapes in the command" '{"tool_name":"Bash","tool_input":{"command":"git commit -m \"a\\nb\" && echo \"\\t\"\\\\x"}}' From 551629e9b40bc82179606b021864badeeb73e2ae Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:26:12 -0400 Subject: [PATCH 4/6] test(lib): keep the fixture cwd free of a \w escape for the portability lint The shell-portability lint reads the backslash-w in the previous spelling as a GNU-only regex class. The value is opaque to the parser under test. Co-Authored-By: Claude Fable 5.1 --- lib/hook-utils.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 6c4d5c0685..f32271546d 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -4496,7 +4496,7 @@ fast_fields_is_jq() { # fail "fast fields ($desc): fast [$(printf '%q ' "${fast[@]}")] jq [$(printf '%q ' "${slow[@]:1}")]" fi } -fast_fields_is_jq "Bash payload" '{"session_id":"s","cwd":"C:\\work\\repo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","description":"probe"}}' +fast_fields_is_jq "Bash payload" '{"session_id":"s","cwd":"C:\\code\\proj","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","description":"probe"}}' # shellcheck disable=SC2016 # the $_ is PowerShell's, inside a JSON payload fast_fields_is_jq "PowerShell payload with braces and backslashes" '{"tool_name":"PowerShell","cwd":"C:\\Dev","tool_input":{"command":"Get-ChildItem C:\\Dev | Where-Object { $_.Name -like \"*x*\" }"}}' fast_fields_is_jq "escapes in the command" '{"tool_name":"Bash","tool_input":{"command":"git commit -m \"a\\nb\" && echo \"\\t\"\\\\x"}}' From 6e16c10484eb47ccebb9d3f333f58526007872fd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:04:14 -0400 Subject: [PATCH 5/6] fix(hook-utils): gate the builtin field parser on Bash 4.0 and size its key bound hook::_fast_fields indexes the payload's key strings with an associative array, which Bash added in 4.0, and it ran on every hook::jq_fields call. On the 3.2 shell macOS ships, and which these hooks document support for, `local -A` fails per call. hook::_fast_fields_supported is the predicate, split out the way hook::read_supports_nchars is so a test can force the below-floor branch on a modern host, and hook::jq_fields_uncached asks it before entering the fast path. Below the floor jq answers, unchanged. The index loop also skipped any string body longer than a fixed 60 bytes before decoding it, while nothing capped the key names a caller may ask for. Two wrong answers came out of that: a requested key longer than 60 characters was proven ABSENT while present, and a key of 11 or more characters spelled with \u escapes (hook_event_name is 15, 90 escaped) was missed the same way. The bound is now six times the longest requested key name, the width of `\uXXXX` per identifier character, which is the bound the header comment always described. The suite gains four cases: the below-floor branch forced with the fast path replaced by a tripwire, a present 70-character key, an absent one, and hook_event_name spelled entirely in \u escapes. Each compares the fast path against jq rather than against an expectation. Co-Authored-By: Claude Fable 5.1 --- lib/hook-utils.sh | 34 +++++++- lib/hook-utils.test.sh | 81 ++++++++++++++++++- plugins/actionlint/CHANGELOG.md | 2 + plugins/actionlint/hooks/hook-utils.sh | 34 +++++++- plugins/autonomy/CHANGELOG.md | 2 + plugins/autonomy/hooks/hook-utils.sh | 34 +++++++- plugins/bash-format/CHANGELOG.md | 2 + plugins/bash-format/hooks/hook-utils.sh | 34 +++++++- plugins/biome-format/CHANGELOG.md | 2 + plugins/biome-format/hooks/hook-utils.sh | 34 +++++++- plugins/claude-ops/CHANGELOG.md | 2 + plugins/claude-ops/hooks/hook-utils.sh | 34 +++++++- plugins/context-guard/CHANGELOG.md | 2 + plugins/context-guard/hooks/hook-utils.sh | 34 +++++++- plugins/desktop-notification/CHANGELOG.md | 2 + .../desktop-notification/hooks/hook-utils.sh | 34 +++++++- plugins/eol-normalizer/CHANGELOG.md | 2 + plugins/eol-normalizer/hooks/hook-utils.sh | 34 +++++++- plugins/go-format/CHANGELOG.md | 2 + plugins/go-format/hooks/hook-utils.sh | 34 +++++++- plugins/guardrails/CHANGELOG.md | 2 + plugins/guardrails/hooks/hook-utils.sh | 34 +++++++- plugins/instruction-placement/CHANGELOG.md | 2 + .../instruction-placement/hooks/hook-utils.sh | 34 +++++++- plugins/markdown-format/CHANGELOG.md | 2 + plugins/markdown-format/hooks/hook-utils.sh | 34 +++++++- plugins/powershell-format/CHANGELOG.md | 2 + plugins/powershell-format/hooks/hook-utils.sh | 34 +++++++- plugins/rate-limit-guard/CHANGELOG.md | 2 + plugins/rate-limit-guard/hooks/hook-utils.sh | 34 +++++++- plugins/ruff-format/CHANGELOG.md | 2 + plugins/ruff-format/hooks/hook-utils.sh | 34 +++++++- plugins/source-control/CHANGELOG.md | 2 + plugins/source-control/hooks/hook-utils.sh | 34 +++++++- plugins/typos-format/CHANGELOG.md | 2 + plugins/typos-format/hooks/hook-utils.sh | 34 +++++++- 36 files changed, 672 insertions(+), 55 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index f32271546d..3f6de78785 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -4559,7 +4559,86 @@ if [[ "${HOOK_JQ_FIELDS[0]}" == "true" && "${HOOK_JQ_FIELDS[1]}" == "Bash" && "$ else fail "jq_fields common payload: [$(printf '%q ' "${HOOK_JQ_FIELDS[@]}")] nul=$HOOK_JQ_FIELDS_NUL" fi -unset ff_case ff_rc + +# The skip bound is six times the longest REQUESTED key name, not a fixed 60: +# an ASCII identifier character's longest escaped spelling is `\uXXXX`. Each +# case is a differential, so what is pinned is jq's answer rather than this +# suite's belief about it. +ff_pair_check() { # + local desc="$1" payload="$2" filter="$3" want="$4" rc=0 src=0 slow + hook::_fast_fields "$payload" "$filter" || rc=$? + if ((rc != 0)); then + fail "$desc: the fast path did not prove it (rc=$rc)" + return + fi + slow=$( + hook::_fast_fields() { return 2; } + hook::jq_fields_uncached "$payload" "$filter" || exit $? + printf '%s' "${HOOK_JQ_FIELDS[0]}" + ) || src=$? + if ((src != 0)); then + fail "$desc: proven by the fast path but the jq path returned $src" + return + fi + if [[ "${HOOK_JQ_FIELDS[0]}" == "$want" && "$slow" == "$want" ]]; then + ok "$desc" + else + fail "$desc: fast=[${HOOK_JQ_FIELDS[0]}] jq=[$slow] want=[$want]" + fi +} +ff_long="" +ff_gone="" +for ((ff_i = 0; ff_i < 70; ff_i++)); do + ff_long+=k + ff_gone+=z +done +ff_pair_check "fast fields: a present key name longer than 60 characters is not skipped" \ + "{\"$ff_long\":\"present\",\"tool_name\":\"Bash\"}" ".$ff_long" "present" +ff_pair_check "fast fields: a key name longer than 60 characters that is absent is proven empty" \ + "{\"$ff_long\":\"present\",\"tool_name\":\"Bash\"}" ".$ff_gone" "" +# `hook_event_name` is 15 characters, so its fully escaped spelling is 90 bytes: +# past the old fixed bound, inside six times the name's own length. +ff_esc="" +ff_name=hook_event_name +for ((ff_i = 0; ff_i < ${#ff_name}; ff_i++)); do + printf -v ff_ch '\\u%04x' "'${ff_name:ff_i:1}" + ff_esc+=$ff_ch +done +ff_pair_check "fast fields: a key spelled entirely with \\u escapes is recognized" \ + "{\"$ff_esc\":\"PreToolUse\",\"tool_name\":\"Bash\"}" ".$ff_name" "PreToolUse" + +# The Bash 4.0 floor. hook::_fast_fields indexes with an associative array, so +# below 4.0 the call site must skip the whole fast path and let jq answer. +# Forced here by overriding the predicate; hook::_fast_fields is replaced by a +# tripwire, so a gate that is not wired shows up as a failure rather than as +# two paths that happen to agree. +ff_floor_payload='{"session_id":"s","cwd":"/x","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","file_path":"/a b","content":"c\nd"}}' +ff_floor_rc=0 +ff_floor=() +mapfile -d '' ff_floor < <( + hook::_fast_fields_supported() { return 1; } + hook::_fast_fields() { + printf 'FAST-PATH-RAN-BELOW-FLOOR\0' + builtin exit 99 + } + hook::jq_fields "$ff_floor_payload" "${FF_FILTERS[@]}" || exit $? + printf '%s\0' "$HOOK_JQ_FIELDS_NUL" "${HOOK_JQ_FIELDS[@]}" +) +hook::jq_fields "$ff_floor_payload" "${FF_FILTERS[@]}" || ff_floor_rc=$? +ff_same=1 +((ff_floor_rc == 0)) || ff_same=0 +((${#ff_floor[@]} == ${#FF_FILTERS[@]} + 1)) || ff_same=0 +[[ "${ff_floor[0]-}" == "$HOOK_JQ_FIELDS_NUL" ]] || ff_same=0 +for ((ff_i = 0; ff_i < ${#HOOK_JQ_FIELDS[@]}; ff_i++)); do + [[ "${ff_floor[ff_i + 1]-}" == "${HOOK_JQ_FIELDS[ff_i]}" ]] || ff_same=0 +done +if ((ff_same)); then + ok "jq_fields: below the Bash 4.0 floor the fast path is skipped and jq answers the same" +else + fail "jq_fields below the floor: got [$(printf '%q ' "${ff_floor[@]}")] want nul=$HOOK_JQ_FIELDS_NUL [$(printf '%q ' "${HOOK_JQ_FIELDS[@]}")]" +fi +unset ff_case ff_rc ff_long ff_gone ff_esc ff_name ff_ch ff_i ff_same ff_floor ff_floor_rc ff_floor_payload +unset -f ff_pair_check # --- Test 23: hook::emit_document is the one stdout path -------------------- ed_out=$(hook::emit_channels PreToolUse "ctx" "sys") diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index 31cbb049f1..35633d366c 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `actionlint` plugin are documented here. Format follo ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.8.50] diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index a865072cad..36a24da235 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `autonomy` plugin are documented here. Format follows ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.23.12] diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index 1923836384..2c7c99aabb 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `bash-format` plugin are documented here. Format foll ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.7.50] diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 78efee231f..5947dbb4b2 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `biome-format` plugin are documented here. Format fol ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.48] diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 12b45389ba..699f4c1233 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.56.13] diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index cb5ab66268..aa619369b3 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -10,6 +10,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.7.63] diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index 8592d9fe5d..60476b8244 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `desktop-notification` plugin are documented here. Fo ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.43] diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index ae84a2d84d..da2c64e5ee 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format f ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.49] diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index 7def03a1f0..95ba97b255 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `go-format` plugin are documented here. Format follow ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.3.53] diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index ab6a246b80..414f8284d0 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to the `guardrails` plugin are documented here. Format follo - run-guards.sh runs its guards inside its own shell instead of one command-substitution subshell per guard. A sourced guard's `exit` is a dispatcher function that records the status and runs the next guard from inside the call; a guard's stdout document is collected through `hook::emit_document` rather than captured from a subshell; a guard that dies of a hard error hands its status to the abort boundary's new chain slot (`_GAB_CONTINUE`, abort-boundary.sh), which settles that guard's posture and runs the guards still owed in one subshell. With the library's builtin field parser answering the primed fields, a benign Bash tool call's chain spawns nothing: on Windows Git Bash the chain went from 23 process creations to 3 (the harness's `bash -c`, the `env` of the shebang, and bash itself), 880 ms to 297 ms isolated p50; the PowerShell lane from 100 to 80 creations, 3.3 s to 2.4 s, with its remaining forks inside `lib/powershell/ps-command.sh`. Every deny and allow is byte-identical before and after (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over the commands harvested from the guard suites. block-windows-drive-tmp masks quoted redirects in-shell (`mask_quoted_redirect_ops_to`); block-no-verify and flag-commit-pr-skill-bypass resolve their telemetry subject in-shell. - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value); `hook::jq_fields_uncached` names the same body for the dispatcher's cache; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.33.11] diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/instruction-placement/CHANGELOG.md b/plugins/instruction-placement/CHANGELOG.md index 1560240f49..69fbe1412e 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `instruction-placement` plugin are documented here. F ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.13.10] diff --git a/plugins/instruction-placement/hooks/hook-utils.sh b/plugins/instruction-placement/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/instruction-placement/hooks/hook-utils.sh +++ b/plugins/instruction-placement/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 55c356aeab..f73243964c 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `markdown-format` plugin are documented here. Format ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.11.58] diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index f7f0139c7a..8f689d9a93 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `powershell-format` plugin are documented here. Forma ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.7.51] diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index 14c99d2cca..5e3988191d 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.8.19] diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index d13fb0d612..54d834c74b 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `ruff-format` plugin are documented here. Format foll ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.49] diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index 9324215eec..37e58f0576 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `source-control` plugin are documented here. Format f ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.55.88] diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/source-control/hooks/hook-utils.sh +++ b/plugins/source-control/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md index 6b4a691691..d7cc1dc51c 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `typos-format` plugin are documented here. Format fol ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.54] diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() From 915423ac49c84843e3f805d4c4d0587773a35738 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:04:28 -0400 Subject: [PATCH 6/6] docs(guardrails): name run-guards.sh as the chain slot's one exception The chain-slot paragraph asks a function plugged into _GAB_CONTINUE to be builtins only, never exit, and never touch the trap, and the slot's own comment says it must not return. run-guards.sh's consumer does none of that: run_guards::guard_died forks a subshell for the guards still owed, spawns jq to merge their documents, and ends at `builtin exit`. Say so. run-guards.sh is the one documented exception, and it is one because it is the dispatcher finishing the run the process owes rather than a hook doing exit-time work. The discipline is unchanged for everyone else, and the slot comment now matches it: a chained function that returns hands control back and the handler settles the status. Comment only. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/hooks/abort-boundary.sh | 27 ++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/plugins/guardrails/hooks/abort-boundary.sh b/plugins/guardrails/hooks/abort-boundary.sh index 05a8ed2a6b..2511062fda 100644 --- a/plugins/guardrails/hooks/abort-boundary.sh +++ b/plugins/guardrails/hooks/abort-boundary.sh @@ -55,11 +55,21 @@ # naming EXIT in a registered hook or in a library it sources; this file is the # only one allowed to touch it. No hook needs exit-time work today (each is # builtins plus one jq read, nothing to clean up). The supported way for one -# that does is to chain through this library, not around it: add a chain slot -# here that guard::_abort_on_exit calls before it decides (the chained -# function under the same handler discipline: builtins only, never exits, -# never touches the trap), with a suite case beside the others, in the same -# change. A release on purpose goes through guard::abort_boundary_release. +# that does is to chain through this library, not around it: fill the chain slot +# below (_GAB_CONTINUE) that guard::_abort_on_exit calls before it decides, the +# chained function under the same handler discipline (builtins only, never +# exits, never touches the trap), with a suite case beside the others, in the +# same change. A release on purpose goes through guard::abort_boundary_release. +# +# run-guards.sh is the one documented exception to that discipline, and it is +# an exception because it is not a hook's exit-time work: its +# run_guards::guard_died is the DISPATCHER settling a dead guard and then +# finishing the run the process still owes, so it forks a subshell for the +# guards left, spawns jq to merge their documents, and ends at `builtin exit` +# without returning. Nothing is left for the handler to decide, which is why +# the slot may swallow control here and nowhere else. Any other consumer keeps +# the discipline above: a chained function that RETURNS, so that +# guard::_abort_on_exit still settles the status and exits. # # Under run-guards.sh the guards are sourced into the dispatcher's own shell, # and `exit` there is a function of the dispatcher's. A guard that ends through @@ -123,8 +133,11 @@ guard::_abort_json_escape_to() { # and this handler is what runs. With a function name here the handler hands # that guard's status to it instead of deciding the process's fate itself; the # dispatcher settles the guard's boundary, runs the guards still owed, and -# exits on the aggregate. The function must not return. Empty (every guard -# run alone), the handler decides as documented above. +# exits on the aggregate. run-guards.sh is the one consumer that does not +# return from here, and the header above says why it may; a chained function +# that DOES return hands control back and the handler settles the status as +# documented there. Empty (every guard run alone), the handler decides as +# documented above. _GAB_CONTINUE="" guard::_abort_on_exit() {