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/9] 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/9] 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/9] 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 0008692fd2a128e9f46026cef5e48828e1007e0c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:00:59 -0400 Subject: [PATCH 4/9] perf(context-guard): skip the zone resolver when the snapshot is unchanged zone-crossing-inject.sh reads three files the world outside it can move: the per-session snapshot, the optional zones.json, and the compaction marker. When none is newer than the `.seen` mark left by the last COMPLETED resolve, the fire would repeat that resolve's decision, which is already persisted, so it exits before starting a process. The mark is stamped with a redirection and compared with `-nt`, both builtins, and it moves only after both markers persist, so a resolver failure, an `unknown` reading and a failed marker write are each retried rather than skipped. The envelope parse had to become free for the skip to mean anything, and it is free only on the payloads hook::jq_fields can prove: within its ceiling the builtin parser answers both fields with no process, above it the single here-string jq stays, because the helper's oversize fallback reads through a process substitution and costs four process creations against that jq's two. That builtin parser arrives with the guardrails-run-guards-in-process-no-subs merge (PR #4185's lib/hook-utils.sh sync), which this fast path depends on. Process creations under a Windows job object, 5 reps, identical across reps; the subject's own floor is 3 (the `-c` shell, env, the shebang's shell): small envelope first 11 -> 9 repeat 9 -> 3 rewritten 9 -> 7 150 KB batch first 11 -> 11 repeat 9 -> 5 rewritten 9 -> 9 No cell is worse than before. Median wall for the small repeat fire, on a host whose timings are bimodal, 1,448 ms -> 237 ms. The one failure mode: a snapshot written DURING a resolve is marked as seen and its crossing waits for the next statusline render, so the window is the resolve rather than an mtime tick. A missed crossing is late, never lost, and a spurious one is impossible, because skipping only ever chooses silence. The suite gains the skip cases (silent repeat, byte-identical crossing after a skipped fire against a control session, a newer zones.json, a resolver failure leaving the mark untouched) and splits the per-batch budget in two: the steady fire now spawns nothing, and a resolving fire spawns the resolver alone. Co-Authored-By: Claude Fable 5.1 --- .../context-guard/.claude-plugin/plugin.json | 2 +- plugins/context-guard/CHANGELOG.md | 6 + plugins/context-guard/README.md | 34 +++ .../hooks/zone-crossing-inject.sh | 219 ++++++++++----- .../hooks/zone-crossing-inject.test.sh | 252 +++++++++++++++--- 5 files changed, 413 insertions(+), 100 deletions(-) diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index 266f5ad25e..8684cfb708 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.64", + "version": "0.7.65", "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 cb5ab66268..7c3f177199 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.65] + +### Changed + +- hooks: `zone-crossing-inject.sh` skips the zone resolver when nothing it reads has moved. A `$STATE_DIR/$SESSION.seen` mark, stamped with a redirection and compared with `-nt` (both builtins), records the inputs behind the last COMPLETED resolve; when the snapshot, `zones.json` and the compaction marker are all no newer than it, the fire exits before starting a process. The mark moves only after the markers persist, so a resolver failure, an `unknown` reading and a failed marker write are each retried. The envelope parse now uses `hook::jq_fields`' builtin parser on a payload within its proof ceiling and keeps the single here-string `jq` above it, because the helper's oversize fallback reads through a process substitution and costs four process creations against that `jq`'s two. Process creations under a Windows job object (5 reps, identical across reps; the subject's own floor is 3): small envelope, first fire 11 → 9, repeat with nothing moved 9 → **3**, snapshot rewritten 9 → 7; 150 KB batch payload, 11 → 11, 9 → **5**, 9 → 9. No cell is worse than before. Median wall for the small repeat fire, on a host whose timings are bimodal, 1,448 ms → 237 ms. The one failure mode: a snapshot written DURING a resolve is marked as seen, so its crossing waits for the next statusline render — the window is the resolve, not an mtime tick — and a missed crossing is late, never lost, because skipping only ever chooses silence. Crossing messages are byte-identical, asserted against a control session driven through the same zone sequence with no skipped fire. The per-batch budgets the contract test pins move with the paths: the steady fire now spawns nothing (0 commands, 0 process creations, 1 program launch) and a resolving fire spawns the resolver alone (1 command, 2 process creations, 3 program launches). + ## [0.7.64] ### Changed diff --git a/plugins/context-guard/README.md b/plugins/context-guard/README.md index 6dc4faa119..9797c2ae53 100644 --- a/plugins/context-guard/README.md +++ b/plugins/context-guard/README.md @@ -160,6 +160,40 @@ the older command-position budget, so a redirection moved back inside a substitu rather than quietly doubling a call site. Where `strace` is unavailable that assertion skips and the command-position budget still runs. +#### Skipping the resolve when nothing moved + +Three files outside the hook decide everything it does: the per-session snapshot, the optional +`zones.json`, and the compaction marker. When none is newer than the `.seen` mark the last +completed resolve left, the fire cannot reach a different answer, and the hook exits through +builtins alone. The envelope parse had to become free for that to mean anything, so a payload +within `hook::jq_fields`' proof ceiling is parsed by the library's builtin JSON parser, and one +above it keeps the single here-string `jq` described below. + +Measured as process creations under a Windows job object, which counts every descendant; 5 reps per +cell, identical across reps. The subject is the hooks.json row run through `usr/bin/bash.exe -c`, +whose own floor is 3: the `-c` shell, `env`, and the shell the script's shebang starts. + +| Fire | Payload | Creations before | After | +|---|---|---|---| +| First, resolves | small envelope | 11 | 9 | +| Repeat, nothing moved | small envelope | 9 | **3** | +| Snapshot rewritten | small envelope | 9 | 7 | +| First, resolves | 150 KB batch | 11 | 11 | +| Repeat, nothing moved | 150 KB batch | 9 | **5** | +| Snapshot rewritten | 150 KB batch | 9 | 9 | + +No cell is worse than before, which is what the size test on the envelope parse buys: the helper's +fallback reads through a process substitution and costs four creations on an oversize payload +against two for the here-string `jq`, so only the small arm goes through the helper. Wall clock on +this host is bimodal and is reported only for the row it dominates: the small repeat fire's median +fell from 1,448 ms to 237 ms. + +The one failure mode is a snapshot written DURING a resolve. The mark is stamped after the resolve +completes, so that write counts as seen and its crossing waits for the next statusline render — the +window is the resolve, not an mtime tick. A missed crossing is therefore late, never lost, and the +converse cannot happen: skipping only ever chooses silence, so no arrangement of timestamps can +manufacture an injection the full path would not have made. + #### The cost this pass added: a temp file on payloads over 64KiB The saving is not free, and the charge is disk rather than CPU. Two of the five removed process diff --git a/plugins/context-guard/hooks/zone-crossing-inject.sh b/plugins/context-guard/hooks/zone-crossing-inject.sh index 8bea04acd5..baf9a704ad 100755 --- a/plugins/context-guard/hooks/zone-crossing-inject.sh +++ b/plugins/context-guard/hooks/zone-crossing-inject.sh @@ -92,6 +92,21 @@ # per-tool dedupe needed; UserPromptSubmit covers turns that begin without a # prior batch (fresh prompt after idle). # +# UNCHANGED INPUT, NO WORK. Three files outside this hook decide everything +# below: the per-session snapshot the statusline tee writes, the optional +# zones.json override, and the compaction marker. When none of them is newer +# than the mark left by the last completed resolve, this fire would repeat that +# resolve's decision exactly, and that decision is already persisted — so the +# hook exits before starting a single process. A third marker, +# `$STATE_DIR/$SESSION.seen`, carries the mark in its mtime alone, stamped with +# a redirection and compared with `-nt`, both builtins. The mark moves only +# after a resolve that persisted, so a resolver failure, an `unknown` reading +# and a failed marker write each leave it where it was and are retried. The +# residual is stated at the gate itself: a snapshot written DURING a resolve is +# marked as seen and its crossing waits for the next write, while a spurious +# injection is impossible in the other direction, because skipping only ever +# chooses silence. +# # State root: ${CLAUDE_PLUGIN_DATA} (plugin-private runtime state, NOT part # of the reader contract seam), falling back to ~/.claude/context-guard/state # when the harness doesn't export it. @@ -155,61 +170,76 @@ RESOLVER="$CG_DIR/../scripts/context-zone.sh" INPUT="" cg::read_payload_to INPUT || exit 0 -# ONE jq for the whole payload rather than one per field. hook::jq_field spawns -# a jq per call and this hook needs two fields; the payload is read once and -# both fields come back as two lines in a FIXED ORDER (event, then session). An -# absent field yields an empty line, which is what a per-field `// empty` plus -# non-empty test yields too. `gsub("\r";"")` is carried over from -# hook::jq_field for the Windows carriage-return case. +# ONE PASS over the whole envelope, and through the shared helper rather than a +# jq of this hook's own. hook::jq_fields answers `.hook_event_name` and +# `.session_id` — top-level keys carrying plain strings — from its BUILTIN JSON +# parser, so an ordinary envelope is parsed with no process at all. That is +# what lets the unchanged-snapshot skip below exit having started nothing: the +# skip still needs the session id, so a parse that cost a process would put a +# floor of one under every fire. # -# Not regex-extracted: a PostToolBatch payload carries every serialized tool -# result, so a pattern for these fields would be matching against tool output -# rather than against the envelope. post-compact-mark.sh's regex path is safe -# for its own payload shape; this one keeps jq as the parser. +# THE SIZE TEST IS NOT A STYLE CHOICE. The helper falls back to jq whenever it +# cannot PROVE the builtin answer is jq's, and one of those cases is a payload +# past the parser's ceiling — which a PostToolBatch payload carrying every +# serialized tool result clears routinely. That fallback reads through a +# process substitution, and measured on this repo's Windows host it costs FOUR +# process creations against TWO for the single here-string jq below. So the +# oversize arm keeps that jq, and only the small arm — where the helper is +# free — goes through the helper. The branch is what makes this change cost +# nothing on any payload instead of buying the small case at the large one's +# expense. +# +# 65536 mirrors hook::_json_split's own proof ceiling. Drift is benign in both +# directions: a payload the helper would have proven merely pays the here-string +# jq, and a payload past a ceiling this test missed reaches the helper, which +# refuses it and falls back to the same jq through a costlier route. Neither +# changes an answer. +# +# `<<<` IS NOT A PIPE, and that is the oversize arm's known cost. Bash 5.1+ +# delivers a here-string through a pipe only while it fits the pipe buffer; at +# or above 64KiB it spills to a temp file (`/tmp/sh-thd.*`) and hands jq that +# fd, which Defender then scans on the very hosts this hook is tuned for. The +# trade stands because a process creation on those hosts is the larger cost by +# an order of magnitude; the plugin README's hook-cost section carries the +# measured counts. # # REDIRECTIONS GO ON THE GROUP, NOT INSIDE THE SUBSTITUTION — see the -# REDIRECTION PLACEMENT note at the top of this file. `printf '%s' "$INPUT" | -# jq` cost three process creations to run one jq: the subshell the substitution -# opens, a child for the pipeline's left-hand side (a `printf` BUILTIN — a whole -# process to hand over a string this shell already holds), and the child that -# becomes jq. Hoisting `<<<` and `2>/dev/null` onto the enclosing group leaves -# jq a bare simple command inside the substitution, and the extraction costs -# one process instead of three. +# REDIRECTION PLACEMENT note at the top of this file. Inside, `<<<` and +# `2>/dev/null` each defeat the fork elision and bill a second process for one +# jq. What jq sees is unchanged either way: the group's stderr redirect +# suppresses exactly what jq's own did, stdout is still captured, a nonzero jq +# status still propagates out of the group, and jq parses JSON, so the newline +# `<<<` appends changes nothing. # -# What jq sees is unchanged: the group's stderr redirect suppresses exactly what -# jq's own did, the substitution still captures stdout, a nonzero jq status still -# propagates out of the group, and jq parses JSON, so the newline `<<<` appends -# changes nothing. +# Not regex-extracted: a PostToolBatch payload carries every serialized tool +# result, so a pattern for these fields would be matching against tool output +# rather than against the envelope. post-compact-mark.sh's regex path is safe +# for its own payload shape; this one keeps a real parser. # -# ONE THING DOES CHANGE, and it is disclosed rather than buried. `<<<` is not a -# pipe. Bash 5.1+ delivers a here-string through a pipe only while it fits in -# the pipe buffer; at or above 64KiB it spills the string to a temp file -# (`/tmp/sh-thd.*`, measured here: 60,000 bytes stays in the pipe, 65,536 opens -# the file) and hands jq that fd. The `printf | jq` form this replaced never -# touched disk at any size. Output is byte-identical either way, but a -# PostToolBatch payload carrying every serialized tool result routinely clears -# 64KiB, so a large fire now writes and reads a temp file it did not before. -# That is a real cost on the very hosts this change is for: #3508's Windows -# machines run Defender real-time protection, which scans temp-file writes, and -# the 0.4.8 measurement in the plugin README already attributes 22.0 s on that -# platform to it. The trade taken is one guaranteed process creation per fire -# against disk I/O on the fires that exceed the buffer; the README's hook-cost -# section states it. Feeding the hook's stdin straight to jq would avoid both, -# but that means giving up payload.sh's bounded drain loop — see the note there. -{ FIELDS=$(jq -r '(.hook_event_name // ""), (.session_id // "") | gsub("\r";"")'); } 2>/dev/null <<<"$INPUT" -# jq writes CRLF line endings on this host, and command substitution strips only -# the TRAILING one, so with two lines the separator's carriage return survives -# into the split and would ride along on the event name. The single-field helper -# never saw this because its one and only line ending was the trailing one. -# gsub above has already removed any CR belonging to a field's value, so nothing -# left here is anything but jq's own terminators. -FIELDS=${FIELDS//$'\r'/} -EVENT=${FIELDS%%$'\n'*} -SESSION=${FIELDS#*$'\n'} -# No newline in FIELDS means jq emitted at most one line, so there is no -# session field to take, and the expansion above would otherwise hand back the -# event name. -[[ "$SESSION" != "$FIELDS" ]] || SESSION="" +# The helper arm is guarded by `if` rather than `|| exit 0`: on rc 1 (no jq) and +# rc 2 (a payload jq rejects) it leaves HOOK_JQ_FIELDS EMPTY, and indexing that +# under `set -u` would abort where this hook must fail open. An absent field +# arrives as the empty string on both arms, which is what a per-field +# `// empty` plus a non-empty test yielded too. +EVENT="" +SESSION="" +if ((${#INPUT} > 65536)); then + { FIELDS=$(jq -r '(.hook_event_name // ""), (.session_id // "") | gsub("\r";"")'); } 2>/dev/null <<<"$INPUT" + # jq writes CRLF line endings on this host, and command substitution strips + # only the TRAILING one, so with two lines the separator's carriage return + # survives into the split and would ride along on the event name. gsub above + # has already removed any CR belonging to a field's value. + FIELDS=${FIELDS//$'\r'/} + EVENT=${FIELDS%%$'\n'*} + SESSION=${FIELDS#*$'\n'} + # No newline in FIELDS means jq emitted at most one line, so there is no + # session field to take, and the expansion above would otherwise hand back + # the event name. + [[ "$SESSION" != "$FIELDS" ]] || SESSION="" +elif hook::jq_fields "$INPUT" '.hook_event_name' '.session_id'; then + EVENT="${HOOK_JQ_FIELDS[0]}" + SESSION="${HOOK_JQ_FIELDS[1]}" +fi [[ -n "$EVENT" ]] || EVENT="PostToolBatch" hook::require_jq "$EVENT" "context-guard" "$INPUT" @@ -218,6 +248,67 @@ hook::require_jq "$EVENT" "context-guard" "$INPUT" # the state file below. [[ "$SESSION" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0 +# silent-skip-ok: with neither CLAUDE_PLUGIN_DATA nor HOME there is no +# resolvable state root, and a `.`-relative fallback would key the last-seen +# zone to whatever directory the hook happened to start in — the once-per- +# transition contract cannot hold against state that moves with the working +# directory, so the hook would re-inject on every cd. Same doctrine +# post-compact-mark.sh applies to its marker path. +# +# Resolved AHEAD of the resolver because the skip below is keyed on a file in +# this directory. Nothing else moves with it: the resolver has no side effects, +# so a session with no state root now exits without starting it rather than +# after — same silence, one process less. +if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then + STATE_DIR="$CLAUDE_PLUGIN_DATA/state" +elif [[ -n "${HOME:-}" ]]; then + STATE_DIR="$HOME/.claude/context-guard/state" +else + exit 0 +fi +STATE_FILE="$STATE_DIR/$SESSION.zone" +ARMED_FILE="$STATE_DIR/$SESSION.armed" +SEEN_FILE="$STATE_DIR/$SESSION.seen" +COMPACTED_FILE="" +[[ -n "${HOME:-}" ]] && COMPACTED_FILE="$HOME/.claude/context-guard/context/$SESSION.compacted" + +# THE UNCHANGED-INPUT SKIP. Everything below this line reads exactly three +# things the world outside this hook can move: the per-session snapshot the +# statusline tee writes, the optional zones.json override, and the compaction +# marker. (The zone words themselves also depend on wall-clock time, but only +# through the resolver's staleness window, whose one outcome is `unknown` — and +# `unknown` is silent and leaves state untouched, which is exactly what this +# skip does.) So when none of the three has moved since the last completed +# resolve, this fire cannot reach a different decision than the last one did, +# and the last one already persisted its markers. Exit before starting +# anything. +# +# `$STATE_DIR/$SESSION.seen` is the mark, stamped with a redirection rather +# than `touch(1)` so the steady path keeps costing zero processes, and compared +# with `-nt`, which is a bash builtin and involves no stat(1) dialect. `-nt` is +# true when the left file exists and the right one does not, so a session with +# no mark yet — the first fire, or one whose last fire failed to persist — +# never takes the skip. +# +# A MISSING SNAPSHOT is not skippable: the `-e` test fails and the resolver +# runs and answers for it as it always has. +# +# THE ONE FAILURE MODE, stated. The mark is stamped AFTER the resolve +# completes, so a snapshot write that lands between the resolver's read and +# that stamp is recorded as already seen and its crossing is not reported. The +# window is the resolve itself, not an mtime tick. The statusline writes the +# snapshot again on its next render, so the next write catches the crossing up; +# the cost of a miss is a report one fire late, never a report that never +# comes. The converse cannot happen: skipping only ever chooses silence, so no +# arrangement of timestamps can manufacture an injection that the full path +# would not have made. +if [[ -n "${HOME:-}" && -e "$HOME/.claude/context-guard/context/$SESSION.json" ]] && + [[ ! "$HOME/.claude/context-guard/context/$SESSION.json" -nt "$SEEN_FILE" ]] && + [[ ! "$HOME/.claude/context-guard/zones.json" -nt "$SEEN_FILE" ]] && + [[ ! "$COMPACTED_FILE" -nt "$SEEN_FILE" ]]; then + exit 0 +fi + # Stderr redirected on the GROUP, not inside the substitution: the resolver is # one process, and `$(bash … 2>/dev/null)` billed two for it. Same suppression # (the resolver's zones.json notices stay hidden from this caller, as before), @@ -229,7 +320,7 @@ hook::require_jq "$EVENT" "context-guard" "$INPUT" # reading and including unknown, because the marker IS data even when the # snapshot has none. degraded="" -if [[ -n "${HOME:-}" && -e "$HOME/.claude/context-guard/context/$SESSION.compacted" ]]; then +if [[ -n "$COMPACTED_FILE" && -e "$COMPACTED_FILE" ]]; then degraded="yes" zone="dumb" fi @@ -238,21 +329,6 @@ fi # transition, and a later real reading must compare against the last REAL one. [[ "$zone" == "smart" || "$zone" == "acceptable" || "$zone" == "dumb" ]] || exit 0 -# silent-skip-ok: with neither CLAUDE_PLUGIN_DATA nor HOME there is no -# resolvable state root, and a `.`-relative fallback would key the last-seen -# zone to whatever directory the hook happened to start in — the once-per- -# transition contract cannot hold against state that moves with the working -# directory, so the hook would re-inject on every cd. Same doctrine -# post-compact-mark.sh applies to its marker path. -if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then - STATE_DIR="$CLAUDE_PLUGIN_DATA/state" -elif [[ -n "${HOME:-}" ]]; then - STATE_DIR="$HOME/.claude/context-guard/state" -else - exit 0 -fi -STATE_FILE="$STATE_DIR/$SESSION.zone" -ARMED_FILE="$STATE_DIR/$SESSION.armed" # One reader for both markers. It sets REPLY (the raw bytes on disk) and # REPLY_NORM (the normalized zone word) rather than printing them, the same # reason rank/unrank below do: a command substitution forks a subshell, and @@ -391,6 +467,17 @@ if [[ -n "$persist_failed" ]]; then exit 0 fi +# The resolve completed and both markers hold it, so the inputs behind it may +# now be treated as seen. Stamped HERE and nowhere earlier: a resolver that +# failed, a reading of `unknown`, and a marker write that failed all exit above +# this line, and all three must be RETRIED on the next fire rather than skipped +# — the decision they were owed was never made. Truncation rather than `touch`, +# and an empty file rather than a written one, because the mtime is the whole +# content; a failure to stamp is ignored for the same reason a failure to skip +# is harmless, namely that it only costs the next fire a resolve it would have +# done anyway. +: >"$SEEN_FILE" 2>/dev/null || : + ((new_rank > armed_rank)) || { # Nothing worse than this session has already reported. Three shapes reach # here and only the first two are worth telemetry: a genuine recovery (rank diff --git a/plugins/context-guard/hooks/zone-crossing-inject.test.sh b/plugins/context-guard/hooks/zone-crossing-inject.test.sh index 1010d1ebf5..c891112fe6 100755 --- a/plugins/context-guard/hooks/zone-crossing-inject.test.sh +++ b/plugins/context-guard/hooks/zone-crossing-inject.test.sh @@ -549,6 +549,11 @@ fi SKIP_REF="$WORK/skip-ref" touch -t 200001010000 "$D/state/sskip.zone" "$D/state/sskip.armed" touch -t 200001020000 "$SKIP_REF" +# The `.seen` mark is cleared before each fire in this case, because 12c is +# about the MARKER writes on a fire that RESOLVES. Left in place, the +# unchanged-input skip would exit before the resolver and both assertions below +# would pass without the code they guard ever running. +rm -f "$D/state/sskip.seen" run "$H" "$D" sskip # same zone, same gate: nothing to persist if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "write skip: the steady fire is silent" @@ -569,6 +574,7 @@ fi # broken probe cannot pass the two assertions above by never detecting anything. printf 'acceptable\n' >"$D/state/sskip.zone" # legacy-looking mismatch: a rewrite is owed touch -t 200001010000 "$D/state/sskip.zone" +rm -f "$D/state/sskip.seen" run "$H" "$D" sskip if [[ $RC -eq 0 && "$D/state/sskip.zone" -nt "$SKIP_REF" && "$(cat "$D/state/sskip.zone" 2>/dev/null)" == "dumb" ]]; then ok "write skip: a marker that differs on disk is still rewritten (probe detects writes)" @@ -576,6 +582,111 @@ else fail "write skip probe: mismatched marker not rewritten: rc=$RC zone=$(cat "$D/state/sskip.zone" 2>/dev/null)" fi +# 13. THE UNCHANGED-INPUT SKIP decides only WHETHER the work runs, never what +# the work says. The xtrace budget below proves the skipped fire starts nothing; +# this pins the other half, which a process count cannot see: a session that +# skipped a fire must still produce the SAME crossing message, byte for byte, as +# one that never skipped. Compared against a control session driven through the +# identical zone sequence with no idle fire in it — the message carries only the +# two zone words, so two such sessions are byte-identical or the skip changed +# something it had no business touching. +write_snapshot "$H" sfpc 10 # control: smart, then a crossing to dumb +run "$H" "$D" sfpc +write_snapshot "$H" sfpc 90 +run "$H" "$D" sfpc +CTRL_OUT="$OUT" +if [[ $RC -eq 0 && "$CTRL_OUT" == *additionalContext* && "$CTRL_OUT" == *dumb* ]]; then + ok "skip control: the un-skipped session produced the crossing message" +else + fail "skip control did not cross: rc=$RC out=${CTRL_OUT:0:120}" +fi +write_snapshot "$H" sfps 10 # the same sequence with an idle fire in the middle +run "$H" "$D" sfps +run "$H" "$D" sfps # nothing moved since the resolve: the skip +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "skip: a fire whose inputs have not moved is silent" +else + fail "skip: idle fire emitted: rc=$RC out=${OUT:0:120}" +fi +write_snapshot "$H" sfps 90 # rewritten: the skip must not survive it +run "$H" "$D" sfps +if [[ "$OUT" == "$CTRL_OUT" ]]; then + ok "skip: a rewritten snapshot resolves and its message is byte-identical" +else + fail "skip changed the crossing message: [${OUT:0:200}] != [${CTRL_OUT:0:200}]" +fi + +# 13a. zones.json is the second input the skip must watch: the bands can move +# under an unchanged snapshot, and the same percentage then resolves to a +# different word. A mark left by a resolve under the old bands may not silence +# the first fire under the new ones. +ZFH="$WORK/home-zfast" +ZFD="$WORK/data-zfast" +mkdir -p "$ZFD" +write_snapshot "$ZFH" szfast 60 # acceptable under the shipped bands +run "$ZFH" "$ZFD" szfast +if [[ $RC -eq 0 && "$OUT" == *acceptable* ]]; then + ok "zones.json skip: the baseline observation resolves acceptable" +else + fail "zones.json skip baseline: rc=$RC out=${OUT:0:120}" +fi +run "$ZFH" "$ZFD" szfast +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "zones.json skip: the repeat fire is silent" +else + fail "zones.json skip: repeat fire emitted: rc=$RC out=${OUT:0:120}" +fi +sleep 0.05 # the override and the mark must land on distinguishable mtimes +mkdir -p "$ZFH/.claude/context-guard" +printf '{"smart_max_used_percentage":5,"acceptable_max_used_percentage":20}' \ + >"$ZFH/.claude/context-guard/zones.json" +run "$ZFH" "$ZFD" szfast +if [[ $RC -eq 0 && "$OUT" == *additionalContext* && "$OUT" == *dumb* ]]; then + ok "zones.json newer than the mark still resolves (same snapshot, new bands)" +else + fail "a newer zones.json was skipped: rc=$RC out=${OUT:0:120}" +fi + +# 13b. A RESOLVER FAILURE LEAVES THE MARK UNTOUCHED. The mark means "the inputs +# behind a completed decision", so a fire that reached no decision may not set +# it — otherwise one failed resolve would silence the session until its next +# snapshot write. Driven through a copy of the hook whose sibling resolver is a +# stub, which is a true nonzero exit from the process the hook actually starts +# rather than a simulated one: RESOLVER is derived from the hook's own path. +FAKE="$WORK/fake" +mkdir -p "$FAKE/hooks" "$FAKE/scripts" +cp "$SCRIPT_DIR"/*.sh "$FAKE/hooks/" +printf '#!/usr/bin/env bash\nexit 1\n' >"$FAKE/scripts/context-zone.sh" +FH="$WORK/home-fail" +FD="$WORK/data-fail" +mkdir -p "$FD" +write_snapshot "$FH" sfail 90 +F_OUT=$(printf '{"session_id":"sfail","hook_event_name":"PostToolBatch"}' | + HOME="$FH" CLAUDE_PLUGIN_DATA="$FD" HOOK_TELEMETRY_SINK="" bash "$FAKE/hooks/zone-crossing-inject.sh" 2>/dev/null) +F_RC=$? +if [[ $F_RC -eq 0 && -z "$F_OUT" ]]; then + ok "resolver failure: the hook is silent and exits 0" +else + fail "resolver failure: rc=$F_RC out=${F_OUT:0:120}" +fi +if [[ ! -e "$FD/state/sfail.seen" ]]; then + ok "resolver failure: the mark is not stamped" +else + fail "resolver failure stamped the mark, so the next fire would be skipped" +fi +# ...and the fire it was owed is still issued once the resolver works, with the +# snapshot untouched in between. +printf '#!/usr/bin/env bash\nexec bash %q/../scripts/context-zone.sh "$@"\n' "$SCRIPT_DIR" \ + >"$FAKE/scripts/context-zone.sh" +F_OUT=$(printf '{"session_id":"sfail","hook_event_name":"PostToolBatch"}' | + HOME="$FH" CLAUDE_PLUGIN_DATA="$FD" HOOK_TELEMETRY_SINK="" bash "$FAKE/hooks/zone-crossing-inject.sh" 2>/dev/null) +F_RC=$? +if [[ $F_RC -eq 0 && "$F_OUT" == *additionalContext* && "$F_OUT" == *dumb* ]]; then + ok "resolver failure: the retried fire resolves and injects" +else + fail "retry after a resolver failure was skipped: rc=$F_RC out=${F_OUT:0:120}" +fi + # No resolvable state root → stay silent rather than key the last-seen zone to # the working directory, which would re-inject on every cd. write_snapshot "$WORK/nohome" snr 90 @@ -601,10 +712,19 @@ fi # EXACT COUNTS, not on absence, so a regression back to a second `jq` fails here # rather than showing up as a slow session. # -# Budget on the steady non-crossing path, which is the common case: -# 1 jq : one pass over the payload for both envelope fields -# 1 bash : scripts/context-zone.sh, the single band authority this hook must -# not re-implement; its own execs are in that process, not this trace +# TWO paths are budgeted, because the steady fire no longer does the work. +# +# A. THE STEADY NON-CROSSING PATH — the common case, and now the unchanged-input +# skip: the snapshot, zones.json and the compaction marker are all older than +# the `.seen` mark the last resolve left, so the hook exits before the +# resolver. Budget: ZERO. The envelope parse is answered by hook::jq_fields' +# builtin parser, the mark is compared with `-nt` and stamped with a +# redirection, and all three are shell builtins. +# B. THE RESOLVING PATH — a fire whose snapshot has been rewritten since. Budget: +# 1 bash : scripts/context-zone.sh, the single band authority this hook must +# not re-implement; its own execs are in that process, not this trace +# and NO jq, for the same reason A is free: the builtin parser answers the two +# envelope fields of an ordinary-sized payload. # Anything else is a regression. The count is of commands in COMMAND POSITION # (anchored on the xtrace depth prefix), so `command -v jq` in hook::require_jq # is correctly not counted: it is a shell builtin and spawns nothing. @@ -616,10 +736,15 @@ TH="$WORK/home-trace" TD="$WORK/data-trace" mkdir -p "$TD" write_snapshot "$TH" strace 10 -# Prime: the first fire creates the state directory and the markers, so the -# traced fire is the steady path a running session actually pays. +# Prime: the first fire creates the state directory, the markers and the `.seen` +# mark, so the traced fire is the steady path a running session actually pays. printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" bash "$HOOK" >/dev/null 2>&1 +if [[ -f "$TD/state/strace.seen" ]]; then + ok "trace: a completed resolve leaves the .seen mark" +else + fail "trace: no .seen mark after a completed resolve" +fi TRACE_LOG="$WORK/inject-xtrace.log" printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" \ @@ -633,30 +758,56 @@ else fi TRACE_SPAWNS=$(grep -cE "$TRACE_PAT" "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') TRACE_DETAIL=$(grep -oE "$TRACE_PAT" "$TRACE_LOG" 2>/dev/null | sed -E 's/^\++ //; s/ $//' | sort | uniq -c | tr -d '\n') -if [[ "$TRACE_SPAWNS" == "2" ]]; then - ok "trace: the steady path spawns exactly 2 processes" +if [[ "$TRACE_SPAWNS" == "0" ]]; then + ok "trace: the steady path spawns nothing at all" else - fail "trace: steady path spawns $TRACE_SPAWNS processes, budget is 2: $TRACE_DETAIL" + fail "trace: steady path spawns $TRACE_SPAWNS processes, budget is 0: $TRACE_DETAIL" fi +# Named separately from the total, so a revert to always-resolving is legible in +# the failure message rather than only in the count. +TRACE_BASH=$(grep -cE '^\++ bash ' "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') TRACE_JQ=$(grep -cE '^\++ jq ' "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') -if [[ "$TRACE_JQ" == "1" ]]; then - ok "trace: exactly one jq pass over the payload" +if [[ "$TRACE_BASH" == "0" && "$TRACE_JQ" == "0" ]]; then + ok "trace: the steady path invokes neither the resolver nor jq" else - fail "trace: $TRACE_JQ jq processes on the steady path, budget is 1" + fail "trace: steady path ran $TRACE_BASH bash and $TRACE_JQ jq, both budgets are 0" fi -TRACE_BASH=$(grep -cE '^\++ bash ' "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') -if [[ "$TRACE_BASH" == "1" ]]; then + +# B. The resolving path, on the same session: rewrite the snapshot so it is +# newer than the mark, and the hook must do the work it skipped above — the +# skip may only ever suppress a REPEAT. +sleep 0.05 # the mark and the rewrite must land on distinguishable mtimes +write_snapshot "$TH" strace 10 +TRACE_LOG2="$WORK/inject-xtrace-resolve.log" +printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | + HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" \ + BASH_XTRACEFD=9 bash -x "$HOOK" >/dev/null 2>/dev/null 9>"$TRACE_LOG2" +TRACE2_SPAWNS=$(grep -cE "$TRACE_PAT" "$TRACE_LOG2" 2>/dev/null | tr -cd '0-9') +TRACE2_DETAIL=$(grep -oE "$TRACE_PAT" "$TRACE_LOG2" 2>/dev/null | sed -E 's/^\++ //; s/ $//' | sort | uniq -c | tr -d '\n') +if [[ "$TRACE2_SPAWNS" == "1" ]]; then + ok "trace: a rewritten snapshot resolves, and spawns exactly 1 process" +else + fail "trace: resolving path spawns $TRACE2_SPAWNS processes, budget is 1: $TRACE2_DETAIL" +fi +TRACE2_BASH=$(grep -cE '^\++ bash ' "$TRACE_LOG2" 2>/dev/null | tr -cd '0-9') +if [[ "$TRACE2_BASH" == "1" ]]; then ok "trace: exactly one resolver process (the band authority)" else - fail "trace: $TRACE_BASH bash processes on the steady path, budget is 1" + fail "trace: $TRACE2_BASH bash processes on the resolving path, budget is 1" +fi +TRACE2_JQ=$(grep -cE '^\++ jq ' "$TRACE_LOG2" 2>/dev/null | tr -cd '0-9') +if [[ "$TRACE2_JQ" == "0" ]]; then + ok "trace: the envelope parse costs no jq (builtin parser)" +else + fail "trace: $TRACE2_JQ jq processes on the resolving path, budget is 0" fi # The specific pipelines this budget replaced, named so a revert is legible in # the failure message rather than only in the total. -TRACE_GONE=$(grep -cE '^\++ (dirname|tr|head) ' "$TRACE_LOG" 2>/dev/null | tr -cd '0-9') +TRACE_GONE=$(grep -cE '^\++ (dirname|tr|head|touch) ' "$TRACE_LOG2" 2>/dev/null | tr -cd '0-9') if [[ "$TRACE_GONE" == "0" ]]; then - ok "trace: no dirname, tr or head on the steady path" + ok "trace: no dirname, tr, head or touch on the resolving path" else - fail "trace: $TRACE_GONE dirname/tr/head process(es) returned: $TRACE_DETAIL" + fail "trace: $TRACE_GONE dirname/tr/head/touch process(es) returned: $TRACE2_DETAIL" fi # --- The per-batch PROCESS-CREATION budget, proven by strace ------------------- @@ -676,16 +827,25 @@ fi # Asserted as an EXACT count, not a ceiling, so a regression back to an inner # redirect fails here rather than showing up as a timed-out session. # -# Budget on the steady non-crossing path, one process creation each: -# 1 jq : the payload pass, both envelope fields +# Budget on the STEADY NON-CROSSING path: ZERO process creations, and one +# execve — the hook's own shell, which strace itself launches rather than the +# hook forking it. The unchanged-input skip reaches its exit through builtins +# only. +# +# Budget on the RESOLVING path, one process creation each: # 1 bash : scripts/context-zone.sh, the band authority # 1 jq : the resolver's snapshot pass, inside that bash -# The hook's own shell is execve'd by the harness, not forked by the hook, so it -# is not in this count. Skipped where strace is unavailable (it needs ptrace, -# which containers and macOS commonly withhold) — the xtrace budget above still -# runs there, and CI keeps a Linux lane that does not skip. +# plus the hook's own shell for a program-launch count of 3. The payload jq the +# earlier budget carried is gone: hook::jq_fields answers this envelope from its +# builtin parser. +# +# Skipped where strace is unavailable (it needs ptrace, which containers and +# macOS commonly withhold) — the xtrace budgets above still run there, and CI +# keeps a Linux lane that does not skip. if command -v strace >/dev/null 2>&1; then STRACE_LOG="$WORK/inject-strace.log" + # The snapshot was rewritten for trace B above and the fire that followed it + # re-stamped the mark, so this fire is the steady one again. printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" \ strace -f -qq -e trace=clone,clone3,fork,vfork,execve -o "$STRACE_LOG" \ @@ -693,25 +853,51 @@ if command -v strace >/dev/null 2>&1; then if [[ -s "$STRACE_LOG" ]]; then ok "strace: the steady non-crossing path was traced" S_FORKS=$(grep -cE '(clone|clone3|fork|vfork)\(' "$STRACE_LOG" 2>/dev/null | tr -cd '0-9') - if [[ "$S_FORKS" == "3" ]]; then - ok "strace: the steady path creates exactly 3 processes" + if [[ "$S_FORKS" == "0" ]]; then + ok "strace: the steady path creates no processes at all" else S_DETAIL=$(grep -oE 'execve\("[^"]+"' "$STRACE_LOG" 2>/dev/null | sed 's/execve("//' | sort | uniq -c | tr -d '\n') - fail "strace: steady path creates $S_FORKS processes, budget is 3 (execs: $S_DETAIL)" + fail "strace: steady path creates $S_FORKS processes, budget is 0 (execs: $S_DETAIL)" fi - # The programs actually launched must not change with the fork count: this - # is a latency fix, so the same work must still run. One jq for the payload, - # one bash for the resolver, one jq inside it, plus the hook's own shell. S_EXECS=$(grep -cE 'execve\(' "$STRACE_LOG" 2>/dev/null | tr -cd '0-9') - if [[ "$S_EXECS" == "4" ]]; then - ok "strace: the same 4 program launches as before the fork reduction" + if [[ "$S_EXECS" == "1" ]]; then + ok "strace: the steady path launches only the hook's own shell" else - fail "strace: $S_EXECS program launches on the steady path, expected 4" + fail "strace: $S_EXECS program launches on the steady path, expected 1" fi else fail "strace: no usable trace captured" fi + STRACE_LOG2="$WORK/inject-strace-resolve.log" + sleep 0.05 + write_snapshot "$TH" strace 10 + printf '{"session_id":"strace","hook_event_name":"PostToolBatch"}' | + HOME="$TH" CLAUDE_PLUGIN_DATA="$TD" HOOK_TELEMETRY_SINK="" \ + strace -f -qq -e trace=clone,clone3,fork,vfork,execve -o "$STRACE_LOG2" \ + bash "$HOOK" >/dev/null 2>&1 + if [[ -s "$STRACE_LOG2" ]]; then + ok "strace: the resolving path was traced" + S2_FORKS=$(grep -cE '(clone|clone3|fork|vfork)\(' "$STRACE_LOG2" 2>/dev/null | tr -cd '0-9') + if [[ "$S2_FORKS" == "2" ]]; then + ok "strace: the resolving path creates exactly 2 processes" + else + S2_DETAIL=$(grep -oE 'execve\("[^"]+"' "$STRACE_LOG2" 2>/dev/null | + sed 's/execve("//' | sort | uniq -c | tr -d '\n') + fail "strace: resolving path creates $S2_FORKS processes, budget is 2 (execs: $S2_DETAIL)" + fi + # The programs actually launched must not change with the fork count: this + # is a latency fix, so the same work must still run. One bash for the + # resolver, one jq inside it, plus the hook's own shell. + S2_EXECS=$(grep -cE 'execve\(' "$STRACE_LOG2" 2>/dev/null | tr -cd '0-9') + if [[ "$S2_EXECS" == "3" ]]; then + ok "strace: the resolving path launches exactly 3 programs" + else + fail "strace: $S2_EXECS program launches on the resolving path, expected 3" + fi + else + fail "strace: no usable resolving-path trace captured" + fi else ok "SKIP: strace unavailable — process-creation budget not asserted here" fi 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 5/9] 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 d66a7a87229aa472d73ec05748c09e474cd2121c Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:26:47 -0400 Subject: [PATCH 6/9] docs(context-guard): drop the em dashes from the 0.7.65 entry and README Both surfaces are declared purged in scripts/em-dash-purged-paths.txt, so the lint lane treats one as a regression. Co-Authored-By: Claude Fable 5.1 --- plugins/context-guard/CHANGELOG.md | 2 +- plugins/context-guard/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index 7c3f177199..9babbce491 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -9,7 +9,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed -- hooks: `zone-crossing-inject.sh` skips the zone resolver when nothing it reads has moved. A `$STATE_DIR/$SESSION.seen` mark, stamped with a redirection and compared with `-nt` (both builtins), records the inputs behind the last COMPLETED resolve; when the snapshot, `zones.json` and the compaction marker are all no newer than it, the fire exits before starting a process. The mark moves only after the markers persist, so a resolver failure, an `unknown` reading and a failed marker write are each retried. The envelope parse now uses `hook::jq_fields`' builtin parser on a payload within its proof ceiling and keeps the single here-string `jq` above it, because the helper's oversize fallback reads through a process substitution and costs four process creations against that `jq`'s two. Process creations under a Windows job object (5 reps, identical across reps; the subject's own floor is 3): small envelope, first fire 11 → 9, repeat with nothing moved 9 → **3**, snapshot rewritten 9 → 7; 150 KB batch payload, 11 → 11, 9 → **5**, 9 → 9. No cell is worse than before. Median wall for the small repeat fire, on a host whose timings are bimodal, 1,448 ms → 237 ms. The one failure mode: a snapshot written DURING a resolve is marked as seen, so its crossing waits for the next statusline render — the window is the resolve, not an mtime tick — and a missed crossing is late, never lost, because skipping only ever chooses silence. Crossing messages are byte-identical, asserted against a control session driven through the same zone sequence with no skipped fire. The per-batch budgets the contract test pins move with the paths: the steady fire now spawns nothing (0 commands, 0 process creations, 1 program launch) and a resolving fire spawns the resolver alone (1 command, 2 process creations, 3 program launches). +- hooks: `zone-crossing-inject.sh` skips the zone resolver when nothing it reads has moved. A `$STATE_DIR/$SESSION.seen` mark, stamped with a redirection and compared with `-nt` (both builtins), records the inputs behind the last COMPLETED resolve; when the snapshot, `zones.json` and the compaction marker are all no newer than it, the fire exits before starting a process. The mark moves only after the markers persist, so a resolver failure, an `unknown` reading and a failed marker write are each retried. The envelope parse now uses `hook::jq_fields`' builtin parser on a payload within its proof ceiling and keeps the single here-string `jq` above it, because the helper's oversize fallback reads through a process substitution and costs four process creations against that `jq`'s two. Process creations under a Windows job object (5 reps, identical across reps; the subject's own floor is 3): small envelope, first fire 11 → 9, repeat with nothing moved 9 → **3**, snapshot rewritten 9 → 7; 150 KB batch payload, 11 → 11, 9 → **5**, 9 → 9. No cell is worse than before. Median wall for the small repeat fire, on a host whose timings are bimodal, 1,448 ms → 237 ms. The one failure mode: a snapshot written DURING a resolve is marked as seen, so its crossing waits for the next statusline render, since the window is the resolve rather than an mtime tick, and a missed crossing is late, never lost, because skipping only ever chooses silence. Crossing messages are byte-identical, asserted against a control session driven through the same zone sequence with no skipped fire. The per-batch budgets the contract test pins move with the paths: the steady fire now spawns nothing (0 commands, 0 process creations, 1 program launch) and a resolving fire spawns the resolver alone (1 command, 2 process creations, 3 program launches). ## [0.7.64] diff --git a/plugins/context-guard/README.md b/plugins/context-guard/README.md index 9797c2ae53..c78152fad5 100644 --- a/plugins/context-guard/README.md +++ b/plugins/context-guard/README.md @@ -189,7 +189,7 @@ this host is bimodal and is reported only for the row it dominates: the small re fell from 1,448 ms to 237 ms. The one failure mode is a snapshot written DURING a resolve. The mark is stamped after the resolve -completes, so that write counts as seen and its crossing waits for the next statusline render — the +completes, so that write counts as seen and its crossing waits for the next statusline render; the window is the resolve, not an mtime tick. A missed crossing is therefore late, never lost, and the converse cannot happen: skipping only ever chooses silence, so no arrangement of timestamps can manufacture an injection the full path would not have made. From e2c053bce22c34a01b6fd9fec0a3540f508fcb8f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:53:48 -0400 Subject: [PATCH 7/9] fix(context-guard): re-resolve when zones.json or the compacted marker is removed The unchanged-input skip compared three inputs with `-nt` alone, which only ever sees an existing file becoming newer. Removing the zones.json override or the compaction marker moves no mtime, so both read as unchanged and the fire skipped, leaving the stale zone in place until some unrelated snapshot write. The mark now also carries one line recording whether each of the two optional inputs existed behind the last completed resolve, read back with the `read` builtin, and the skip is taken only when the three `-nt` tests are false and those flags still match the current `-e` results. A mark with no readable line, an older build's stamp or a write that failed after truncating, never takes the skip. Both flags are captured from what the resolve actually used, so an override created while the resolver runs is not recorded as seen. Every added read and write is a builtin, so both process budgets are unchanged: the small steady repeat fire still measures 3 process creations under a Windows job object, 5 reps identical, against the subject's own floor of 3. Co-Authored-By: Claude Fable 5.1 --- plugins/context-guard/CHANGELOG.md | 1 + plugins/context-guard/README.md | 8 +- .../hooks/zone-crossing-inject.sh | 52 +++++++++--- .../hooks/zone-crossing-inject.test.sh | 81 ++++++++++++++++++- 4 files changed, 124 insertions(+), 18 deletions(-) diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index 9babbce491..cd507b665a 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -10,6 +10,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed - hooks: `zone-crossing-inject.sh` skips the zone resolver when nothing it reads has moved. A `$STATE_DIR/$SESSION.seen` mark, stamped with a redirection and compared with `-nt` (both builtins), records the inputs behind the last COMPLETED resolve; when the snapshot, `zones.json` and the compaction marker are all no newer than it, the fire exits before starting a process. The mark moves only after the markers persist, so a resolver failure, an `unknown` reading and a failed marker write are each retried. The envelope parse now uses `hook::jq_fields`' builtin parser on a payload within its proof ceiling and keeps the single here-string `jq` above it, because the helper's oversize fallback reads through a process substitution and costs four process creations against that `jq`'s two. Process creations under a Windows job object (5 reps, identical across reps; the subject's own floor is 3): small envelope, first fire 11 → 9, repeat with nothing moved 9 → **3**, snapshot rewritten 9 → 7; 150 KB batch payload, 11 → 11, 9 → **5**, 9 → 9. No cell is worse than before. Median wall for the small repeat fire, on a host whose timings are bimodal, 1,448 ms → 237 ms. The one failure mode: a snapshot written DURING a resolve is marked as seen, so its crossing waits for the next statusline render, since the window is the resolve rather than an mtime tick, and a missed crossing is late, never lost, because skipping only ever chooses silence. Crossing messages are byte-identical, asserted against a control session driven through the same zone sequence with no skipped fire. The per-batch budgets the contract test pins move with the paths: the steady fire now spawns nothing (0 commands, 0 process creations, 1 program launch) and a resolving fire spawns the resolver alone (1 command, 2 process creations, 3 program launches). +- hooks: the same skip also requires EXISTENCE parity, not mtimes alone. The mark carries one line recording whether `zones.json` and the compaction marker existed behind the last completed resolve, read back with the `read` builtin, and the skip is taken only when the three `-nt` tests are false and those flags still match; a mark with no readable line never takes it. `-nt` cannot see a removal, so deleting an override or the compaction marker previously read as nothing having moved and left the stale zone in place until an unrelated snapshot write. Both process budgets are unchanged. ## [0.7.64] diff --git a/plugins/context-guard/README.md b/plugins/context-guard/README.md index c78152fad5..181b18ca70 100644 --- a/plugins/context-guard/README.md +++ b/plugins/context-guard/README.md @@ -164,8 +164,12 @@ the command-position budget still runs. Three files outside the hook decide everything it does: the per-session snapshot, the optional `zones.json`, and the compaction marker. When none is newer than the `.seen` mark the last -completed resolve left, the fire cannot reach a different answer, and the hook exits through -builtins alone. The envelope parse had to become free for that to mean anything, so a payload +completed resolve left, and the two optional ones still exist or are still absent exactly as that +mark's own line records them, the fire cannot reach a different answer, and the hook exits through +builtins alone. The existence line is what an mtime comparison cannot supply: a removed file is +never newer than anything, so without it, deleting `zones.json` or the compaction marker read as +nothing having moved. A mark carrying no readable line never takes the skip. The envelope parse had +to become free for any of this to mean anything, so a payload within `hook::jq_fields`' proof ceiling is parsed by the library's builtin JSON parser, and one above it keeps the single here-string `jq` described below. diff --git a/plugins/context-guard/hooks/zone-crossing-inject.sh b/plugins/context-guard/hooks/zone-crossing-inject.sh index baf9a704ad..eac2913c8a 100755 --- a/plugins/context-guard/hooks/zone-crossing-inject.sh +++ b/plugins/context-guard/hooks/zone-crossing-inject.sh @@ -284,11 +284,21 @@ COMPACTED_FILE="" # anything. # # `$STATE_DIR/$SESSION.seen` is the mark, stamped with a redirection rather -# than `touch(1)` so the steady path keeps costing zero processes, and compared -# with `-nt`, which is a bash builtin and involves no stat(1) dialect. `-nt` is -# true when the left file exists and the right one does not, so a session with -# no mark yet — the first fire, or one whose last fire failed to persist — -# never takes the skip. +# than `touch(1)` so the steady path keeps costing zero processes. It records +# the inputs behind the last completed resolve twice over: in its own mtime, +# compared with `-nt`, which is a bash builtin and involves no stat(1) dialect; +# and in one line naming which of the two OPTIONAL inputs existed, read back +# with the `read` builtin, which starts nothing either. `-nt` is true when the +# left file exists and the right one does not, so a session with no mark yet, +# the first fire or one whose last fire failed to persist, never takes the skip. +# +# BOTH RECORDS ARE REQUIRED, because `-nt` only ever sees a file that is there +# getting newer. Deleting zones.json restores the shipped bands and deleting the +# compaction marker un-degrades the session, and neither move touches an mtime +# the mtime half can read: to it a file that is gone reads exactly like one that +# never changed. So the skip also demands that the existence flags still match, +# and a mark carrying no readable line, an older build's stamp or a write that +# failed after truncating, never takes the skip at all. # # A MISSING SNAPSHOT is not skippable: the `-e` test fails and the resolver # runs and answers for it as it always has. @@ -306,9 +316,22 @@ if [[ -n "${HOME:-}" && -e "$HOME/.claude/context-guard/context/$SESSION.json" ] [[ ! "$HOME/.claude/context-guard/context/$SESSION.json" -nt "$SEEN_FILE" ]] && [[ ! "$HOME/.claude/context-guard/zones.json" -nt "$SEEN_FILE" ]] && [[ ! "$COMPACTED_FILE" -nt "$SEEN_FILE" ]]; then - exit 0 + seen_flags="" + IFS= read -r seen_flags <"$SEEN_FILE" 2>/dev/null || : + zones_now=0 + [[ -e "$HOME/.claude/context-guard/zones.json" ]] && zones_now=1 + compacted_now=0 + [[ -e "$COMPACTED_FILE" ]] && compacted_now=1 + [[ "$seen_flags" == "z=$zones_now c=$compacted_now" ]] && exit 0 fi +# Read BEFORE the resolver rather than at the stamp below, because what the mark +# records is what THIS resolve was decided on: an override created while the +# resolver runs lands older than the mark, so recording it as present would +# silence the first fire that could act on it. +zones_seen=0 +[[ -n "${HOME:-}" && -e "$HOME/.claude/context-guard/zones.json" ]] && zones_seen=1 + # Stderr redirected on the GROUP, not inside the substitution: the resolver is # one process, and `$(bash … 2>/dev/null)` billed two for it. Same suppression # (the resolver's zones.json notices stay hidden from this caller, as before), @@ -471,12 +494,17 @@ fi # now be treated as seen. Stamped HERE and nowhere earlier: a resolver that # failed, a reading of `unknown`, and a marker write that failed all exit above # this line, and all three must be RETRIED on the next fire rather than skipped -# — the decision they were owed was never made. Truncation rather than `touch`, -# and an empty file rather than a written one, because the mtime is the whole -# content; a failure to stamp is ignored for the same reason a failure to skip -# is harmless, namely that it only costs the next fire a resolve it would have -# done anyway. -: >"$SEEN_FILE" 2>/dev/null || : +# — the decision they were owed was never made. A redirection rather than +# `touch`, and one line rather than an empty file, because the mtime cannot +# carry the other half: the two optional inputs can be REMOVED, and a removal +# moves no mtime. The compacted flag is `degraded` rather than a fresh `-e`, for +# the same reason the zones flag was read before the resolver: it is what the +# decision used. A failure to stamp is ignored for the same reason a failure to +# skip is harmless, namely that it only costs the next fire a resolve it would +# have done anyway. +compacted_seen=0 +[[ -n "$degraded" ]] && compacted_seen=1 +printf 'z=%s c=%s\n' "$zones_seen" "$compacted_seen" >"$SEEN_FILE" 2>/dev/null || : ((new_rank > armed_rank)) || { # Nothing worse than this session has already reported. Three shapes reach diff --git a/plugins/context-guard/hooks/zone-crossing-inject.test.sh b/plugins/context-guard/hooks/zone-crossing-inject.test.sh index c891112fe6..5922320954 100755 --- a/plugins/context-guard/hooks/zone-crossing-inject.test.sh +++ b/plugins/context-guard/hooks/zone-crossing-inject.test.sh @@ -687,6 +687,78 @@ else fail "retry after a resolver failure was skipped: rc=$F_RC out=${F_OUT:0:120}" fi +# 13c. REMOVING zones.json moves the same input, and `-nt` cannot see it: a file +# that is gone is never newer than anything. The override above is what makes +# 60% resolve dumb, so deleting it restores the shipped bands and the session is +# acceptable again. The mark therefore records whether each OPTIONAL input +# existed, and the skip requires that record to still hold. +run "$ZFH" "$ZFD" szfast +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "zones.json skip: the repeat fire under the override is silent" +else + fail "zones.json skip: the fire under the override emitted: rc=$RC out=${OUT:0:120}" +fi +rm -f "$ZFH/.claude/context-guard/zones.json" +run "$ZFH" "$ZFD" szfast +# An improvement is silent by contract, so the persisted zone is the observable. +if [[ "$(cat "$ZFD/state/szfast.zone" 2>/dev/null)" == "acceptable" ]]; then + ok "zones.json removed: the next fire re-resolves under the shipped bands" +else + fail "a removed zones.json was skipped: zone=$(cat "$ZFD/state/szfast.zone" 2>/dev/null)" +fi + +# 13d. The compaction marker has the same hole, and the degraded reading lasts +# only as long as the marker does. A session that resolved dumb under one must +# resolve again once it is gone, although nothing left carries an mtime newer +# than the mark. +CMH="$WORK/home-cmark" +CMD="$WORK/data-cmark" +mkdir -p "$CMD" +write_snapshot "$CMH" scmark 10 # smart +run "$CMH" "$CMD" scmark +run "$CMH" "$CMD" scmark +if [[ $RC -eq 0 && -z "$OUT" ]]; then + ok "compaction marker: the repeat fire before any marker is silent" +else + fail "compaction marker: repeat fire emitted: rc=$RC out=${OUT:0:120}" +fi +# No sleep: creating the marker flips the recorded flag, so this half is settled +# by existence parity and needs no distinguishable mtime. The removal below is +# the same, which is the point, because an mtime race cannot decide either way. +: >"$CMH/$CTX_REL/scmark.compacted" +run "$CMH" "$CMD" scmark +if [[ $RC -eq 0 && "$OUT" == *additionalContext* && "$OUT" == *dumb* ]]; then + ok "compaction marker: a new marker resolves and reports the degraded zone" +else + fail "compaction marker: a new marker was skipped: rc=$RC out=${OUT:0:120}" +fi +rm -f "$CMH/$CTX_REL/scmark.compacted" +run "$CMH" "$CMD" scmark +if [[ "$(cat "$CMD/state/scmark.zone" 2>/dev/null)" == "smart" ]]; then + ok "compaction marker removed: the next fire re-resolves undegraded" +else + fail "a removed compaction marker was skipped: zone=$(cat "$CMD/state/scmark.zone" 2>/dev/null)" +fi + +# 13e. A MARK WITH NO READABLE LINE IS NOT A SKIP. The line is what makes the +# two removals above visible, so a mark left by an older build, or by a write +# that failed after truncating, carries none and must fall through to a resolve +# rather than inherit a skip it never earned. Truncating the mark also makes it +# the newest of the four files, so every `-nt` test passes and the line is the +# only thing left to refuse on. +: >"$D/state/sfps.seen" +run "$H" "$D" sfps +if [[ -s "$D/state/sfps.seen" ]]; then + ok "mark with no line: the fire re-resolves and re-stamps the mark" +else + fail "a mark with no line took the skip, so it was never re-stamped" +fi +if [[ "$(head -1 "$D/state/sfps.seen" 2>/dev/null)" == "z=0 c=0" ]]; then + ok "mark with no line: the new mark records both optional inputs as absent" +else + fail "mark line is not the existence record: [$(head -1 "$D/state/sfps.seen" 2>/dev/null)]" +fi + # No resolvable state root → stay silent rather than key the last-seen zone to # the working directory, which would re-inject on every cd. write_snapshot "$WORK/nohome" snr 90 @@ -716,10 +788,11 @@ fi # # A. THE STEADY NON-CROSSING PATH — the common case, and now the unchanged-input # skip: the snapshot, zones.json and the compaction marker are all older than -# the `.seen` mark the last resolve left, so the hook exits before the -# resolver. Budget: ZERO. The envelope parse is answered by hook::jq_fields' -# builtin parser, the mark is compared with `-nt` and stamped with a -# redirection, and all three are shell builtins. +# the `.seen` mark the last resolve left, and the mark's existence line still +# matches, so the hook exits before the resolver. Budget: ZERO. The envelope +# parse is answered by hook::jq_fields' builtin parser, the mark is compared +# with `-nt`, its line is read with `read`, and it is stamped with a +# redirection: every one of those is a shell builtin. # B. THE RESOLVING PATH — a fire whose snapshot has been rewritten since. Budget: # 1 bash : scripts/context-zone.sh, the single band authority this hook must # not re-implement; its own execs are in that process, not this trace 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 8/9] 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 9/9] 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() {