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 425df6bef1b07f2ffce264559889b7fb8cdaac7b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:48:57 -0400 Subject: [PATCH 3/9] perf(claude-ops): audit only transcript lines appended since last Stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stop hook re-read the whole tail window on every turn: a `wc`, a `grep`, and the library's stdin validation probe fired before it could learn that nothing new had failed. The transcript is the session's own JSONL and grows every turn, so no mtime or size sentinel on the file can answer "nothing new to audit" — the signal has to be about the appended content. A per-session cursor beside the warning marker records how many transcript lines have been audited. A later Stop reads only the lines past it with `mapfile`, pre-filters them with the same fixed string the grep used, and exits having created no process. The cursor advances only once the lines it covers are disposed of: no candidate, an empty structural selection, nothing left unwarned, or a warning emitted. A jq failure leaves it where it stood. Missing, malformed, pruned, ahead of a shortened transcript, or recorded against a different transcript_path all reset it to a full rescan, and a rescanned line cannot re-warn because the (hookName, command) marker still decides that. Job-object process accounting on Windows Git Bash, five runs per arm against a 3-creation harness floor (`bash -c`, `env`, `bash`): turn with no new failure 10 -> 3 (the floor: the hook creates nothing) first Stop of a session 10 -> 5 (7 when the data dir is created) turn with a failure 30 -> 21 Wall clock is not the record here — the same before-arm measured 0.36 s and 1.38 s an hour apart on this host — but the after-arm held 0.23-0.36 s across both runs where the before-arm ran 0.36-1.38 s. Findings are unchanged byte for byte. Proven by diffing this hook's output against the pre-change script over five fixtures (single record, three mixed classes, several registrations with both false-positive shapes, an over-cap transcript, a last line with no trailing newline) and over a two-turn incremental sequence; the suite now asserts the same equality in-tree, plus a PATH shim that fails loudly if any of jq/grep/wc/tail/sed/find/cat/mkdir runs on a second Stop. The payload fields now ride on `hook::buffer_stdin_to`, which fuses the library's `jq -e .` probe into the field read. That is why this branch first merges guardrails-run-guards-in-process-no-subs, which carries PR #4185's lib/hook-utils.sh sync: `hook::jq_fields` answers plain-string payload fields (transcript_path, session_id) with a builtin parser and spawns no jq. `hook::require_jq` moves after the fused call, because without jq the library returns an empty field array and reading it under `set -u` would kill the hook rather than fail open. The cold path keeps the tail cap — it is the one read the cursor cannot bound — and pays one `wc -lc`, which answers the cap decision and the cursor's starting line count in a single process. Bumps claude-ops 0.56.14 -> 0.56.15. The sibling branch claude-ops-session-event-log-spawns-whil (PR #4189) also bumps claude-ops to 0.56.14, so whichever lands second needs its entry renumbered. Co-Authored-By: Claude Fable 5.1 --- plugins/claude-ops/.claude-plugin/plugin.json | 2 +- plugins/claude-ops/CHANGELOG.md | 6 + .../claude-ops/hooks/hook-failure-audit.sh | 264 ++++++++++++++---- .../hooks/hook-failure-audit.test.sh | 179 +++++++++--- 4 files changed, 347 insertions(+), 104 deletions(-) diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index 699277718a..84534e2bf5 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.14", + "version": "0.56.15", "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 12b45389ba..b910de608e 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.15] + +### Changed + +- hook-failure-audit.sh audits only the transcript lines appended since the last Stop, keyed on a per-session cursor kept beside the warning marker. A turn with no new failure record now creates no process at all: measured by job-object accounting on Windows Git Bash at 3 process creations against a 3-creation harness floor (`bash -c`, `env`, `bash`), where it previously took 10. The count is the record because wall clock on that host drifts several-fold within an hour; across two runs the same turn measured 0.36-1.38 s before against 0.23-0.36 s after. The first Stop of a session keeps the tail cap and costs one `wc -lc` — the byte count the cap decision needs and the line count the cursor starts from, in one process — for 5 creations against 10, or 7 on the one session that first creates the data directory. The payload fields now ride on `hook::buffer_stdin_to`, which fuses the library's validation probe into the field read and answers both from the builtin parser. A turn that DOES carry a failure record reports exactly what it reported before, byte for byte. A cursor that is missing, malformed, pruned, ahead of a shortened transcript, or recorded against a different transcript_path rescans from the start, and rescanning cannot re-warn because the marker still decides that. + ## [0.56.14] ### Changed diff --git a/plugins/claude-ops/hooks/hook-failure-audit.sh b/plugins/claude-ops/hooks/hook-failure-audit.sh index 57573a09ba..303f375eb4 100755 --- a/plugins/claude-ops/hooks/hook-failure-audit.sh +++ b/plugins/claude-ops/hooks/hook-failure-audit.sh @@ -21,9 +21,17 @@ # PreToolUse/PostToolUse, for the cost rationale `guard_launch_monitor.py` and # ADR 0004 (D-12) record: a failure record is already in the transcript by the # time the turn ends, so once-per-turn cadence catches it as promptly as -# once-per-tool-call would at a fraction of the invocation count. The read is -# bounded (tail cap, truncated first line dropped) so per-turn cost is O(cap), -# not O(session length). +# once-per-tool-call would at a fraction of the invocation count. +# +# The scan is INCREMENTAL, keyed on a per-session cursor holding the number of +# transcript lines already audited. The transcript is the session's own JSONL +# and grows every turn, so no mtime or size sentinel on the FILE can mean +# "nothing new to audit" — the cheap signal has to be about the appended +# content. A Stop reads only the lines past the cursor, with `mapfile` and the +# shell's own pattern match, and a turn whose new lines carry no candidate exits +# having spawned no process at all. The cold scan (first Stop of a session, or a +# reset) keeps the tail cap so that one unbounded read stays O(cap). See the +# cursor block below for the file's shape and its failure modes. # # Matching is STRUCTURAL, never substring: a record counts only when the # top-level `.type == "attachment"` and `.attachment.type == @@ -63,16 +71,25 @@ hook::check_enabled "HOOK_FAILURE_AUDIT" START=${EPOCHREALTIME:-} -hook::buffer_stdin_to INPUT || exit 0 +# The payload fields are read by the SAME call that buffers stdin. Passing +# filters to hook::buffer_stdin_to fuses the library's `jq -e .` validation +# probe into the field read, and hook::jq_fields answers a well-formed payload's +# plain-string fields with the library's builtin parser — so a Stop envelope +# costs no process at all, where the unfused pair cost a fork and a jq exec. +# +# The fused call is also why hook::require_jq comes AFTER it rather than before: +# without jq the library returns an EMPTY field array and still reports success, +# and reading `${HOOK_JQ_FIELDS[0]}` from it under `set -u` would kill the hook +# with an unbound-variable error instead of failing open. The gate runs first, +# and the cardinality check behind it is what makes the array read safe. +hook::buffer_stdin_to INPUT '.transcript_path' '.session_id' || exit 0 # Advisory finding -> fail open, with the standard once-per-session notice. hook::require_jq Stop claude-ops "$INPUT" -# Both payload fields in ONE jq process (hook::jq_fields), not two: a jq spawn is -# a process, and two hook::jq_field calls read the same envelope twice for it. -# An absent field arrives as the empty string here rather than as a non-zero -# return, so each guard below is spelled out instead of riding on `||`. -hook::jq_fields "$INPUT" '.transcript_path' '.session_id' || exit 0 +# An absent field arrives as the empty string rather than as a non-zero return, +# so each guard below is spelled out instead of riding on `||`. +((${#HOOK_JQ_FIELDS[@]} == 2)) || exit 0 TRANSCRIPT="${HOOK_JQ_FIELDS[0]}" [[ -n "$TRANSCRIPT" && -f "$TRANSCRIPT" ]] || exit 0 SESSION="${HOOK_JQ_FIELDS[1]}" @@ -83,22 +100,84 @@ SESSION_ID="" [[ "$SESSION" != "no-session" && "$SESSION" =~ ^[A-Za-z0-9._-]+$ ]] && SESSION_ID="$SESSION" SESSION="${SESSION//[^A-Za-z0-9_-]/-}" -# Bounded tail read: cost stays O(cap) regardless of transcript growth. When -# the cap truncates, the first in-window line is likely partial — drop it, as -# guard_launch_monitor.py does. The override exists for the contract test. +# `mapfile` is Bash 4.0+ and these hooks document 3.2+ support (hook-utils.sh). +# Without it there is no builtin line reader, so the cursor is simply not +# available and every Stop takes the cold path's grep pre-filter — the work this +# hook has always done, never a silent skip. +HAVE_MAPFILE=0 +((BASH_VERSINFO[0] >= 4)) && HAVE_MAPFILE=1 + +# CURSOR: how many transcript lines this session has already audited. The file +# is "\n\n", beside the warning marker, under the same +# ${CLAUDE_PLUGIN_DATA} home and swept by the same 7-day prune. +# +# Every failure mode resolves toward RESCANNING, never toward silence — the same +# doctrine the marker follows: +# - no marker home, an unreadable or malformed cursor -> 0, a full scan +# - a different transcript_path -> 0, this session was handed another file +# - fewer lines present than the cursor -> 0, the transcript shrank or was +# replaced +# - a cursor pruned mid-session, or a Bash without `mapfile` -> 0 +# Rescanning cannot re-warn: the (hookName, command) marker below is what +# decides that, and it is unchanged. +# +# The cursor counts COMPLETE lines only. A final line with no newline is still +# scanned this turn — skipping it could hide a record the full scan would have +# surfaced — but it is not counted, so the next Stop reads it again once the +# harness has finished writing it. +MARKER_DIR="" +CURSOR_FILE="" +CURSOR=0 +if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then + MARKER_DIR="${CLAUDE_PLUGIN_DATA}/hook-failure-audit" + # `-d` first: after the first turn the directory always exists, and `mkdir -p` + # on an existing directory is a whole process to reach the same no-op. A + # directory that exists but is unwritable reaches the writes below and fails + # there, which is the degrade-toward-rescanning path. + if [[ -d "$MARKER_DIR" ]] || mkdir -p "$MARKER_DIR" 2>/dev/null; then + if ((HAVE_MAPFILE)); then + CURSOR_FILE="$MARKER_DIR/${SESSION}.cursor" + CURSOR_LINES="" + CURSOR_PATH="" + # Group redirect, not `$(/dev/null` is written BEFORE the + # input redirect because redirections apply left to right — after it, a + # failed open still prints its diagnostic, and a Stop hook's stderr is + # user-visible output. + [[ -f "$CURSOR_FILE" ]] && + { + IFS= read -r CURSOR_LINES + IFS= read -r CURSOR_PATH + } 2>/dev/null <"$CURSOR_FILE" + CURSOR_LINES="${CURSOR_LINES%$'\r'}" + CURSOR_PATH="${CURSOR_PATH%$'\r'}" + [[ "$CURSOR_LINES" =~ ^[0-9]+$ && "$CURSOR_PATH" == "$TRANSCRIPT" ]] && + CURSOR="$CURSOR_LINES" + fi + else + MARKER_DIR="" + fi +fi + +# Advanced only once the lines it covers have been DISPOSED of: no candidate, an +# empty structural selection, nothing left unwarned, or a warning emitted. A jq +# failure leaves the cursor where it stood so those lines are audited again. +# `2>/dev/null` ahead of the output redirect, as on the read above: an unwritable +# marker home must degrade to rescanning silently, not print at the user. +SCANNED=0 +cursor_advance() { + [[ -n "$CURSOR_FILE" ]] || return 0 + printf '%s\n%s\n' "$SCANNED" "$TRANSCRIPT" 2>/dev/null >"$CURSOR_FILE" || : +} + +# Bounded COLD read: the first Stop of a session (or a reset) is the one read +# the cursor cannot bound, so the byte cap stays. When the cap truncates, the +# first in-window line is likely partial — drop it, as guard_launch_monitor.py +# does. The override exists for the contract test. TAIL_BYTES="${HOOK_FAILURE_AUDIT_TAIL_BYTES:-2000000}" -# `wc -c -- `, not `wc -c `: bash runs the command of a command -# substitution in the substitution's own subshell and skips the extra fork ONLY -# when that command carries no redirection of its own, so the `<` bought a whole -# second process for one byte count (#3779). Naming the file instead adds a -# filename column, which `read` drops along with the leading padding some `wc` -# builds emit. The `2>/dev/null` rides on the surrounding single-command group, -# where it silences the same stream without re-arming the fork. -SIZE="" -{ read -r SIZE _ < <(wc -c -- "$TRANSCRIPT"); } 2>/dev/null -[[ -n "$SIZE" ]] || exit 0 -# grep is a cheap pre-filter only; the structural jq selection decides. The +# The pre-filter is a cheap candidate test only; the structural jq selection +# decides. The # no-match common case exits on the pre-filter's emptiness, before paying for # the jq spawn (an empty stream produced the same silent exit via "[]"). # `fromjson?` skips unparsable lines instead of aborting the stream. @@ -152,26 +231,79 @@ SIZE="" # group visible, and the message flags are computed from those counts, never # from a single collapsed value. # -# The window is read INTO grep, not through a `read_window` helper: a function -# call inside `$( )` is a second subshell on top of the substitution's own, and -# `cat -- file | grep` paid a further process to hand grep bytes it can open -# itself. Under the cap, grep now opens the transcript directly and the whole -# pre-filter is one process. Over the cap the pipeline is unchanged — the -# truncated first in-window line is likely partial and `sed '1d'` drops it, as -# guard_launch_monitor.py does — and its `2>/dev/null` stays exactly where it -# was, on `tail`, because a pipeline element forks either way and moving the -# redirect out would newly silence sed and grep for no saving. -if ((SIZE > TAIL_BYTES)); then - RECORDS=$(tail -c "$TAIL_BYTES" -- "$TRANSCRIPT" 2>/dev/null | sed '1d' | - grep -F '"hook_non_blocking_error"') -else - # Group-scoped redirect: `grep … 2>/dev/null` inside the substitution would - # cost the extra fork the file-argument form just saved (#3779). The group - # holds one command, so nothing beyond grep's own stderr is silenced — and - # that stream was already discarded before, by `cat`'s own `2>/dev/null`. - { RECORDS=$(grep -F '"hook_non_blocking_error"' -- "$TRANSCRIPT"); } 2>/dev/null +# `[[ $line == *needle* ]]` is `grep -F` on one line, and it is the same fixed +# string: identical selection, no process. `mapfile` is read WITHOUT `-t` so +# every line keeps its newline, which makes the joined candidates byte-identical +# to what `$(grep …)` produced — and makes a final line with no newline visible, +# the one line the cursor must not count. +NEEDLE='"hook_non_blocking_error"' +RECORDS="" +LINES=() +N=0 +scan_lines() { # + local i + for ((i = $1; i < N; i++)); do + [[ "${LINES[i]}" == *"$NEEDLE"* ]] && RECORDS+="${LINES[i]}" + done + RECORDS="${RECORDS%$'\n'}" +} + +if ((CURSOR > 0)); then + # One line BEFORE the cursor is read as an anchor, so the same read that + # fetches the new lines also proves the transcript still HAS that many: an + # empty result means it shrank or was replaced, and no second pass over the + # file is needed to find that out. `-s` discards the skipped lines rather than + # storing them, and `mapfile` is a builtin — no subshell, no exec. + mapfile -s $((CURSOR - 1)) LINES <"$TRANSCRIPT" 2>/dev/null + N=${#LINES[@]} + if ((N == 0)); then + CURSOR=0 + else + SCANNED=$((CURSOR - 1 + N)) + [[ "${LINES[N - 1]}" == *$'\n' ]] || SCANNED=$((SCANNED - 1)) + scan_lines 1 + fi +fi + +if ((CURSOR == 0)); then + # `wc -lc -- ` answers BOTH cold-path questions in one process: the byte + # count the cap decision needs, and the line count the cursor starts from + # (newlines, so a trailing partial line is excluded for free). Naming the file + # rather than `< file` is what keeps it one process: bash runs the command of + # a command substitution in the substitution's own subshell and skips the + # extra fork ONLY when that command carries no redirection of its own (#3779). + # `read` drops the filename column along with the leading padding some `wc` + # builds emit, and the `2>/dev/null` rides on the surrounding single-command + # group, where it silences the same stream without re-arming that fork. + SIZE="" + { read -r SCANNED SIZE _ < <(wc -lc -- "$TRANSCRIPT"); } 2>/dev/null + [[ "$SCANNED" =~ ^[0-9]+$ && -n "$SIZE" ]] || exit 0 + if ((SIZE > TAIL_BYTES)); then + # Over the cap the pipeline is unchanged — the truncated first in-window + # line is likely partial and `sed '1d'` drops it, as guard_launch_monitor.py + # does — and its `2>/dev/null` stays exactly where it was, on `tail`, + # because a pipeline element forks either way and moving the redirect out + # would newly silence sed and grep for no saving. Lines before the window + # are not read here and never were; the cursor simply stops the next Stop + # from re-deciding that. + RECORDS=$(tail -c "$TAIL_BYTES" -- "$TRANSCRIPT" 2>/dev/null | sed '1d' | + grep -F "$NEEDLE") + elif ((HAVE_MAPFILE)); then + mapfile LINES <"$TRANSCRIPT" 2>/dev/null + N=${#LINES[@]} + scan_lines 0 + else + # Group-scoped redirect: `grep … 2>/dev/null` inside the substitution would + # cost the extra fork the file-argument form just saved (#3779). The group + # holds one command, so nothing beyond grep's own stderr is silenced. + { RECORDS=$(grep -F "$NEEDLE" -- "$TRANSCRIPT"); } 2>/dev/null + fi fi -[[ -n "$RECORDS" ]] || exit 0 +LINES=() +[[ -n "$RECORDS" ]] || { + cursor_advance + exit 0 +} # `printf | jq` and NOT a here-string, even though the pipeline costs a process # the here-string would not. What is known, stated as known: hook::jq_field in # the shared library documents this hazard and refuses the here-string form for @@ -207,28 +339,29 @@ SUMMARY=$(printf '%s' "$RECORDS" | ambiguousCount: (map(select(.class == "ambiguous")) | length), completedCount: (map(select(.class == "completed")) | length), exitCode: last.exitCode, stderr: last.stderr})' 2>/dev/null) -[[ -n "$SUMMARY" && "$SUMMARY" != "[]" ]] || exit 0 +# An EMPTY $SUMMARY is jq failing, not a clean selection: leave the cursor where +# it stood so the same lines are audited again. `[]` is a document, and one that +# disposes of them. +[[ -n "$SUMMARY" ]] || exit 0 +[[ "$SUMMARY" != "[]" ]] || { + cursor_advance + exit 0 +} # Once per session per hook name. Markers live under ${CLAUDE_PLUGIN_DATA} -# (survives plugin updates); stale sessions' markers are pruned after 7 days. -# Any bookkeeping failure leaves WARNED empty, so everything found is treated -# as new — re-warn, never suppress. +# (survives plugin updates) in the directory resolved above; stale sessions' +# markers and cursors are pruned together after 7 days. Any bookkeeping failure +# leaves WARNED empty, so everything found is treated as new — re-warn, never +# suppress. MARKER="" WARNED="" -if [[ -n "${CLAUDE_PLUGIN_DATA:-}" ]]; then - MARKER_DIR="${CLAUDE_PLUGIN_DATA}/hook-failure-audit" - # `-d` first: after the first warned turn the directory always exists, and - # `mkdir -p` on an existing directory is a whole process to reach the same - # no-op. A directory that exists but is unwritable fell through `mkdir -p` - # successfully before too, and still fails at the marker write below. - if [[ -d "$MARKER_DIR" ]] || mkdir -p "$MARKER_DIR" 2>/dev/null; then - find "$MARKER_DIR" -type f -mtime +7 -delete 2>/dev/null - MARKER="$MARKER_DIR/${SESSION}" - # `$(/dev/null - fi +if [[ -n "$MARKER_DIR" ]]; then + find "$MARKER_DIR" -type f -mtime +7 -delete 2>/dev/null + MARKER="$MARKER_DIR/${SESSION}" + # `$(/dev/null fi # Marker lines are "\t" fingerprints. `rtrimstr("\r")` on @@ -238,7 +371,11 @@ fi NEW=$(jq -cn --argjson summary "$SUMMARY" --arg warned "$WARNED" ' ($warned | split("\n") | map(rtrimstr("\r")) | map(select(length > 0))) as $seen | [$summary[] | select((.hookName + " " + .command) as $k | $seen | index($k) | not)]') -[[ -n "$NEW" && "$NEW" != "[]" ]] || exit 0 +[[ -n "$NEW" ]] || exit 0 +[[ "$NEW" != "[]" ]] || { + cursor_advance + exit 0 +} TOTAL=$(jq -rn --argjson new "$NEW" '[$new[].count] | add') @@ -304,6 +441,11 @@ fi hook::emit_system_message "$MSG" +# The lines are disposed of the moment the warning is out. A marker write that +# fails after this point costs nothing: those registrations were warned about +# once, which is the contract. +cursor_advance + # Record what was warned about before telemetry: the warning is the contract, # the envelope is best-effort. # `tr -d '\r'` is gone, not its effect: the CRs come from a Windows jq build diff --git a/plugins/claude-ops/hooks/hook-failure-audit.test.sh b/plugins/claude-ops/hooks/hook-failure-audit.test.sh index ec85fb4724..185ca1fd0f 100755 --- a/plugins/claude-ops/hooks/hook-failure-audit.test.sh +++ b/plugins/claude-ops/hooks/hook-failure-audit.test.sh @@ -289,6 +289,92 @@ OUT6=$(run_hook "$T3" "$DATA3" HOOK_FAILURE_AUDIT_TAIL_BYTES=8000) assert_contains "in-window failure reported" "$OUT6" "PreToolUse:InWindow" assert_absent "out-of-window failure not read" "$OUT6" "PreToolUse:OutOfWindow" +# --- Incremental scan: the cursor -------------------------------------------- +# The cursor records how many transcript lines a session has already audited, so +# a later Stop reads only what was appended. Four properties are asserted: a +# turn with nothing new spawns nothing at all, a failure appended past the +# cursor produces EXACTLY the message a full rescan produces, and both a shorter +# transcript and a different transcript_path reset the cursor rather than +# skipping lines that were never audited. + +# A PATH shim that fails loudly instead of doing the work. `command -v jq` still +# succeeds — that is what lets the library's builtin field parser proceed — so +# any surviving spawn reaches a shim and breaks silence. +SHIM="$TEST_TMPDIR/shim" +mkdir -p "$SHIM" +for PROG in jq grep wc tail sed find cat mkdir; do + printf '#!/bin/sh\necho "SPAWNED %s" >&2\nexit 99\n' "$PROG" >"$SHIM/$PROG" + chmod +x "$SHIM/$PROG" +done + +T_CUR="$TEST_TMPDIR/cursor.jsonl" +DATA_CUR="$TEST_TMPDIR/data-cursor" +{ + for _ in {1..6}; do + printf '{"type":"assistant","message":{"content":[{"type":"text","text":"fine"}]},"uuid":"c","session_id":"s"}\n' + done + failure_record "PreToolUse:Bash" "first-registration.sh" +} >"$T_CUR" +OUT_C1=$(run_hook "$T_CUR" "$DATA_CUR") +assert_contains "cursor: first Stop warns" "$OUT_C1" "first-registration.sh" +assert_eq "cursor: file records the audited line count" "7" \ + "$(head -1 "$DATA_CUR/hook-failure-audit/test-session.cursor")" +assert_eq "cursor: file records the transcript path" "$T_CUR" \ + "$(sed -n 2p "$DATA_CUR/hook-failure-audit/test-session.cursor")" + +# Nothing appended: the second Stop must reach its exit with no process at all. +OUT_C2=$(run_hook "$T_CUR" "$DATA_CUR" PATH="$SHIM:$PATH") +assert_silent "cursor: unchanged transcript -> silent, nothing spawned" "$OUT_C2" + +# Benign lines only: still nothing to hand to jq, still no process. +for _ in {1..20}; do + printf '{"type":"assistant","message":{"content":[{"type":"text","text":"fine"}]},"uuid":"c","session_id":"s"}\n' >>"$T_CUR" +done +OUT_C3=$(run_hook "$T_CUR" "$DATA_CUR" PATH="$SHIM:$PATH") +assert_silent "cursor: appended benign lines -> silent, nothing spawned" "$OUT_C3" + +# A failure appended past the cursor: byte-identical to what a full rescan of +# the same transcript, against the same marker state, produces. DATA_FULL is a +# copy of the incremental state with only the cursor removed, so the two runs +# differ in nothing but how much of the transcript they read. +failure_record "SessionStart" "second-registration.mjs" >>"$T_CUR" +DATA_FULL="$TEST_TMPDIR/data-cursor-full" +rm -rf "$DATA_FULL" +cp -r "$DATA_CUR" "$DATA_FULL" +rm -f "$DATA_FULL/hook-failure-audit/test-session.cursor" +OUT_INC=$(run_hook "$T_CUR" "$DATA_CUR") +OUT_FULL=$(run_hook "$T_CUR" "$DATA_FULL") +assert_contains "cursor: appended failure is reported" "$OUT_INC" "second-registration.mjs" +assert_eq "cursor: incremental output equals a full rescan's" "$OUT_FULL" "$OUT_INC" +assert_absent "cursor: the already-warned registration stays muted" "$OUT_INC" "first-registration.sh" + +# A SHORTER transcript resets the cursor. Without the reset the read starts past +# the end of the file and the new record is never seen. +T_SHORT="$TEST_TMPDIR/cursor.jsonl" +failure_record "PreToolUse:Shrunk" "after-truncation.sh" >"$T_SHORT" +OUT_C4=$(run_hook "$T_SHORT" "$DATA_CUR") +assert_contains "cursor: a shorter transcript rescans from the start" "$OUT_C4" "after-truncation.sh" + +# A DIFFERENT transcript_path resets it too. This file is LONGER than the stored +# cursor and carries its failure record BEFORE it, so only the path check can +# save the record: a stale line count would skip straight past it. +T_OTHER="$TEST_TMPDIR/cursor-other.jsonl" +DATA_OTHER="$TEST_TMPDIR/data-cursor-other" +{ + for _ in {1..30}; do + printf '{"type":"assistant","message":{"content":[{"type":"text","text":"fine"}]},"uuid":"c","session_id":"s"}\n' + done +} >"$T_OTHER" +run_hook "$T_OTHER" "$DATA_OTHER" >/dev/null # cursor: 30 lines of this path +printf '%s\n' "$(failure_record 'PreToolUse:Moved' 'other-transcript.sh')" \ + >"$TEST_TMPDIR/cursor-other-2.jsonl" +for _ in {1..40}; do + printf '{"type":"assistant","message":{"content":[{"type":"text","text":"fine"}]},"uuid":"c","session_id":"s"}\n' >>"$TEST_TMPDIR/cursor-other-2.jsonl" +done +OUT_C5=$(run_hook "$TEST_TMPDIR/cursor-other-2.jsonl" "$DATA_OTHER") +assert_contains "cursor: a different transcript_path rescans from the start" \ + "$OUT_C5" "other-transcript.sh" + # --- Kill switch ------------------------------------------------------------- OUT7=$(run_hook "$T2" "$TEST_TMPDIR/data-kill" CLAUDE_PLUGIN_OPTION_HOOK_FAILURE_AUDIT_ENABLED=false) RC7=$? @@ -346,23 +432,24 @@ fi # `-ff` writes one file per pid, so no syscall line is ever split across an # / pair where a naive grep would silently undercount. # -# The common path is a turn with NO hook failure recorded, under the tail cap. -# Budget: -# 1 wc the file size the tail-cap decision needs -# 1 grep the pre-filter, reading the transcript directly -# 2 jq both inside hook-utils.sh (buffer_stdin's validation probe, and the -# single hook::jq_fields payload read) — a synced library this plugin -# does not own -# 0 cat the removed process: `cat -- file | grep` handed grep bytes it can -# open itself, through a `read_window` function call that was a -# second subshell on top of the substitution's own +# TWO paths are measured, because the cursor splits them. The COLD path is the +# first Stop of a session, under the tail cap: +# 1 wc `wc -lc`: the byte count the cap decision needs and the line count +# the cursor starts from, in one process +# 1 mkdir the marker/cursor directory, created once per data home +# 0 jq the payload fields ride on hook::buffer_stdin_to, and the library +# answers a plain-string field with its builtin parser +# 0 grep the pre-filter is `[[ $line == *needle* ]]` over a `mapfile` read +# 0 cat, 0 tail, 0 sed +# The WARM path — every later Stop, which is the cadence this hook actually runs +# at — reads only the appended lines and creates NOTHING. # -# MUTATION-CHECKED: moving a silenced redirect back inside its substitution — -# `SIZE=$(wc -c <"$TRANSCRIPT" 2>/dev/null)`, or -# `RECORDS=$(grep -F … -- "$TRANSCRIPT" 2>/dev/null)` — adds a fork with NO new -# exec, leaves every behavioural assertion above green, and trips the creation -# ceiling below. That is what the creation count is for; execve alone is blind -# to it. +# MUTATION-CHECKED: moving a silenced redirect back inside its substitution +# (`SIZE=$(wc -lc <"$TRANSCRIPT" 2>/dev/null)`) adds a fork with NO new exec, +# leaves every behavioural assertion above green, and trips the cold creation +# ceiling below. Dropping the cursor write leaves every behavioural assertion +# green too, and trips the warm ceiling. That is what the creation count is for; +# execve alone is blind to both. TRACE_OK=1 command -v strace >/dev/null 2>&1 || TRACE_OK=0 if ((TRACE_OK)); then @@ -373,42 +460,50 @@ if ((TRACE_OK == 0)); then else TB="$TEST_TMPDIR/budget.jsonl" printf '{"type":"assistant","message":{"content":[{"type":"text","text":"all fine"}]},"uuid":"c","session_id":"s"}\n' >"$TB" - TRACE_PREFIX="$TEST_TMPDIR/budget-trace" - env CLAUDE_PLUGIN_DATA="$TEST_TMPDIR/data-budget" HOOK_TELEMETRY_SINK="" \ - strace -ff -qq -s 400 -e trace=clone,clone3,fork,vfork,execve -o "$TRACE_PREFIX" \ - bash "$HOOK" <<<"{\"session_id\":\"budget\",\"transcript_path\":\"$TB\",\"hook_event_name\":\"Stop\"}" \ - >/dev/null 2>&1 - TRACE_ALL="$TEST_TMPDIR/budget-trace.all" - cat "$TRACE_PREFIX".* >"$TRACE_ALL" 2>/dev/null - if [[ -s "$TRACE_ALL" ]]; then - ok "budget: the common path was traced" - else - bad "budget: no usable strace output captured" - fi + TRACE_ALL="" + trace_hook() { # + local prefix="$TEST_TMPDIR/$1" + env CLAUDE_PLUGIN_DATA="$TEST_TMPDIR/data-budget" HOOK_TELEMETRY_SINK="" \ + strace -ff -qq -s 400 -e trace=clone,clone3,fork,vfork,execve -o "$prefix" \ + bash "$HOOK" <<<"{\"session_id\":\"budget\",\"transcript_path\":\"$TB\",\"hook_event_name\":\"Stop\"}" \ + >/dev/null 2>&1 + TRACE_ALL="$prefix.all" + cat "$prefix".* >"$TRACE_ALL" 2>/dev/null + } # Every process creation the kernel saw, subshell forks included. - CREATIONS=$(grep -cE '^(clone|clone3|fork|vfork)\(' "$TRACE_ALL") + creations() { grep -cE '^(clone|clone3|fork|vfork)\(' "$TRACE_ALL"; } # Successful execs only: a PATH search emits failing execve calls that spawn # nothing. The `bash ` exec at the top is strace's own, not a cost of # the hook, and is excluded by matching the hook path in its argv — which is # why the trace runs with `-s 400`: at strace's 32-byte default that path is # abbreviated and the exclusion silently matches nothing. - EXECS=$(grep -E '^execve\(.*= 0$' "$TRACE_ALL" | grep -c -v -e "$HOOK") - PROGS=$(grep -E '^execve\(.*= 0$' "$TRACE_ALL" | grep -v -e "$HOOK" | - grep -oE '^execve\("[^"]+"' | sed 's|.*/||; s|"||' | sort | uniq -c | tr -s ' \n' ' ') + execs() { grep -E '^execve\(.*= 0$' "$TRACE_ALL" | grep -c -v -e "$HOOK"; } + progs() { + grep -E '^execve\(.*= 0$' "$TRACE_ALL" | grep -v -e "$HOOK" | + grep -oE '^execve\("[^"]+"' | sed 's|.*/||; s|"||' | sort | uniq -c | tr -s ' \n' ' ' + } prog_count() { grep -cE "^execve\\(\"[^\"]*/$1\"" "$TRACE_ALL"; } - if ((CREATIONS <= 9)); then - ok "budget: common path creates $CREATIONS processes (ceiling 9)" - else - bad "budget: common path creates $CREATIONS processes, ceiling is 9 —$PROGS" - fi - if ((EXECS <= 4)); then - ok "budget: common path execs $EXECS programs (ceiling 4)" + ceiling() { #