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 01/15] 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 e4cb218f9b1660c994f6c1bdbccbcda05b3b2d6a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:30:45 -0400 Subject: [PATCH 02/15] perf(guardrails): make ps-command.sh fork-free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PowerShell lane's PreToolUse chain spent 77 of its 80 process creations inside this one library, across 35 sites: 24 command-substitution captures of ps:: helpers (1 creation each), 8 `printf | sed` pipelines (4 each) and 3 `< <(printf …)` line readers (1 each). Every capture is now an out-parameter helper assigning with `printf -v` (the `_to` convention lib/hook-utils.sh uses), and every pipeline and reader is a pure-bash substitution or split. Measured on Windows Git Bash, 5 runs each (creations / isolated p50): PowerShell `exit 0` 80 / 2422 ms -> 3 / 300 ms PowerShell script block + git token 44 / 1433 ms -> 3 / 299 ms Bash `true` (never loads this library) 3 / 288 ms -> 3 / 274 ms 3 is the harness floor: the `bash -c`, the `env` of the shebang, and bash. No decision changes. Byte-identical rc, stdout and stderr against the pre-change tree: 17-command perf corpus, both tool modes cases=34 mismatches=0 653 harvested commands, PowerShell lane cases=653 mismatches=0 653 harvested commands, Bash lane cases=653 mismatches=0 Plus two narrower differentials over the same corpus: every converted helper answer, sink-blanking result, classify verdict and write_bypass verdict for 697 inputs (15334 rows, 0 differences), and each converted substitution against the `sed` it replaced (5054 cases, 0 differences). Two behaviors that only the narrow differentials could see are carried deliberately: `$(…)` on this host eats a trailing CRLF whole, and its `sed` reads in text mode, so a CRLF line ending loses its CR. Both are reproduced rather than dropped. block-dangerous-git, block-no-verify, block-hook-bypass, block-noncanonical-commit and run-guards report the same results as the pre-change tree on this host, failures included. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 6 + .../hooks/block-dangerous-git.test.sh | 2 +- .../hooks/block-hook-bypass.test.sh | 10 +- .../guardrails/hooks/block-no-verify.test.sh | 2 +- .../guardrails/lib/powershell/ps-command.sh | 318 ++++++++++++------ 6 files changed, 226 insertions(+), 114 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 5e47e41b6f..1592f6c129 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.34.0" + "version": "0.35.0" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index ab6a246b80..85b6327129 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,12 @@ 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.35.0] + +### Changed + +- lib/powershell/ps-command.sh spawns nothing. Every `$(ps::…)` capture is now an out-parameter helper that assigns with `printf -v` — `ps::blank_quoted_spans_to`, `ps::opaque_quoted_spans_to`, `ps::call_site_operand_region_to`, `ps::blank_bracket_interiors_to`, `ps::fold_escaped_brace_closers_to` and the three `ps::_skip_*_to` index walkers, the `_to` convention hook-utils.sh already uses — and every `printf | sed` pipeline and `< <(printf …)` line reader is a pure-bash substitution or split (`ps::_gsub_to`, which applies an ERE per line exactly as sed hands its regex one line at a time, and `ps::_split_lines_to`). On Windows Git Bash the PowerShell lane's PreToolUse chain went from 80 process creations to 3 — the harness's `bash -c`, the `env` of the shebang, and bash itself — and 2422 ms to 300 ms isolated p50; a PowerShell command that carries a script block and a git token, so it reaches the fail-closed sink, from 44 creations and 1433 ms to 3 and 299 ms. The Bash lane, which never loads this library, stays at 3 creations and 288 ms to 274 ms. Every deny and allow is byte-identical (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over 653 commands harvested from the guard suites on each lane. Two host behaviors the captured forms carried are reproduced rather than dropped, because a PowerShell command arrives with Windows line endings and PS_SAFE_COMMAND goes on to a Bash tokenizer: `$(…)` here eats a trailing CRLF whole, not just its LF, and this host's `sed` reads in text mode, so a CRLF line ending loses its CR. + ## [0.34.0] ### Changed diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 344274b923..509dfbeaca 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -1259,7 +1259,7 @@ assert_contains "NUL msg: all-NUL command refused by the flag, not skipped" \ run "empty command, no NUL (allowed)" "" 0 # --- #2965: an apostrophe in a DOUBLE-quoted string is not a span delimiter ----- -# ps::blank_quoted_spans used to pair quotes with two independent `sed` +# ps::blank_quoted_spans_to used to pair quotes with two independent `sed` # expressions, neither aware of which style opened first. The single-quote # expression matched from the apostrophe inside one double-quoted string to the # apostrophe inside the next and DELETED everything between them: diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 838fcd0cb7..0840a0615a 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -876,7 +876,7 @@ run_pwsh "PS: braced call target, single positional (allowed — #2848 verifier) # scanner's `[^}]*` stopped at the injected brace, the whitespace boundary failed, # and the call site vanished: both measuring probes returned false and the gate # fell through ALLOWED. The escape is now consumed BEFORE the deletion -# (ps::fold_escaped_brace_closers), and the target token may carry non-space text +# (ps::fold_escaped_brace_closers_to), and the target token may carry non-space text # glued after its closing brace. Blocked pre-0.28.33; these pin the recovery. # shellcheck disable=SC2016 run_pwsh "PS: escaped closer in a braced call target, positional Path+Value (blocked — #2908 review)" \ @@ -1083,7 +1083,7 @@ run_pwsh "PS: & { git diff } > file (tool producer, allowed)" \ "& { git diff } > out.txt" 0 # --- fd-dup merge must not hide a computed writer's operands (#2927) --------- -# The `&` inside `2>&1` sits at bracket depth ZERO, and ps::call_site_operand_region +# The `&` inside `2>&1` sits at bracket depth ZERO, and ps::call_site_operand_region_to # ends a call's operand region at a depth-zero `;` `|` `&`. So the region of # `& $w 2>&1 f.txt x` was truncated to `" 2>"`, both measuring probes went silent, # and a working `Set-Content ` — verified as a real write under @@ -2150,7 +2150,7 @@ bash "$HOOK" <<<"$(jq -n '{tool_name:"Bash",tool_input:{command:("git status" + assert_exit "NUL in command (blocked)" 2 "$nul_rc" # --- #2965: an apostrophe in a DOUBLE-quoted string is not a span delimiter ----- -# ps::blank_quoted_spans used to pair quotes with two independent `sed` +# ps::blank_quoted_spans_to used to pair quotes with two independent `sed` # expressions, neither aware of which style opened first. The single-quote # expression matched from the apostrophe inside one double-quoted string to the # apostrophe inside the next and DELETED everything between them, so a computed @@ -2179,7 +2179,7 @@ run_pwsh "PS: bare-computed writer with -Value, straddled (blocked — #2965)" \ # Both the backtick and the doubled-quote escape therefore delete NOTHING on # their line. This spelling # reaches write_bypass through `lcq_bt` — the backtick-intact copy built before -# backticks are stripped from `lcq` — so `ps::blank_quoted_spans` sees the +# backticks are stripped from `lcq` — so `ps::blank_quoted_spans_to` sees the # backtick and the backtick-ambiguity branch emits the line verbatim. The # doubled-quote arm is not what catches this pinned case. # shellcheck disable=SC2016 @@ -2203,7 +2203,7 @@ run_pwsh "PS: #2848 bare-computed call target flanked by an apostrophe (allowed "Write-Host \"Kyle's build\"; & \$py \$script (Join-Path \$dir \"\$id.jsonl\")" 0 # --- #2906: quoting an operand is not a free escape from the positional signal -- -# ps::blank_quoted_spans DELETES quoted spans, so the two-positional arm of +# ps::blank_quoted_spans_to DELETES quoted spans, so the two-positional arm of # ps::computed_call_has_positional_write_signal saw `& $w 'f.txt' 'x'` as a # zero-operand call. Quoting is the idiomatic Path+Value spelling, not an # obscure one. The contained fix keeps those operands present-but-opaque for diff --git a/plugins/guardrails/hooks/block-no-verify.test.sh b/plugins/guardrails/hooks/block-no-verify.test.sh index 534095adb8..d56763e23e 100755 --- a/plugins/guardrails/hooks/block-no-verify.test.sh +++ b/plugins/guardrails/hooks/block-no-verify.test.sh @@ -584,7 +584,7 @@ assert_contains "NUL msg: all-NUL command refused by the flag, not skipped" \ run "empty command, no NUL (allowed)" "" 0 # --- #2965: an apostrophe in a DOUBLE-quoted string is not a span delimiter ----- -# ps::blank_quoted_spans used to pair quotes with two independent `sed` +# ps::blank_quoted_spans_to used to pair quotes with two independent `sed` # expressions, neither aware of which style opened first. The single-quote # expression matched from the apostrophe inside one double-quoted string to the # apostrophe inside the next and DELETED everything between them — including the diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 92c8478657..59ec0a0f04 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -95,6 +95,103 @@ PS_SINK_TRIGGER="" # shellcheck disable=SC2034 PS_SAFE_COMMAND="" +# --- fork-free primitives ----------------------------------------------------- +# +# Six guards load this file on the hot path of every PowerShell tool call, and +# on Windows Git Bash a command substitution is one process creation while an +# external command in a pipeline is two more. So nothing here reaches a result +# through `$(…)` or `printf | sed`: a helper whose answer is a string takes the +# caller's variable NAME and assigns it with `printf -v` (the `_to` convention +# lib/hook-utils.sh uses), and the substitutions below are done in bash. +# +# TWO RULES MAKE A `_to` HELPER A DROP-IN FOR THE `$(…)` IT REPLACES. It ends by +# chomping the trailing newline run a capture would have eaten (ps::_chomp_to). +# And its own locals are name-prefixed wherever a caller might pass one of them +# as the out-parameter — `printf -v` would otherwise assign the callee's local +# and leave the caller's variable untouched, which is a silent wrong answer +# rather than an error. + +# ps::_chomp_to +# +# TEXT without the trailing newline run `$(…)` would have removed. +# +# A trailing CRLF goes WHOLE, not just its LF. Measured on the Windows Git Bash +# these guards run under: `$(printf 'a\r\n')` is `a`, `$(printf 'a\r\r\n')` is +# `a` plus one CR, and an INTERIOR CRLF survives (`$(printf 'a\r\nb')` keeps +# both bytes). A helper that dropped only the LF would hand its caller a +# trailing `\r` the captured form never had — and PS_SAFE_COMMAND goes on to a +# Bash tokenizer, where that CR joins the final token. +ps::_chomp_to() { + local __ch_s="$2" + while [[ "$__ch_s" == *$'\n' ]]; do + __ch_s="${__ch_s%$'\n'}" + __ch_s="${__ch_s%$'\r'}" + done + printf -v "$1" '%s' "$__ch_s" +} + +# ps::_split_lines_to +# +# The newline-separated parts of TEXT, in the named array — what +# `while IFS= read -r line; do … done < <(printf '%s\n' "$text")` yielded, minus +# the process substitution. `printf '%s\n'` appends one newline, so the reader +# saw exactly these parts, including the trailing EMPTY part of a TEXT that +# already ended in a newline. +ps::_split_lines_to() { + local -n __sl_out="$1" + local __sl_rest="$2" + __sl_out=() + while [[ "$__sl_rest" == *$'\n'* ]]; do + __sl_out+=("${__sl_rest%%$'\n'*}") + __sl_rest="${__sl_rest#*$'\n'}" + done + __sl_out+=("$__sl_rest") +} + +# ps::_gsub_to +# +# `sed -E "s///g"` over TEXT, in bash. +# +# PER LINE, because that is what sed does: it hands its regex one line at a time +# with the newline already stripped, so in sed `[[:space:]]` can never match a +# newline and `.` never spans one, while bash's engine is handed the whole string +# and both would. Within a line the two agree — both resolve an ambiguous match +# POSIX leftmost-longest — so the per-line match set is identical. +# +# A CRLF LINE ENDING LOSES ITS CR, because this host's sed reads in text mode: +# measured, `printf 'a\r\nb\r\n' | sed …` writes `a\nb\n`, while a CR that ends +# no line survives (`a\rb`, and a final unterminated `a\r`). PowerShell commands +# arrive with Windows line endings, and PS_SAFE_COMMAND goes on to a Bash +# tokenizer, so leaving those CRs in would attach one to a token the captured +# form never had. +# +# The match POSITION comes from `${line%%"$m"*}`, the first LITERAL occurrence of +# the matched text. That is this match's own position: were the same text to +# occur earlier, the regex would have matched THERE instead, since no pattern in +# this file is anchored. An empty match would not advance, so it ends the line +# rather than looping forever; no pattern here can produce one, each requiring at +# least one literal character. +ps::_gsub_to() { + local __gs_re="$3" __gs_repl="$4" __gs_out="" __gs_line __gs_acc __gs_pre __gs_m __gs_i + local -a __gs_lines=() + ps::_split_lines_to __gs_lines "$2" + for ((__gs_i = 0; __gs_i < ${#__gs_lines[@]}; __gs_i++)); do + __gs_line="${__gs_lines[__gs_i]}" + ((__gs_i + 1 < ${#__gs_lines[@]})) && __gs_line="${__gs_line%$'\r'}" + __gs_acc="" + while [[ "$__gs_line" =~ $__gs_re ]]; do + __gs_m="${BASH_REMATCH[0]}" + [[ -n "$__gs_m" ]] || break + __gs_pre="${__gs_line%%"$__gs_m"*}" + __gs_acc+="${__gs_pre}${__gs_repl}" + __gs_line="${__gs_line:${#__gs_pre}+${#__gs_m}}" + done + ((__gs_i)) && __gs_out+=$'\n' + __gs_out+="${__gs_acc}${__gs_line}" + done + ps::_chomp_to "$1" "$__gs_out" +} + # Unicode code points PowerShell's tokenizer treats as TOKEN-SEPARATING # whitespace but bash's `[[:space:]]` does not. Spelled as raw UTF-8 byte # sequences via `$'\xNN'`, which is byte-literal and therefore identical under a @@ -189,10 +286,12 @@ ps::blank_herestrings() { ps::normalize_token_separating_spaces "$1" local cmd="$PS_NORMALIZED" local line out="" pending="" in_hs=0 hs_quote="" first2 rest closer opener_scan + local -a hs_lines=() PS_HERESTRING_UNBALANCED=0 PS_HERESTRING_QUOTE="" - while IFS= read -r line || [[ -n "$line" ]]; do + ps::_split_lines_to hs_lines "$cmd" + for line in "${hs_lines[@]}"; do if ((in_hs)); then first2="${line:0:2}" closer="${hs_quote}@" # '@ or "@ @@ -214,9 +313,10 @@ ps::blank_herestrings() { # would swallow following code lines into a phantom here-string body). # Distinguish by stripping PAIRED quote spans first: a real opener's quote # is unpaired, so its `@'` survives, while `'@'` / `'foo@'` disappear. - # One `sed` per line, not two: the expressions apply in order, so the - # double-quote strip still sees the single-quote-stripped line. - opener_scan=$(printf '%s' "$line" | sed -E -e "s/'[^']*'//g" -e 's/"([^"\\]|\\.)*"//g') + # The two strips apply IN ORDER, so the double-quote strip still sees the + # single-quote-stripped line. + ps::_gsub_to opener_scan "$line" "'[^']*'" '' + ps::_gsub_to opener_scan "$opener_scan" '"([^"\\]|\\.)*"' '' if [[ "$opener_scan" == *"@'" || "$opener_scan" == *'@"' ]]; then hs_quote="${line: -1}" # ' or " pending="${line%??}${PS_HERESTRING_PLACEHOLDER}" @@ -224,7 +324,7 @@ ps::blank_herestrings() { continue fi out+="${line}"$'\n' - done < <(printf '%s\n' "$cmd") # not <<<: a >=64KiB here-string deadlocks (see hardcoded-path-patterns.sh) + done if ((in_hs)); then # Opener with no column-zero closer: ambiguous extent. Blanking to end could @@ -238,8 +338,8 @@ ps::blank_herestrings() { PS_BLANKED="${out%$'\n'}" } -# The single left-to-right quoted-span walk behind ps::blank_quoted_spans and -# ps::opaque_quoted_spans. MODE is `blank` (a found span is deleted) or `opaque` +# The single left-to-right quoted-span walk behind ps::blank_quoted_spans_to and +# ps::opaque_quoted_spans_to. MODE is `blank` (a found span is deleted) or `opaque` # (a found span becomes a classified placeholder, per the classification the # opaque wrapper documents). WHERE a span starts and ends, and every ambiguity # resolution, is identical for both and therefore stated once here: two @@ -291,7 +391,7 @@ ps::blank_herestrings() { # and ambiguity here means DELETE NOTHING ON THIS LINE. Neither available answer # is safe on its own, which is why the resolution is to refuse the question: # -# - HONORING the escape (what `ps::_skip_double_quote` does, correctly, in the +# - HONORING the escape (what `ps::_skip_double_quote_to` does, correctly, in the # sink BLANKING path that runs after the entry decision) extends the span # past `` `" `` to the next real quote, so # `Write-Host "a`"; & ('g'+'it') push --force; Write-Host "b"` becomes one @@ -307,8 +407,8 @@ ps::blank_herestrings() { # deleted, which is the deeper reason this branch exists. SINGLE-quoted spans are # exempt: PowerShell gives them no escape at all, so a backtick inside one is an # ordinary character and the pairing is genuinely unambiguous. -ps::_walk_quoted_spans() { - local text="$1" mode="$2" out="" i=0 n j q found c inner +ps::_walk_quoted_spans_to() { + local text="$2" mode="$3" out="" i=0 n j q found c inner n=${#text} while ((i < n)); do q="${text:i:1}" @@ -361,17 +461,17 @@ ps::_walk_quoted_spans() { out+="$q" i=$((i + 1)) done - printf '%s' "$out" + ps::_chomp_to "$1" "$out" } # Crude, SCAN-ONLY strip of single- and double-quoted spans, so that structural # detection and commit/push shaping ignore characters inside message text. Never # fed to a parser. -ps::blank_quoted_spans() { - ps::_walk_quoted_spans "$1" blank +ps::blank_quoted_spans_to() { + ps::_walk_quoted_spans_to "$1" "$2" blank } -# Sibling of ps::blank_quoted_spans for ONE consumer: +# Sibling of ps::blank_quoted_spans_to for ONE consumer: # ps::computed_call_has_positional_write_signal. Same left-to-right pairing — # first opener owns its span, ambiguity copies the rest of the line verbatim — # but a FOUND span is replaced by a classified placeholder instead of deleted. @@ -400,11 +500,11 @@ ps::blank_quoted_spans() { # 3. otherwise → `_q_` # Present-but-opaque visible literal. # -# An EMPTY span (`""`, `''`) is still deleted, matching blank_quoted_spans. +# An EMPTY span (`""`, `''`) is still deleted, matching blank_quoted_spans_to. # `& $py $script ""` must stay allowed (#2965); counting the empty string as a # literal would turn that pin into an over-block. -ps::opaque_quoted_spans() { - ps::_walk_quoted_spans "$1" opaque +ps::opaque_quoted_spans_to() { + ps::_walk_quoted_spans_to "$1" "$2" opaque } # Fold a BACKTICK-ESCAPED closing brace to `_`, left to right, BEFORE any caller @@ -432,8 +532,8 @@ ps::opaque_quoted_spans() { # `${my`{writer}` deletes to `${my{writer}`, where `[^}]*` matches the name and # the real closer still terminates it — and removing a `{` other probes count # would be a change outside this finding. -ps::fold_escaped_brace_closers() { - local s="$1" out="" i n ch +ps::fold_escaped_brace_closers_to() { + local s="$2" out="" i n ch n=${#s} for ((i = 0; i < n; i++)); do ch="${s:i:1}" @@ -457,7 +557,7 @@ ps::fold_escaped_brace_closers() { fi out+="$ch" done - printf '%s' "$out" + ps::_chomp_to "$1" "$out" } # True (0) when the (quote-stripped) text carries a PowerShell construct the Bash @@ -651,8 +751,8 @@ ps::call_target_is_bare_subexpression() { # An unmatched OPENER (a genuinely unbalanced command) leaves the region running # to end of string; the callers stay conservative on what they can still see, and # such a command does not parse in PowerShell to begin with. -ps::call_site_operand_region() { - local s="$1" out="" i ch depth=0 +ps::call_site_operand_region_to() { + local s="$2" out="" i ch depth=0 for ((i = 0; i < ${#s}; i++)); do ch="${s:i:1}" case "$ch" in @@ -672,7 +772,7 @@ ps::call_site_operand_region() { esac out+="$ch" done - printf '%s' "$out" + ps::_chomp_to "$1" "$out" } # Blank the INTERIOR of every balanced bracket group in a call's operand region, @@ -681,8 +781,8 @@ ps::call_site_operand_region() { # `& $ic -ScriptBlock { & $w @p }` belongs to the inner `& $w`, which the call-site # walk reaches on its own iteration, and the interior of `${script:Path}` holds no # operands at all. -ps::blank_bracket_interiors() { - local s="$1" out="" i ch depth=0 +ps::blank_bracket_interiors_to() { + local s="$2" out="" i ch depth=0 for ((i = 0; i < ${#s}; i++)); do ch="${s:i:1}" case "$ch" in @@ -702,7 +802,7 @@ ps::blank_bracket_interiors() { esac if ((depth > 0)); then out+=" "; else out+="$ch"; fi done - printf '%s' "$out" + ps::_chomp_to "$1" "$out" } ps::computed_call_has_positional_write_signal() { @@ -744,10 +844,11 @@ ps::computed_call_has_positional_write_signal() { # count, and the `}` ending an ENCLOSING script block is not an operand — # while a `}` that closes one of this call's own operands must not truncate # past the operands after it (review of #2848). - rest=$(ps::call_site_operand_region "$rest") + ps::call_site_operand_region_to rest "$rest" # Drop redirect operands (`> f`, `2> err`) — those are covered by the # redirect probe; they are not Path+Value positionals. - rest=$(printf '%s' "$rest" | sed -E 's/[0-9*]*>+[^[:space:]]*//g; s/[0-9*]*<+[^[:space:]]*//g') + ps::_gsub_to rest "$rest" '[0-9*]*>+[^[:space:]]*' '' + ps::_gsub_to rest "$rest" '[0-9*]*<+[^[:space:]]*' '' # shellcheck disable=SC2086 # intentional word-split on PowerShell tokens for tok in $rest; do [[ -z "$tok" ]] && continue @@ -840,8 +941,8 @@ ps::computed_call_has_splat_operand() { # blanked: a `}` closing one of THIS call's operands must not truncate away a # real splat after it (`& $w ${script:Path} @Body`), while a splat nested # inside a script block belongs to the inner call the walk reaches next. - rest=$(ps::call_site_operand_region "$rest") - rest=$(ps::blank_bracket_interiors "$rest") + ps::call_site_operand_region_to rest "$rest" + ps::blank_bracket_interiors_to rest "$rest" if [[ "$rest" =~ (^|[[:space:]])@[a-z0-9_:]+ ]]; then return 0 fi @@ -1100,7 +1201,7 @@ ps::might_write_via_python3() { # The quote-BLANKED command: an `open(` or a quoted mention inside the write # payload is removed, so an UNQUOTED `(` (subexpression) or `$` (variable) that # survives here is a genuine computed construct, not payload text. - blanked=$(ps::blank_quoted_spans "$1") + ps::blank_quoted_spans_to blanked "$1" # A launcher (Start-Process/saps/start/pwsh/powershell/cmd) whose PROGRAM name is # COMPUTED cannot be proven non-python. Rather than model parameter ordering / # binding with a regex (which successive rounds defeated — one preceding option, @@ -1117,7 +1218,7 @@ ps::might_write_via_python3() { fi # A call `&` / dot-source `.` of a DOUBLE-QUOTED target that INTERPOLATES a # variable or subexpression (`& "$env:PYTHON_BIN" …`, `& "$(…)" …`) runs a - # COMPUTED program that could resolve to python3. blank_quoted_spans erases the + # COMPUTED program that could resolve to python3. blank_quoted_spans_to erases the # target (so the launcher/token tests miss it) and it is not a launcher, so match # it here on the quote-INTACT text and fail closed. A SINGLE-quoted target does # NOT interpolate in PowerShell (`& '$x'` is the literal name `$x`), so it is not @@ -1181,7 +1282,7 @@ ps::has_dynamic_invocation() { # following quote visible (`& "…"` / `. '…'`). Quote-blanked confirms the # `$name=` itself is not inside a string. [[ "$recovered" =~ (^|[[:space:]\;\{\}\(\|\&])\$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*=[[:space:]]*[.\&][[:space:]]*[$q] ]] || return 1 - blanked=$(ps::blank_quoted_spans "$recovered") + ps::blank_quoted_spans_to blanked "$recovered" [[ "$blanked" =~ \$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*=[[:space:]]*[.\&] ]] } @@ -1208,7 +1309,7 @@ ps::has_launcher() { # and a quoted `$out=pwsh` stay data (about_Quoting_Rules). `git -c # section.key=cmd` is git(1) `-c =`, not an assignment, and # does not match. Spelled out literally, never shared through a variable. - blanked=$(ps::blank_quoted_spans "$lc") + ps::blank_quoted_spans_to blanked "$lc" [[ "$blanked" =~ (^|[[:space:]\;\|\&\(])\$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*=[[:space:]]*(start-process|saps|start|pwsh|powershell|cmd)(\.exe)?([[:space:]]|$) ]] } @@ -1232,7 +1333,7 @@ ps::classify_git_command() { [[ "$tool" == "PowerShell" ]] || return 0 ps::blank_herestrings "$cmd" - scan=$(ps::blank_quoted_spans "$PS_BLANKED") + ps::blank_quoted_spans_to scan "$PS_BLANKED" # Record WHICH trigger routed the command here. Four distinct shapes reach this # sink and they need different remediation: an operator told to "remove the # unparsable construct" when the trigger was a launcher or a computed call @@ -1270,12 +1371,12 @@ ps::classify_git_command() { # regardless of which OS the HOOK runs on, and hook::git_is_bin strips # `.exe` only on its msys/cygwin branch. local reduced="${PS_BLANKED//\\//}" - reduced=$(printf '%s' "$reduced" | sed -E 's/[Gg][Ii][Tt]\.[Ee][Xx][Ee]/git/g') + ps::_gsub_to reduced "$reduced" '[Gg][Ii][Tt]\.[Ee][Xx][Ee]' git # PowerShell `$var=cmd` / `$var+=cmd` begins a new pipeline on the RHS without # requiring whitespace. Bash expands `$var` and leaves `=cmd` as a non-git # word, so strip the assignment prefix so the RHS command word is visible # (Claude review on #2592: `$x=git reset --hard`). - reduced=$(printf '%s' "$reduced" | sed -E 's/\$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*(\+=|-=|\*=|\/=|%=|=)[[:space:]]*/ /g') + ps::_gsub_to reduced "$reduced" '\$[A-Za-z_][A-Za-z0-9_]*(:[A-Za-z_][A-Za-z0-9_]*)?[[:space:]]*(\+=|-=|\*=|/=|%=|=)[[:space:]]*' ' ' # Read by the sourcing guard, not within this library. # shellcheck disable=SC2034 PS_SAFE_COMMAND="$reduced" @@ -1318,81 +1419,81 @@ ps::_at_command_position() { [[ "$prev" == [[:space:]\;\|\&\{\}\(\)] ]] } -# Consume a double-quoted span starting at IDX (points at "); returns end index -# (one past the closer, or past end of string if unbalanced). -ps::_skip_double_quote() { - local cmd="$1" i="$2" n=${#1} c - i=$((i + 1)) - while ((i < n)); do - c="${cmd:i:1}" - if [[ "$c" == '`' ]]; then - i=$((i + 2)) +# Consume a double-quoted span starting at IDX (points at "); assigns the end +# index (one past the closer, or past end of string if unbalanced) to VARNAME. +ps::_skip_double_quote_to() { + local __dq_cmd="$2" __dq_i="$3" __dq_n=${#2} __dq_c + __dq_i=$((__dq_i + 1)) + while ((__dq_i < __dq_n)); do + __dq_c="${__dq_cmd:__dq_i:1}" + if [[ "$__dq_c" == '`' ]]; then + __dq_i=$((__dq_i + 2)) continue fi - if [[ "$c" == '"' ]]; then - echo $((i + 1)) + if [[ "$__dq_c" == '"' ]]; then + printf -v "$1" '%s' "$((__dq_i + 1))" return 0 fi - i=$((i + 1)) + __dq_i=$((__dq_i + 1)) done - echo "$n" + printf -v "$1" '%s' "$__dq_n" } # Consume a single-quoted span starting at IDX (points at '). -ps::_skip_single_quote() { - local cmd="$1" i="$2" n=${#1} c - i=$((i + 1)) - while ((i < n)); do - c="${cmd:i:1}" - if [[ "$c" == "'" ]]; then - echo $((i + 1)) +ps::_skip_single_quote_to() { + local __sq_cmd="$2" __sq_i="$3" __sq_n=${#2} __sq_c + __sq_i=$((__sq_i + 1)) + while ((__sq_i < __sq_n)); do + __sq_c="${__sq_cmd:__sq_i:1}" + if [[ "$__sq_c" == "'" ]]; then + printf -v "$1" '%s' "$((__sq_i + 1))" return 0 fi - i=$((i + 1)) + __sq_i=$((__sq_i + 1)) done - echo "$n" + printf -v "$1" '%s' "$__sq_n" } # Advance IDX to the end of the current statement/pipeline element at depth 0 # (stop before top-level `;`, newline, `|`, `&&`, `||`). Quote- and depth-aware. -ps::_skip_statement_tail() { - local cmd="$1" i="$2" n=${#1} depth=0 c - while ((i < n)); do - c="${cmd:i:1}" - if ((depth == 0)); then - if [[ "$c" == "'" ]]; then - i=$(ps::_skip_single_quote "$cmd" "$i") +ps::_skip_statement_tail_to() { + local __st_cmd="$2" __st_i="$3" __st_n=${#2} __st_depth=0 __st_c + while ((__st_i < __st_n)); do + __st_c="${__st_cmd:__st_i:1}" + if ((__st_depth == 0)); then + if [[ "$__st_c" == "'" ]]; then + ps::_skip_single_quote_to __st_i "$__st_cmd" "$__st_i" continue fi - if [[ "$c" == '"' ]]; then - i=$(ps::_skip_double_quote "$cmd" "$i") + if [[ "$__st_c" == '"' ]]; then + ps::_skip_double_quote_to __st_i "$__st_cmd" "$__st_i" continue fi - if [[ "$c" == ';' || "$c" == $'\n' ]]; then - echo "$i" + if [[ "$__st_c" == ';' || "$__st_c" == $'\n' ]]; then + printf -v "$1" '%s' "$__st_i" return 0 fi - if [[ "$c" == '|' ]]; then + if [[ "$__st_c" == '|' ]]; then # `|` and `||` both end this pipeline element / statement. - echo "$i" + printf -v "$1" '%s' "$__st_i" return 0 fi - if [[ "$c" == '&' && "${cmd:i+1:1}" == '&' ]]; then - echo "$i" + if [[ "$__st_c" == '&' && "${__st_cmd:__st_i+1:1}" == '&' ]]; then + printf -v "$1" '%s' "$__st_i" return 0 fi # Bare `&` is the call operator (or background) — part of this statement. fi - case "$c" in - '{') depth=$((depth + 1)) ;; - '}') ((depth > 0)) && depth=$((depth - 1)) ;; - '(') depth=$((depth + 1)) ;; - ')') ((depth > 0)) && depth=$((depth - 1)) ;; + case "$__st_c" in + '{') __st_depth=$((__st_depth + 1)) ;; + '}') ((__st_depth > 0)) && __st_depth=$((__st_depth - 1)) ;; + '(') __st_depth=$((__st_depth + 1)) ;; + ')') ((__st_depth > 0)) && __st_depth=$((__st_depth - 1)) ;; *) ;; esac - i=$((i + 1)) + __st_i=$((__st_i + 1)) done - echo "$n" + printf -v "$1" '%s' "$__st_n" } # Blank dynamic-invocation or launcher statements in CMD. KIND is `dynamic` or @@ -1402,13 +1503,13 @@ ps::_blank_cmd_statements() { while ((i < n)); do c="${cmd:i:1}" if [[ "$c" == "'" ]]; then - end=$(ps::_skip_single_quote "$cmd" "$i") + ps::_skip_single_quote_to end "$cmd" "$i" out+="${cmd:i:end-i}" i=$end continue fi if [[ "$c" == '"' ]]; then - end=$(ps::_skip_double_quote "$cmd" "$i") + ps::_skip_double_quote_to end "$cmd" "$i" out+="${cmd:i:end-i}" i=$end continue @@ -1422,7 +1523,7 @@ ps::_blank_cmd_statements() { if [[ "$lc" =~ ^(iex|invoke-expression)([^a-z0-9_-]|$) ]]; then if [[ "$lc" == iex* ]]; then word=3; else word=18; fi # iex / Invoke-Expression — blank through end of this pipeline element. - end=$(ps::_skip_statement_tail "$cmd" $((i + word))) + ps::_skip_statement_tail_to end "$cmd" $((i + word)) i=$end out+=" " matched=1 @@ -1431,7 +1532,7 @@ ps::_blank_cmd_statements() { j=$((i + 1)) while ((j < n)) && [[ "${cmd:j:1}" == [[:space:]] ]]; do j=$((j + 1)); done if ((j < n)) && [[ "${cmd:j:1}" == "'" || "${cmd:j:1}" == '"' ]]; then - end=$(ps::_skip_statement_tail "$cmd" "$i") + ps::_skip_statement_tail_to end "$cmd" "$i" i=$end out+=" " matched=1 @@ -1440,7 +1541,7 @@ ps::_blank_cmd_statements() { ;; launcher) if [[ "$lc" =~ ^(start-process|saps|start|pwsh|powershell|cmd)(\.exe)?([^a-z0-9_-]|$) ]]; then - end=$(ps::_skip_statement_tail "$cmd" "$i") + ps::_skip_statement_tail_to end "$cmd" "$i" i=$end out+=" " matched=1 @@ -1466,13 +1567,13 @@ ps::_blank_special_construct_regions() { while ((i < n)); do c="${cmd:i:1}" if [[ "$c" == "'" ]]; then - end=$(ps::_skip_single_quote "$cmd" "$i") + ps::_skip_single_quote_to end "$cmd" "$i" out+="${cmd:i:end-i}" i=$end continue fi if [[ "$c" == '"' ]]; then - end=$(ps::_skip_double_quote "$cmd" "$i") + ps::_skip_double_quote_to end "$cmd" "$i" out+="${cmd:i:end-i}" i=$end continue @@ -1484,7 +1585,7 @@ ps::_blank_special_construct_regions() { continue fi if [[ "${cmd:i:3}" == '--%' ]]; then - end=$(ps::_skip_statement_tail "$cmd" "$i") + ps::_skip_statement_tail_to end "$cmd" "$i" i=$end out+=" " continue @@ -1497,11 +1598,11 @@ ps::_blank_special_construct_regions() { while ((i < n && depth > 0)); do c="${cmd:i:1}" if [[ "$c" == "'" ]]; then - i=$(ps::_skip_single_quote "$cmd" "$i") + ps::_skip_single_quote_to i "$cmd" "$i" continue fi if [[ "$c" == '"' ]]; then - i=$(ps::_skip_double_quote "$cmd" "$i") + ps::_skip_double_quote_to i "$cmd" "$i" continue fi if [[ "$c" == '`' ]]; then @@ -1529,9 +1630,11 @@ ps::_blank_special_construct_regions() { # PS_SAFE_COMMAND (prefix before the hanging opener, if any). ps::_blank_unbalanced_herestring_tail() { local cmd="$1" line out="" pending="" in_hs=0 hs_quote="" first2 closer opener_scan + local -a hs_lines=() # Mirror ps::blank_herestrings' opener detection; once an opener has no closer, # drop it and everything after (extent unknown — trailing code may be inside). - while IFS= read -r line || [[ -n "$line" ]]; do + ps::_split_lines_to hs_lines "$cmd" + for line in "${hs_lines[@]}"; do if ((in_hs)); then first2="${line:0:2}" closer="${hs_quote}@" @@ -1543,7 +1646,8 @@ ps::_blank_unbalanced_herestring_tail() { fi continue fi - opener_scan=$(printf '%s' "$line" | sed -E -e "s/'[^']*'//g" -e 's/"([^"\\]|\\.)*"//g') + ps::_gsub_to opener_scan "$line" "'[^']*'" '' + ps::_gsub_to opener_scan "$opener_scan" '"([^"\\]|\\.)*"' '' if [[ "$opener_scan" == *"@'" || "$opener_scan" == *'@"' ]]; then hs_quote="${line: -1}" pending="${line%??}" @@ -1551,7 +1655,7 @@ ps::_blank_unbalanced_herestring_tail() { continue fi out+="${line}"$'\n' - done < <(printf '%s\n' "$cmd") + done if ((in_hs)); then # Hanging opener: keep only the prefix before it; drop the opaque tail. # shellcheck disable=SC2034 @@ -1679,14 +1783,14 @@ ps::write_bypass() { # and vanish from the braced-target scanner entirely (review of #2848). This is # the load-bearing position: the probes cannot do it themselves, because by the # time they are called the backticks are already gone. - lcq=$(ps::fold_escaped_brace_closers "$PS_BLANKED") + ps::fold_escaped_brace_closers_to lcq "$PS_BLANKED" # Keep a backtick-INTACT copy for the quote-blanking below, for the same reason # the brace fold has to run before the deletion: a backtick-escaped QUOTE is an # escape context that the deletion destroys. `"say `"hi"` is one string, but # once the backtick is gone it reads as `"say "` + `hi` + a dangling `"` whose # pairing runs forward to the next literal quote anywhere on the line — which # swallowed `& ('set-'+'content') f.txt x` and returned 0 (review of #2965). - # ps::blank_quoted_spans can only resolve that toward NOT deleting while the + # ps::blank_quoted_spans_to can only resolve that toward NOT deleting while the # backtick still exists, so it must see this copy; the result is stripped # afterwards, which still recovers an obfuscated `Set``-Content` name. lcq_bt="${lcq,,}" @@ -1706,7 +1810,7 @@ ps::write_bypass() { # Both this and the quoted-writer check above accept a statement/block separator # boundary (`;& …`), not only whitespace (review round 6). if ps::call_target_is_bare_computed "$lcq"; then - blanked_gate=$(ps::blank_quoted_spans "$lcq_bt") + ps::blank_quoted_spans_to blanked_gate "$lcq_bt" blanked_gate="${blanked_gate//\`/}" # fd-dup merges (`2>&1`) are plumbing, not file writes — strip them before # ANY probe in this branch runs, so `& $tool 2>&1` does not look like a @@ -1714,7 +1818,7 @@ ps::write_bypass() { # separator by the call-site walk. # # The fd-dup strip MUST run on BOTH texts below before any probe sees them, - # not only on the `>` redirect probe's copy. `ps::call_site_operand_region` + # not only on the `>` redirect probe's copy. `ps::call_site_operand_region_to` # ends a call's operand region at a depth-zero `;` `|` `&`, and the `&` # inside `2>&1` is at depth zero, so handing the MEASURING probes unstripped # text truncates the region of `& $w 2>&1 f.txt x` (a working @@ -1731,17 +1835,17 @@ ps::write_bypass() { # # Redirect / -va* probes run on quote-blanked text so a quoted `>` or # `-value` substring in message text is not a write signal (#2722 review). - blanked_gate=$(printf '%s' "$blanked_gate" | sed -E 's/[0-9*]*>&[0-9]+//g') + ps::_gsub_to blanked_gate "$blanked_gate" '[0-9*]*>&[0-9]+' '' # Quoted operands stay PRESENT-BUT-OPAQUE for the positional probe only. - # blank_quoted_spans DELETES them, so `& $w 'f.txt' 'x'` counted as a + # blank_quoted_spans_to DELETES them, so `& $w 'f.txt' 'x'` counted as a # zero-operand call and quoting was a general evasion of the Path+Value # signal (#2906). A global placeholder would also feed quoted `>` / `-value` # in message text to the redirect and `-va*` probes — measured fail-open # on producer-redirect rows — so this string is derived here and handed # to ps::computed_call_has_positional_write_signal alone. - opaque_gate=$(ps::opaque_quoted_spans "$lcq_bt") + ps::opaque_quoted_spans_to opaque_gate "$lcq_bt" opaque_gate="${opaque_gate//\`/}" - opaque_gate=$(printf '%s' "$opaque_gate" | sed -E 's/[0-9*]*>&[0-9]+//g') + ps::_gsub_to opaque_gate "$opaque_gate" '[0-9*]*>&[0-9]+' '' # Grouping is NOT a write signal (#2848): a blanket # ps::has_special_constructs gate treats ANY grouping anywhere in the # command as one, reporting an ordinary `foreach (…) { & $py run.py $x }` @@ -1783,7 +1887,7 @@ ps::write_bypass() { fi fi - scan=$(ps::blank_quoted_spans "$PS_BLANKED") + ps::blank_quoted_spans_to scan "$PS_BLANKED" # Delete backticks before matching so a name obfuscated by PowerShell's escape # char (`Set``-Content`) resolves to its real form. scan="${scan//\`/}" @@ -1848,9 +1952,11 @@ ps::write_bypass() { # them BEFORE splitting, or the `&` inside `2>&1` cuts a phantom `1 > file` # segment that the numeric-producer test would wrongly block # (`git status 2>&1 > out.txt` is a tool capture, not a content write). - lcs=$(printf '%s' "$lcs" | sed -E 's/[0-9*]*>&[0-9]+//g') + ps::_gsub_to lcs "$lcs" '[0-9*]*>&[0-9]+' '' local norm="${lcs//[|;&]/$'\n'}" - while IFS= read -r seg; do + local -a seg_lines=() + ps::_split_lines_to seg_lines "$norm" + for seg in "${seg_lines[@]}"; do seg="${seg#"${seg%%[![:space:]]*}"}" # ltrim [[ "$seg" == *'>'* ]] || continue # Exclude the `$null` discard (PowerShell's /dev/null). @@ -1889,6 +1995,6 @@ ps::write_bypass() { # Only the SPACED form — an attached digit prefix (`2>err.txt`, `2>&1`) is a # stream redirect whose producer is the preceding tool, not a value. [[ "$head" =~ ^[0-9]+([.][0-9]+)?$ ]] && return 0 - done < <(printf '%s\n' "$norm") # not <<<: a >=64KiB here-string deadlocks (see hardcoded-path-patterns.sh) + done return 1 } From d809a9e2e5f4f7b40882f4d1c40212461cd1d137 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:20:17 -0400 Subject: [PATCH 03/15] fix(guardrails): exempt a quoted git literal in comparison position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ps::might_invoke_git blanks a quoted string literal to the inert bareword `_q_` before its literal-git probe when BOTH hold, and keeps the quote-intact probe otherwise: (a) the literal's nearest preceding non-whitespace token is a PowerShell comparison operator (-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge, optional c/i case prefix), reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list. RIGHT-hand operands only: `& 'git' -eq $x` is a call, not a comparison, so a left-hand literal is not decidable here. (b) the whole command carries no invocation shape that could execute a computed value — no call or dot-source of a non-bare-word target (`& $_.Name`, `. $x`, `& ('g'+'it')`, `& "$tool"`, `& 'bash'`), no iex / Invoke-Expression / Invoke-Command / icm, and no launcher or nested shell as a bare command word (Start-Process, saps, start, pwsh, powershell, cmd, bash, sh, wsl, node, python). Under (b) the only thing the command can do with the string is compare it, so the exemption cannot reach execution. Newly allowed (blocked before): read-only pipelines that merely NAME git — `Get-Process | Where-Object { $_.Name -eq 'git' }`, `-ceq 'git'`, `-in @('git.exe','bash.exe')`, `-notin @('git','node')`, `-like 'git*'`. Still blocked, each a test case: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names`, an operand list long enough to exhaust the bounded walk, and every `-eq 'git' … | % { $_.Name }` form (&, ., iex, Invoke-Command, cmd /c, bash -c, a quoted `& 'bash' -c`, a path-shaped `& .\$_.Name`). Bounded by a two-root differential against the pre-narrowing tree: the 653-command harvested corpus is byte-identical on both the PowerShell and the Bash lane, the perf baseline's 17-command corpus is identical across both tool modes, and a designed-case corpus flips exactly 5 commands, all BLOCKED->ALLOWED with the sink message gone, leaving its other 18 blocked. The chain still creates 3 processes on a PowerShell call that reaches the sink. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/CHANGELOG.md | 1 + .../hooks/block-dangerous-git.test.sh | 96 +++++++++++ .../guardrails/lib/powershell/ps-command.sh | 157 +++++++++++++++++- 3 files changed, 245 insertions(+), 9 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 85b6327129..dfeecad761 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to the `guardrails` plugin are documented here. Format follo ### Changed - lib/powershell/ps-command.sh spawns nothing. Every `$(ps::…)` capture is now an out-parameter helper that assigns with `printf -v` — `ps::blank_quoted_spans_to`, `ps::opaque_quoted_spans_to`, `ps::call_site_operand_region_to`, `ps::blank_bracket_interiors_to`, `ps::fold_escaped_brace_closers_to` and the three `ps::_skip_*_to` index walkers, the `_to` convention hook-utils.sh already uses — and every `printf | sed` pipeline and `< <(printf …)` line reader is a pure-bash substitution or split (`ps::_gsub_to`, which applies an ERE per line exactly as sed hands its regex one line at a time, and `ps::_split_lines_to`). On Windows Git Bash the PowerShell lane's PreToolUse chain went from 80 process creations to 3 — the harness's `bash -c`, the `env` of the shebang, and bash itself — and 2422 ms to 300 ms isolated p50; a PowerShell command that carries a script block and a git token, so it reaches the fail-closed sink, from 44 creations and 1433 ms to 3 and 299 ms. The Bash lane, which never loads this library, stays at 3 creations and 288 ms to 274 ms. Every deny and allow is byte-identical (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over 653 commands harvested from the guard suites on each lane. Two host behaviors the captured forms carried are reproduced rather than dropped, because a PowerShell command arrives with Windows line endings and PS_SAFE_COMMAND goes on to a Bash tokenizer: `$(…)` here eats a trailing CRLF whole, not just its LF, and this host's `sed` reads in text mode, so a CRLF line ending loses its CR. +- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command carries no invocation shape that could execute a computed value — no call or dot-source of a non-bare-word target (`& $_.Name`, `. $x`, `& ('g'+'it')`, `& "$tool"`, `& 'bash'`, `& .\$_.Name`), no `iex` / `Invoke-Expression` / `Invoke-Command` / `icm`, and no launcher or nested shell as a bare command word (`Start-Process`, `saps`, `start`, `pwsh`, `powershell`, `cmd`, `bash`, `sh`, `wsl`, `node`, `python`). Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, and every `… -eq 'git' … | % { $_.Name }` form (`&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, and a quoted `& 'bash' -c`). Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted — no command in it carries a git literal in operand position) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes; a designed-case corpus of the exempt and counterexample shapes flips exactly 5 commands, every one BLOCKED→ALLOWED with the sink message gone, and leaves its other 18 blocked byte-for-byte. ## [0.34.0] diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 509dfbeaca..3d19c18017 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -1196,6 +1196,102 @@ run_pwsh "PS #2667: sink allow + reset-hard allow opens iex;reset compound" \ "Invoke-Expression 'Write-Host harmless'; git reset --hard" 0 \ CLAUDE_PLUGIN_OPTION_BLOCK_DANGEROUS_GIT_ALLOW=ps-unparsable-dynamic-invocation,reset-hard +# --- a quoted git literal in COMPARISON-OPERAND position is data -------------- +# ps::might_invoke_git exempts a quoted literal whose nearest preceding token is a +# comparison operator, but only in a command that carries no way to execute a +# computed value. Read-only pipelines that merely NAME git now pass the sink; a +# command that could turn the compared string back into a command word does not. +run_pwsh "PS cmp: -eq 'git' in a script block is data (allowed)" \ + "Get-Process | Where-Object { \$_.Name -eq 'git' }" 0 +run_pwsh "PS cmp: -in @('git.exe','bash.exe') is data (allowed)" \ + "Get-CimInstance Win32_Process | Where-Object { \$_.Name -in @('git.exe','bash.exe') } | Select-Object ProcessId" 0 +run_pwsh "PS cmp: case-prefixed -ceq 'git' is data (allowed)" \ + "Get-Process | Where-Object { \$_.Name -ceq 'git' } | Select-Object Id" 0 +run_pwsh "PS cmp: -notin list element is data (allowed)" \ + "Get-Process | ? { \$_.Name -notin @('git','node') }" 0 +run_pwsh "PS cmp: -like 'git*' is data (allowed)" \ + "Get-Process | ? { \$_.Name -like 'git*' }" 0 +run_pwsh "PS cmp: -match \"^git\\.exe\$\" is data (allowed)" \ + "Get-Process | ? { \$_.Name -match \"^git\\.exe\$\" } | Select-Object Id" 0 +# Rows the class already allowed — the exemption must not disturb them. +run_pwsh "PS cmp: no git token at all (allowed)" \ + "Get-ChildItem | Where-Object { \$_.Length -gt 0 }" 0 +run_pwsh "PS cmp: Get-Process git as an argument (allowed)" \ + "Get-Process git | Select-Object Id" 0 +run_pwsh "PS cmp: GitHub path component is not a git command (allowed)" \ + "Get-ChildItem C:\\Dev\\GitHub | Where-Object { \$_.PSIsContainer }" 0 + +# Counterexamples: every one keeps the quote-intact probe and stays blocked. +run_pwsh "PS cmp: bare git in call position beside a comparison (blocked)" \ + "git status; Get-Process | Where-Object { \$_.Name -eq 'node' }" 2 +run_pwsh "PS cmp: Start-Process 'git' is a call target, not an operand (blocked)" \ + "Start-Process 'git' reset --hard; Get-Process | Where-Object { \$_.Name -eq 'x' }" 2 +run_pwsh "PS cmp: saps 'git' is a call target, not an operand (blocked)" \ + "saps 'git' -ArgumentList 'push -f' | % { \$_ }" 2 +run_pwsh "PS cmp: cmd /c 'git push --force' is a nested shell (blocked)" \ + "cmd /c 'git push --force' ; Get-Process | ? { \$_.Name -eq 'node' }" 2 +run_pwsh "PS cmp: computed call of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { & \$_.Name push -f }" 2 +run_pwsh "PS cmp: dot-source of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { . \$_.Name push -f }" 2 +run_pwsh "PS cmp: iex of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { iex \$_.Name }" 2 +run_pwsh "PS cmp: assignment is not a comparison operator (blocked)" \ + "\$n = 'git'; & \$n push -f | % { \$_ }" 2 +run_pwsh "PS cmp: pipeline input is not an operand (blocked)" \ + "'git' | % { & \$_ push -f }" 2 +run_pwsh "PS cmp: cmd /c of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { cmd /c \$_.Name push -f }" 2 +run_pwsh "PS cmp: bash -c of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { bash -c \$_.Name }" 2 +run_pwsh "PS cmp: quoted launcher calling the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { & 'bash' -c \$_.Name }" 2 +run_pwsh "PS cmp: path-shaped call of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { & .\\\$_.Name push -f }" 2 +run_pwsh "PS cmp: Invoke-Command around the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { Invoke-Command -ScriptBlock { & \$_.Name } }" 2 +run_pwsh "PS cmp: call of a quoted git literal (blocked)" \ + "& 'git' commit --no-verify | % { \$_ }" 2 +run_pwsh "PS cmp: quoted subcommand after a bare git (blocked)" \ + "git 'commit' | % { \$_ }" 2 +# Right-hand operands only: a literal on the LEFT of the operator is ambiguous +# with a call target (`& 'git' -eq $x` invokes git), so it keeps the probe. +run_pwsh "PS cmp: left-hand literal keeps the quote-intact probe (blocked)" \ + "'git' -in \$names | % { \$_ }" 2 +# The list walk is bounded; a list long enough to exhaust it fails closed. +run_pwsh "PS cmp: over-long operand list fails closed (blocked)" \ + "Get-Process | ? { \$_.Name -in @('a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','git') }" 2 +# The sink message is the unchanged one — the exemption narrows what reaches the +# sink, it does not soften what the sink says. +cmp_out="$(pwsh_stderr "Get-Process | ? { \$_.Name -eq 'git' } | % { & \$_.Name push -f }")" +assert_contains "PS cmp: blocked counterexample still names could-reach-git" \ + "$cmp_out" "could reach git" + +# Predicate pins — a hook rc of 0 can hide "entered the sink and was waved +# through by something else", so the mechanism itself is asserted. +pin_predicate "ps::might_invoke_git: -eq 'git' is data" \ + ps::might_invoke_git "Get-Process | Where-Object { \$_.Name -eq 'git' }" 1 +pin_predicate "ps::might_invoke_git: -in @('git.exe','bash.exe') is data" \ + ps::might_invoke_git "Get-CimInstance Win32_Process | Where-Object { \$_.Name -in @('git.exe','bash.exe') } | Select-Object ProcessId" 1 +pin_predicate "ps::might_invoke_git: -notin list element is data" \ + ps::might_invoke_git "Get-Process | ? { \$_.Name -notin @('git','node') }" 1 +pin_predicate "ps::might_invoke_git: -match \"^git\\.exe\$\" is data" \ + ps::might_invoke_git "Get-Process | ? { \$_.Name -match \"^git\\.exe\$\" } | Select-Object Id" 1 +pin_predicate "ps::might_invoke_git: computed call of the compared value still blocks" \ + ps::might_invoke_git "Get-Process | ? { \$_.Name -eq 'git' } | % { & \$_.Name push -f }" 0 +pin_predicate "ps::might_invoke_git: bash -c of the compared value still blocks" \ + ps::might_invoke_git "Get-Process | ? { \$_.Name -eq 'git' } | % { bash -c \$_.Name }" 0 +pin_predicate "ps::might_invoke_git: call of a quoted git literal still blocks" \ + ps::might_invoke_git "& 'git' commit --no-verify | % { \$_ }" 0 +pin_predicate "ps::_can_execute_computed_value: a read-only comparison pipeline has no invocation shape" \ + ps::_can_execute_computed_value "Get-Process | Where-Object { \$_.Name -eq 'git' }" 1 +pin_predicate "ps::_can_execute_computed_value: cmd as a bare command word disqualifies" \ + ps::_can_execute_computed_value "Get-Process | ? { \$_.Name -eq 'git' } | % { cmd /c \$_.Name }" 0 +pin_predicate "ps::_can_execute_computed_value: a quoted launcher name is data, not an invocation" \ + ps::_can_execute_computed_value "Get-CimInstance Win32_Process | ? { \$_.Name -in @('git.exe','bash.exe') }" 1 +pin_sink_trigger "classify: the comparison pipeline still enters the special-construct sink" \ + "Get-Process | Where-Object { \$_.Name -eq 'git' }" "special-construct" + malformed_rc=0 (cd "$REPO_SHA1" && bash "$HOOK" <<<'not json at all' >/dev/null 2>&1) || malformed_rc=$? assert_exit "malformed JSON payload (blocked)" 2 "$malformed_rc" diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 59ec0a0f04..ea64706f37 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -47,8 +47,10 @@ # (backtick splits `com`+`mit`, quote-stripping erases `'commit'`), so a # negative match is not evidence of safety (the #740/#903 fail-open class). # Instead ps::might_invoke_git asks the mangle-resistant question "could this -# reach git at all?" — backticks recovered, scan quote-INTACT, plus dynamic -# invocation (iex / call / dot-source) — and blocks unless the answer is no. +# reach git at all?" — backticks recovered, scan quote-INTACT (except a +# literal in comparison-operand position, which is data; see +# ps::might_invoke_git), plus dynamic invocation (iex / call / dot-source) — +# and blocks unless the answer is no. # Otherwise the reduced command is Bash-tokenizer-faithful and handed to the # existing parser. # @@ -338,11 +340,14 @@ ps::blank_herestrings() { PS_BLANKED="${out%$'\n'}" } -# The single left-to-right quoted-span walk behind ps::blank_quoted_spans_to and -# ps::opaque_quoted_spans_to. MODE is `blank` (a found span is deleted) or `opaque` -# (a found span becomes a classified placeholder, per the classification the -# opaque wrapper documents). WHERE a span starts and ends, and every ambiguity -# resolution, is identical for both and therefore stated once here: two +# The single left-to-right quoted-span walk behind ps::blank_quoted_spans_to, +# ps::opaque_quoted_spans_to and ps::_blank_comparison_operand_literals_to. MODE is +# `blank` (a found span is deleted), `opaque` (a found span becomes a classified +# placeholder, per the classification the opaque wrapper documents) or +# `cmpoperand` (a found span in comparison-operand position becomes the inert +# bareword `_q_` and every other span is emitted verbatim). WHERE a span starts +# and ends, and every ambiguity resolution, is identical for all three and +# therefore stated once here: two # hand-maintained copies of this pairing walk are exactly the drift this file's # other SSOT notes warn about, and a copy that silently stopped pairing the same # way would fail OPEN in one lane while the other stayed closed. @@ -447,6 +452,16 @@ ps::_walk_quoted_spans_to() { out+='_q_' fi fi + elif [[ "$mode" == "cmpoperand" ]]; then + # `out` is both the result and the CONTEXT: every span already walked + # is present in it (as `_q_` when blanked, verbatim when kept), so the + # operand test reads the reduced prefix rather than the raw text — which + # is what lets an earlier list element be skipped as one token. + if ps::_is_comparison_operand_context "$out"; then + out+='_q_' + else + out+="${text:i:j-i+1}" + fi fi i=$((j + 1)) continue @@ -471,6 +486,85 @@ ps::blank_quoted_spans_to() { ps::_walk_quoted_spans_to "$1" "$2" blank } +# True (0) when PREFIX — the already-walked text standing in front of a quoted +# string literal — puts that literal in COMPARISON-OPERAND position: the nearest +# preceding non-whitespace token is a PowerShell comparison operator, reached +# through an optional opening `(` / `@(` and through `,`-separated earlier +# elements of that same list. The `c`/`i` case prefixes count (`-ceq`, `-ilike`). +# +# RIGHT-HAND OPERANDS ONLY. A literal on the LEFT of the operator is not decidable +# here: `& 'git' -eq $x` is a CALL of git with `-eq` as its first argument, not a +# comparison, and nothing in the prefix distinguishes the two. So `'git' -in $names` +# and `@('git') -contains $_.Name` keep the quote-intact probe. +# +# The operator's left boundary excludes alphanumerics, `_` and `-`, so a cmdlet or +# parameter name that merely ENDS in an operator spelling is not one. +# +# The hop count is BOUNDED. A list long enough to exhaust it falls through to +# "not an operand", which keeps the quote-intact probe — the over-block +# direction, matching the file's invariant. +ps::_is_comparison_operand_context() { + local p="$1" hops + for ((hops = 0; hops < 16; hops++)); do + if [[ "$p" =~ ^(.*[^[:space:]])[[:space:]]*$ ]]; then p="${BASH_REMATCH[1]}"; else p=""; fi + [[ "$p" =~ (^|[^[:alnum:]_-])-[ci]?(eq|ne|in|notin|contains|notcontains|like|notlike|match|notmatch|lt|le|gt|ge)$ ]] && return 0 + case "$p" in + *'(') + p="${p%?}" + if [[ "$p" =~ ^(.*[^[:space:]])[[:space:]]*$ ]]; then p="${BASH_REMATCH[1]}"; else p=""; fi + [[ "$p" == *'@' ]] && p="${p%?}" + ;; + *',') + p="${p%?}" + # Drop the earlier list element whole — back to the nearest list/group + # delimiter or whitespace. An element already walked as a quoted span is + # the bareword `_q_` here, so it needs no special case. + if [[ "$p" =~ ^(.*[,()[:space:]])[^,()[:space:]]*$ ]]; then p="${BASH_REMATCH[1]}"; else p=""; fi + ;; + *) return 1 ;; + esac + done + return 1 +} + +# Replace every quoted string literal that ps::_is_comparison_operand_context +# accepts with the inert bareword `_q_`, leaving every other span verbatim. +# The one consumer is ps::might_invoke_git's data-literal exemption; the rule and +# why it cannot reach execution are documented there. +ps::_blank_comparison_operand_literals_to() { + ps::_walk_quoted_spans_to "$1" "$2" cmpoperand +} + +# True (0) when the text carries an invocation shape that could execute a value +# the command COMPUTED — a call or dot-source whose target is not a plain bare +# word (`& $_.Name`, `. $x`, `& ('g'+'it')`, `& "$tool"`, `& 'bash'`, +# `& .\$_.Name`), an expression evaluator (`iex`/`Invoke-Expression`, +# `Invoke-Command`/`icm`), or a launcher / nested shell sitting at a command +# position as a bare word (`cmd /c $_.Name`, `bash -c $_.Name`). +# +# This is ps::might_invoke_git's disqualifier, so every arm here is in the +# OVER-BLOCK direction: a match only ever restores the quote-intact probe. +# +# The launcher words extend ps::has_launcher's list with the interpreters that +# take a command string (`bash`, `sh`, `wsl`, `node`, `python`), and unlike the +# computed-launcher probe at the end of ps::might_invoke_git they need no +# `(`/`$` operand — `cmd /c $_.Name` reaches git through an argument the +# operand-shaped probe never sees. A QUOTE is not in the predecessor class, so a +# launcher name that is itself comparison DATA (`-in @('git.exe','bash.exe')`) +# does not disqualify; a call of that same quoted name (`& 'bash' -c …`) does, +# through the call-target arm above it. +ps::_can_execute_computed_value() { + local lc="${1//\`/}" + lc="${lc,,}" + # The target class is stated as a NEGATION — anything that is not a bare-word + # character — so a call spelled around a quote, a variable, a subexpression or + # a path (`& 'bash'`, `& $x`, `& (…)`, `& .\$_.Name`) is one arm, not four. + [[ "$lc" =~ (^|[[:space:]\;\{\}\(\|\&=])[.\&][[:space:]]*[^[:space:][:alnum:]_-] ]] && return 0 + [[ "$lc" =~ (^|[^[:alnum:]_-])(iex|invoke-expression|invoke-command|icm)([^[:alnum:]_-]|$) ]] && return 0 + [[ "$lc" =~ (^|[[:space:]\;\|\&\(\{\}=])(start-process|saps|start|pwsh|powershell|cmd|bash|sh|wsl|node|python|python3)(\.exe)?([^[:alnum:]_.-]|$) ]] && return 0 + return 1 +} + # Sibling of ps::blank_quoted_spans_to for ONE consumer: # ps::computed_call_has_positional_write_signal. Same left-to-right pairing — # first opener owns its span, ambiguity copies the rest of the line verbatim — @@ -991,13 +1085,58 @@ ps::call_target_is_interpolating_string() { # trailing boundary excludes a further `/` or `\` so `git` must be the final path # component, not a directory name. `.git` stays inert because `.` is not a # command-position predecessor. +# +# ONE EXEMPTION, AND IT IS NARROW: a quoted string literal in COMPARISON-OPERAND +# position is DATA, and is blanked to `_q_` before the probe re-runs — but only in +# a command that carries no way to execute a computed value at all. Both halves +# are required, and the second is what makes the first safe. +# (a) the literal's nearest preceding non-whitespace token is a PowerShell +# comparison operator, reached through an optional `(` / `@(` and through +# `,`-separated earlier elements of the same list +# (ps::_is_comparison_operand_context; right-hand operands only); +# (b) the whole command carries no call or dot-source of a non-bare-word target, +# no expression evaluator, and no launcher / nested shell as a bare command +# word (ps::_can_execute_computed_value). +# Under (b) the only thing the matched text can DO with the string is compare it: +# `Get-Process | Where-Object { $_.Name -eq 'git' }` filters objects, and no +# operator in what remains turns the filtered value back into a command word. The +# false-positive class this retires is read-only PowerShell that merely NAMES git +# — `-eq 'git'`, `-in @('git.exe','bash.exe')`, `-like 'git*'`, `-match "^git\.exe$"` +# — which the script block alone had already routed to the sink. +# +# WHAT KEEPS THE QUOTE-INTACT PROBE, and why each one must: +# `& 'git' commit`, `git 'commit'` the literal is a call target or an +# argument, never a comparison operand; +# `Start-Process 'git' …`, `saps 'git'` likewise, and (b) fails on the launcher; +# `cmd /c 'git push --force'` (b) fails on the nested shell; +# `… -eq 'git' … | % { & $_.Name … }` (b) fails: the call target is computed, +# `… -eq 'git' … | % { . $_.Name … }` so the compared string becomes the +# `… -eq 'git' … | % { iex $_.Name }` command word after all; +# `… -eq 'git' … | % { cmd /c $_.Name }` (b) fails on the launcher word, which +# `… -eq 'git' … | % { bash -c $_.Name }` reaches git through an ARGUMENT the +# computed-launcher probe below never sees; +# `$n = 'git'; & $n push -f` `=` is not a comparison operator, and (b) +# fails on the variable call target; +# `'git' | % { & $_ push -f }` the literal is pipeline input, not an +# operand, and (b) fails. ps::might_invoke_git() { - local recovered="${1//\`/}" lc + local recovered="${1//\`/}" lc narrowed lc="${recovered,,}" # Predecessor class includes `:` so a drive-relative `& 'C:git.exe'` still # counts (Codex #2592 review) and `=` so `$x=git …` (no space) still counts # (Claude #2592 review), while `.git` stays inert (`.` is not listed). - [[ "$lc" =~ (^|[[:space:]\;\|\&\(\{\}\"\'/\\:=])git([.]exe)?([^[:alnum:]_/\\]|$) ]] && return 0 + if [[ "$lc" =~ (^|[[:space:]\;\|\&\(\{\}\"\'/\\:=])git([.]exe)?([^[:alnum:]_/\\]|$) ]]; then + # A git token is visible. It is exempt only as comparison DATA in a command + # that cannot execute a computed value — the walk runs only here, so a + # command with no git token pays nothing for it. + ps::_can_execute_computed_value "$recovered" && return 0 + ps::_blank_comparison_operand_literals_to narrowed "$lc" + # The re-probe is the SAME pattern spelled again, not shared through a + # variable — the fail-OPEN reason the sibling predicates record: a pattern + # assembled from a variable that silently stopped matching would wave a git + # command word through. + [[ "$narrowed" =~ (^|[[:space:]\;\|\&\(\{\}\"\'/\\:=])git([.]exe)?([^[:alnum:]_/\\]|$) ]] && return 0 + fi [[ "$lc" =~ (^|[^[:alnum:]_-])(iex|invoke-expression)([^[:alnum:]_-]|$) ]] && return 0 # Call / dot-source of a COMPUTED target — `& (…)`, `& "$x" …` — which could # resolve to git. A CONSTANT target (`& 'git' …`, `& "C:\Git\cmd\git.exe" …`) From b77feafd1aff87b5c7cf5655be5f6547fad321ef Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:35:00 -0400 Subject: [PATCH 04/15] fix(guardrails): treat item and job launchers as executors in the PowerShell fail-close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ps::_can_execute_computed_value's bare-command-word list covered the shells and interpreters but not the cmdlets that run a program without a call operator, an evaluator or a shell word. A comparison-operand exemption could therefore stand beside `Invoke-Item $_.Name`, which turns the compared string back into a command word. Added as bare words, same boundary class as the existing launchers: invoke-item, ii, start-job, sajb, register-scheduledtask, new-service, invoke-wmimethod, invoke-cimmethod, new-object. This widens the DISQUALIFIER, never the exemption: every addition can only restore the quote-intact probe. New counterexample tests keep `… -eq 'git' … | % { Invoke-Item $_.Name }`, its `ii` spelling, a `Start-Job` form, and `New-Object System.Diagnostics.Process` beside a comparison blocked. block-dangerous-git 536 PASS / 0 FAIL, block-no-verify 256 / 0, block-hook-bypass 645 / 2 (pre-existing symlink pair). The designed-case differential still flips exactly the same 5 commands (cases=29 mismatches=5) and the perf baseline's 17-command corpus stays identical across both tool modes (cases=34 mismatches=0). Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/CHANGELOG.md | 2 +- .../guardrails/hooks/block-dangerous-git.test.sh | 15 +++++++++++++++ plugins/guardrails/lib/powershell/ps-command.sh | 10 ++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index dfeecad761..ce1b49ee5d 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to the `guardrails` plugin are documented here. Format follo ### Changed - lib/powershell/ps-command.sh spawns nothing. Every `$(ps::…)` capture is now an out-parameter helper that assigns with `printf -v` — `ps::blank_quoted_spans_to`, `ps::opaque_quoted_spans_to`, `ps::call_site_operand_region_to`, `ps::blank_bracket_interiors_to`, `ps::fold_escaped_brace_closers_to` and the three `ps::_skip_*_to` index walkers, the `_to` convention hook-utils.sh already uses — and every `printf | sed` pipeline and `< <(printf …)` line reader is a pure-bash substitution or split (`ps::_gsub_to`, which applies an ERE per line exactly as sed hands its regex one line at a time, and `ps::_split_lines_to`). On Windows Git Bash the PowerShell lane's PreToolUse chain went from 80 process creations to 3 — the harness's `bash -c`, the `env` of the shebang, and bash itself — and 2422 ms to 300 ms isolated p50; a PowerShell command that carries a script block and a git token, so it reaches the fail-closed sink, from 44 creations and 1433 ms to 3 and 299 ms. The Bash lane, which never loads this library, stays at 3 creations and 288 ms to 274 ms. Every deny and allow is byte-identical (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over 653 commands harvested from the guard suites on each lane. Two host behaviors the captured forms carried are reproduced rather than dropped, because a PowerShell command arrives with Windows line endings and PS_SAFE_COMMAND goes on to a Bash tokenizer: `$(…)` here eats a trailing CRLF whole, not just its LF, and this host's `sed` reads in text mode, so a CRLF line ending loses its CR. -- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command carries no invocation shape that could execute a computed value — no call or dot-source of a non-bare-word target (`& $_.Name`, `. $x`, `& ('g'+'it')`, `& "$tool"`, `& 'bash'`, `& .\$_.Name`), no `iex` / `Invoke-Expression` / `Invoke-Command` / `icm`, and no launcher or nested shell as a bare command word (`Start-Process`, `saps`, `start`, `pwsh`, `powershell`, `cmd`, `bash`, `sh`, `wsl`, `node`, `python`). Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, and every `… -eq 'git' … | % { $_.Name }` form (`&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, and a quoted `& 'bash' -c`). Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted — no command in it carries a git literal in operand position) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes; a designed-case corpus of the exempt and counterexample shapes flips exactly 5 commands, every one BLOCKED→ALLOWED with the sink message gone, and leaves its other 18 blocked byte-for-byte. +- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command carries no invocation shape that could execute a computed value — no call or dot-source of a non-bare-word target (`& $_.Name`, `. $x`, `& ('g'+'it')`, `& "$tool"`, `& 'bash'`, `& .\$_.Name`), no `iex` / `Invoke-Expression` / `Invoke-Command` / `icm`, and no launcher, nested shell or program-running cmdlet as a bare command word (`Start-Process`, `saps`, `start`, `pwsh`, `powershell`, `cmd`, `bash`, `sh`, `wsl`, `node`, `python`, `Invoke-Item`, `ii`, `Start-Job`, `sajb`, `Register-ScheduledTask`, `New-Service`, `Invoke-WmiMethod`, `Invoke-CimMethod`, `New-Object`). Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, and every `… -eq 'git' … | % { $_.Name }` form (`&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, `Invoke-Item`, `ii`, `Start-Job`, and a quoted `& 'bash' -c`). Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted — no command in it carries a git literal in operand position) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes; a designed-case corpus of the exempt and counterexample shapes flips exactly 5 commands, every one BLOCKED→ALLOWED with the sink message gone, and leaves its other 18 blocked byte-for-byte. ## [0.34.0] diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 3d19c18017..c59fa46d79 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -1250,6 +1250,17 @@ run_pwsh "PS cmp: path-shaped call of the compared value (blocked)" \ "Get-Process | ? { \$_.Name -eq 'git' } | % { & .\\\$_.Name push -f }" 2 run_pwsh "PS cmp: Invoke-Command around the compared value (blocked)" \ "Get-Process | ? { \$_.Name -eq 'git' } | % { Invoke-Command -ScriptBlock { & \$_.Name } }" 2 +# Cmdlets that run a program with no call operator, no evaluator and no shell +# word are executors too: the compared string becomes a command word through +# them just as readily. +run_pwsh "PS cmp: Invoke-Item of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { Invoke-Item \$_.Name }" 2 +run_pwsh "PS cmp: the ii alias of Invoke-Item (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { ii \$_.Name }" 2 +run_pwsh "PS cmp: Start-Job around the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { Start-Job { \$_.Name } }" 2 +run_pwsh "PS cmp: New-Object process construction beside a comparison (blocked)" \ + "New-Object System.Diagnostics.Process; Get-Process | ? { \$_.Name -eq 'git' }" 2 run_pwsh "PS cmp: call of a quoted git literal (blocked)" \ "& 'git' commit --no-verify | % { \$_ }" 2 run_pwsh "PS cmp: quoted subcommand after a bare git (blocked)" \ @@ -1287,6 +1298,10 @@ pin_predicate "ps::_can_execute_computed_value: a read-only comparison pipeline ps::_can_execute_computed_value "Get-Process | Where-Object { \$_.Name -eq 'git' }" 1 pin_predicate "ps::_can_execute_computed_value: cmd as a bare command word disqualifies" \ ps::_can_execute_computed_value "Get-Process | ? { \$_.Name -eq 'git' } | % { cmd /c \$_.Name }" 0 +pin_predicate "ps::_can_execute_computed_value: Invoke-Item is an executor" \ + ps::_can_execute_computed_value "Get-Process | ? { \$_.Name -eq 'git' } | % { Invoke-Item \$_.Name }" 0 +pin_predicate "ps::_can_execute_computed_value: New-Object is an executor" \ + ps::_can_execute_computed_value "New-Object System.Diagnostics.Process; Get-Process | ? { \$_.Name -eq 'git' }" 0 pin_predicate "ps::_can_execute_computed_value: a quoted launcher name is data, not an invocation" \ ps::_can_execute_computed_value "Get-CimInstance Win32_Process | ? { \$_.Name -in @('git.exe','bash.exe') }" 1 pin_sink_trigger "classify: the comparison pipeline still enters the special-construct sink" \ diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index ea64706f37..a62203e621 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -546,7 +546,13 @@ ps::_blank_comparison_operand_literals_to() { # OVER-BLOCK direction: a match only ever restores the quote-intact probe. # # The launcher words extend ps::has_launcher's list with the interpreters that -# take a command string (`bash`, `sh`, `wsl`, `node`, `python`), and unlike the +# take a command string (`bash`, `sh`, `wsl`, `node`, `python`) and with the +# cmdlets that run a program WITHOUT a call operator — `Invoke-Item`/`ii` opens a +# path, `Start-Job`/`sajb` runs a script block elsewhere, `Register-ScheduledTask` +# and `New-Service` install a command line, `Invoke-WmiMethod`/`Invoke-CimMethod` +# reach Win32_Process Create, and `New-Object` constructs a Process. Each would +# otherwise turn the compared string back into a command word with none of the +# shapes above present. Unlike the # computed-launcher probe at the end of ps::might_invoke_git they need no # `(`/`$` operand — `cmd /c $_.Name` reaches git through an argument the # operand-shaped probe never sees. A QUOTE is not in the predecessor class, so a @@ -561,7 +567,7 @@ ps::_can_execute_computed_value() { # a path (`& 'bash'`, `& $x`, `& (…)`, `& .\$_.Name`) is one arm, not four. [[ "$lc" =~ (^|[[:space:]\;\{\}\(\|\&=])[.\&][[:space:]]*[^[:space:][:alnum:]_-] ]] && return 0 [[ "$lc" =~ (^|[^[:alnum:]_-])(iex|invoke-expression|invoke-command|icm)([^[:alnum:]_-]|$) ]] && return 0 - [[ "$lc" =~ (^|[[:space:]\;\|\&\(\{\}=])(start-process|saps|start|pwsh|powershell|cmd|bash|sh|wsl|node|python|python3)(\.exe)?([^[:alnum:]_.-]|$) ]] && return 0 + [[ "$lc" =~ (^|[[:space:]\;\|\&\(\{\}=])(start-process|saps|start|pwsh|powershell|cmd|bash|sh|wsl|node|python|python3|invoke-item|ii|start-job|sajb|register-scheduledtask|new-service|invoke-wmimethod|invoke-cimmethod|new-object)(\.exe)?([^[:alnum:]_.-]|$) ]] && return 0 return 1 } From 241e256759689760e1a2fa22dc8a0a96b5946eac Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:19:13 -0400 Subject: [PATCH 05/15] fix(guardrails): exempt a compared git literal only inside a read-only cmdlet pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comparison-operand exemption was gated by a list of known executors. A security review reproduced BLOCKED->ALLOWED flips through executors no list enumerates: [Diagnostics.Process]::Start, $ExecutionContext.InvokeCommand. InvokeScript, [scriptblock]::Create(...).Invoke(), a run-time Set-Alias, WMI/CIM Win32_Process Create, New-Service, schtasks, New-ScheduledTaskAction, wmic, and every launcher on PATH (npx, dotnet, cscript, explorer, ssh). ps::_can_execute_computed_value is replaced by ps::_is_readonly_cmdlet_pipeline, the inverse: the exemption applies only when the command is PROVABLY a read-only cmdlet pipeline. With every quoted string replaced by an opaque placeholder, the command is refused outright on a surviving backtick, `<#`, `--%`, `::`, `[`, `&` or a `.` before `(`; the remainder is walked token by token, and every token at a command position (start of input, or after | ; { } ( = newline) must be an allowlisted interrogator — any Get-* verb, the read-only aliases, Where-Object, Select-Object, ForEach-Object, Sort/Measure/Group-Object, Format-*, Out-*, Write-Output/Write-Host, Select-String, the path helpers, Compare-Object, the ConvertTo/ConvertFrom pair, and the if/else/elseif/in/return keywords. Variable chains, -parameters, string placeholders, numbers and ARGUMENTS are inert; any other command word refuses. An over-long command is refused rather than scanned. An unrecognized command word now costs an over-block, never a bypass. The exemption itself is unchanged: right-hand comparison operand, bounded list walk. All fourteen reviewed executor shapes are counterexample tests, plus the Invoke-Item / ii / Start-Job / New-Object set, and seven predicate pins assert the gate directly. block-dangerous-git 552 PASS / 0 FAIL, block-no-verify 256 / 0, block-hook-bypass 645 / 2 (pre-existing symlink pair). Two-root differentials against the pre-narrowing tree: 653-command harvested corpus, PowerShell lane cases=653 mismatches=0; perf baseline's 17-command corpus cases=34 mismatches=0 across both tool modes; designed cases plus the executor shapes cases=47 mismatches=5 — the same five BLOCKED->ALLOWED flips, with all 41 blocked rows identical on both roots. The chain still creates 3 processes on a PowerShell call that reaches the sink. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/CHANGELOG.md | 2 +- .../hooks/block-dangerous-git.test.sh | 63 ++++-- .../guardrails/lib/powershell/ps-command.sh | 206 +++++++++++++----- 3 files changed, 200 insertions(+), 71 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index ce1b49ee5d..47b956ac3c 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to the `guardrails` plugin are documented here. Format follo ### Changed - lib/powershell/ps-command.sh spawns nothing. Every `$(ps::…)` capture is now an out-parameter helper that assigns with `printf -v` — `ps::blank_quoted_spans_to`, `ps::opaque_quoted_spans_to`, `ps::call_site_operand_region_to`, `ps::blank_bracket_interiors_to`, `ps::fold_escaped_brace_closers_to` and the three `ps::_skip_*_to` index walkers, the `_to` convention hook-utils.sh already uses — and every `printf | sed` pipeline and `< <(printf …)` line reader is a pure-bash substitution or split (`ps::_gsub_to`, which applies an ERE per line exactly as sed hands its regex one line at a time, and `ps::_split_lines_to`). On Windows Git Bash the PowerShell lane's PreToolUse chain went from 80 process creations to 3 — the harness's `bash -c`, the `env` of the shebang, and bash itself — and 2422 ms to 300 ms isolated p50; a PowerShell command that carries a script block and a git token, so it reaches the fail-closed sink, from 44 creations and 1433 ms to 3 and 299 ms. The Bash lane, which never loads this library, stays at 3 creations and 288 ms to 274 ms. Every deny and allow is byte-identical (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over 653 commands harvested from the guard suites on each lane. Two host behaviors the captured forms carried are reproduced rather than dropped, because a PowerShell command arrives with Windows line endings and PS_SAFE_COMMAND goes on to a Bash tokenizer: `$(…)` here eats a trailing CRLF whole, not just its LF, and this host's `sed` reads in text mode, so a CRLF line ending loses its CR. -- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command carries no invocation shape that could execute a computed value — no call or dot-source of a non-bare-word target (`& $_.Name`, `. $x`, `& ('g'+'it')`, `& "$tool"`, `& 'bash'`, `& .\$_.Name`), no `iex` / `Invoke-Expression` / `Invoke-Command` / `icm`, and no launcher, nested shell or program-running cmdlet as a bare command word (`Start-Process`, `saps`, `start`, `pwsh`, `powershell`, `cmd`, `bash`, `sh`, `wsl`, `node`, `python`, `Invoke-Item`, `ii`, `Start-Job`, `sajb`, `Register-ScheduledTask`, `New-Service`, `Invoke-WmiMethod`, `Invoke-CimMethod`, `New-Object`). Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, and every `… -eq 'git' … | % { $_.Name }` form (`&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, `Invoke-Item`, `ii`, `Start-Job`, and a quoted `& 'bash' -c`). Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted — no command in it carries a git literal in operand position) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes; a designed-case corpus of the exempt and counterexample shapes flips exactly 5 commands, every one BLOCKED→ALLOWED with the sink message gone, and leaves its other 18 blocked byte-for-byte. +- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command is provably a READ-ONLY CMDLET PIPELINE. That second half is an ALLOWLIST, not a list of known executors: with every quoted string replaced by an opaque placeholder, the command is refused outright on a surviving backtick, `<#`, `--%`, `::`, `[`, `&` or a `.` before `(`, and is then walked token by token so that every token standing at a command position (the start of input, or after `|`, `;`, `{`, `}`, `(`, `=` or a newline) is an allowlisted interrogator — any `Get-*` verb, `gps`/`ps`/`gcim`/`gwmi`/`gci`/`ls`/`dir`/`gi`/`gc`/`cat`/`type`/`gsv`/`gcm`/`gmo`/`gv`/`gl`/`pwd`, `Where-Object`/`?`, `Select-Object`, `ForEach-Object`/`%`, `Sort-Object`, `Measure-Object`, `Group-Object`, the `Format-*` and `Out-*` set, `Write-Output`/`Write-Host`/`echo`, `Select-String`/`sls`, the path helpers, `Compare-Object`/`diff`, the `ConvertTo-*`/`ConvertFrom-*` pair, and the `if`/`else`/`elseif`/`in`/`return` keywords. `$variable` chains, `-parameter` tokens, string placeholders, numbers and ARGUMENTS (`Get-CimInstance Win32_Process`, `Select-Object ProcessId`) are inert; any other command word refuses. Inverting the test is what makes it sound — a blocklist of executors is structurally under-inclusive, and an unrecognized command word now costs an over-block instead of a bypass. Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, a command past the scan's length ceiling, and every `… -eq 'git' … | % { $_.Name }` form — `&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, `npx`, `dotnet`, `cscript`, `explorer`, `ssh`, `wmic process call create`, `schtasks /tr`, `Invoke-Item`/`ii`, `Start-Job`, `New-Object`, `New-Service`/`nsv`, `New-ScheduledTaskAction`, `iwmi -Class Win32_Process -Name Create`, `Set-Alias zz $_.Name; zz push --force`, `[Diagnostics.Process]::Start($_.Name, …)`, `[scriptblock]::Create($_.Name).Invoke()` and `$ExecutionContext.InvokeCommand.InvokeScript($_.Name)`, none of them named by the rule and all of them refused by it. Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted — no command in it carries a git literal in operand position) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes; a designed-case corpus of the exempt and counterexample shapes flips exactly 5 commands, every one BLOCKED→ALLOWED with the sink message gone, and leaves its other 18 blocked byte-for-byte. ## [0.34.0] diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index c59fa46d79..c1eafc0d40 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -1250,9 +1250,42 @@ run_pwsh "PS cmp: path-shaped call of the compared value (blocked)" \ "Get-Process | ? { \$_.Name -eq 'git' } | % { & .\\\$_.Name push -f }" 2 run_pwsh "PS cmp: Invoke-Command around the compared value (blocked)" \ "Get-Process | ? { \$_.Name -eq 'git' } | % { Invoke-Command -ScriptBlock { & \$_.Name } }" 2 -# Cmdlets that run a program with no call operator, no evaluator and no shell -# word are executors too: the compared string becomes a command word through -# them just as readily. +# Executors an enumeration cannot converge on. Each of these reaches a program +# without a call operator, an evaluator or a shell word, and each is refused by +# the read-only-cmdlet allowlist rather than by being named — a .NET static +# member, the automatic InvokeCommand API, a run-time alias, a script block +# compiled from the value, WMI/CIM process creation, a service binary path, a +# scheduled-task action, and any launcher that happens to be on PATH. +run_pwsh "PS cmp: [Diagnostics.Process]::Start of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { [Diagnostics.Process]::Start(\$_.Name,'push --force') }" 2 +run_pwsh "PS cmp: InvokeCommand.InvokeScript of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { \$ExecutionContext.InvokeCommand.InvokeScript(\$_.Name) }" 2 +run_pwsh "PS cmp: an alias minted from the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { Set-Alias zz \$_.Name }; zz push --force" 2 +run_pwsh "PS cmp: a script block compiled from the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { \$sb=[scriptblock]::Create(\$_.Name); \$sb.Invoke() }" 2 +run_pwsh "PS cmp: iwmi Win32_Process Create of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { iwmi -Class Win32_Process -Name Create -ArgumentList \$_.Name }" 2 +run_pwsh "PS cmp: a service binary path from the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { nsv -Name z -BinaryPathName \$_.Name }" 2 +run_pwsh "PS cmp: schtasks /tr of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { schtasks /create /tn z /sc once /st 00:00 /tr \$_.Name }" 2 +run_pwsh "PS cmp: wmic process call create of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { wmic process call create \$_.Name }" 2 +run_pwsh "PS cmp: a scheduled-task action from the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { New-ScheduledTaskAction -Execute \$_.Name }" 2 +run_pwsh "PS cmp: npx of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { npx \$_.Name }" 2 +run_pwsh "PS cmp: dotnet of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { dotnet \$_.Name }" 2 +run_pwsh "PS cmp: cscript of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { cscript \$_.Name }" 2 +run_pwsh "PS cmp: explorer of the compared value (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { explorer \$_.Name }" 2 +run_pwsh "PS cmp: ssh running the compared value on a remote host (blocked)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | % { ssh host \$_.Name push -f }" 2 +# The same refusal covers the cmdlets that run a program with no call operator, +# no evaluator and no shell word. run_pwsh "PS cmp: Invoke-Item of the compared value (blocked)" \ "Get-Process | ? { \$_.Name -eq 'git' } | % { Invoke-Item \$_.Name }" 2 run_pwsh "PS cmp: the ii alias of Invoke-Item (blocked)" \ @@ -1294,16 +1327,20 @@ pin_predicate "ps::might_invoke_git: bash -c of the compared value still blocks" ps::might_invoke_git "Get-Process | ? { \$_.Name -eq 'git' } | % { bash -c \$_.Name }" 0 pin_predicate "ps::might_invoke_git: call of a quoted git literal still blocks" \ ps::might_invoke_git "& 'git' commit --no-verify | % { \$_ }" 0 -pin_predicate "ps::_can_execute_computed_value: a read-only comparison pipeline has no invocation shape" \ - ps::_can_execute_computed_value "Get-Process | Where-Object { \$_.Name -eq 'git' }" 1 -pin_predicate "ps::_can_execute_computed_value: cmd as a bare command word disqualifies" \ - ps::_can_execute_computed_value "Get-Process | ? { \$_.Name -eq 'git' } | % { cmd /c \$_.Name }" 0 -pin_predicate "ps::_can_execute_computed_value: Invoke-Item is an executor" \ - ps::_can_execute_computed_value "Get-Process | ? { \$_.Name -eq 'git' } | % { Invoke-Item \$_.Name }" 0 -pin_predicate "ps::_can_execute_computed_value: New-Object is an executor" \ - ps::_can_execute_computed_value "New-Object System.Diagnostics.Process; Get-Process | ? { \$_.Name -eq 'git' }" 0 -pin_predicate "ps::_can_execute_computed_value: a quoted launcher name is data, not an invocation" \ - ps::_can_execute_computed_value "Get-CimInstance Win32_Process | ? { \$_.Name -in @('git.exe','bash.exe') }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a comparison pipeline of interrogators is read-only" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq 'git' }" 0 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: cmd at a command position refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | ? { \$_.Name -eq 'git' } | % { cmd /c \$_.Name }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: an unrecognized command word refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | ? { \$_.Name -eq 'git' } | % { npx \$_.Name }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a type literal refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | ? { \$_.Name -eq 'git' } | % { [Diagnostics.Process]::Start(\$_.Name) }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a method call refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | ? { \$_.Name -eq 'git' } | % { \$ExecutionContext.InvokeCommand.InvokeScript(\$_.Name) }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a quoted launcher name is an argument, not a command word" \ + ps::_is_readonly_cmdlet_pipeline "Get-CimInstance Win32_Process | ? { \$_.Name -in @('git.exe','bash.exe') }" 0 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a cmdlet argument is not a command word" \ + ps::_is_readonly_cmdlet_pipeline "Get-CimInstance Win32_Process | Select-Object ProcessId,Name" 0 pin_sink_trigger "classify: the comparison pipeline still enters the special-construct sink" \ "Get-Process | Where-Object { \$_.Name -eq 'git' }" "special-construct" diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index a62203e621..121c5ba432 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -535,44 +535,124 @@ ps::_blank_comparison_operand_literals_to() { ps::_walk_quoted_spans_to "$1" "$2" cmpoperand } -# True (0) when the text carries an invocation shape that could execute a value -# the command COMPUTED — a call or dot-source whose target is not a plain bare -# word (`& $_.Name`, `. $x`, `& ('g'+'it')`, `& "$tool"`, `& 'bash'`, -# `& .\$_.Name`), an expression evaluator (`iex`/`Invoke-Expression`, -# `Invoke-Command`/`icm`), or a launcher / nested shell sitting at a command -# position as a bare word (`cmd /c $_.Name`, `bash -c $_.Name`). -# -# This is ps::might_invoke_git's disqualifier, so every arm here is in the -# OVER-BLOCK direction: a match only ever restores the quote-intact probe. -# -# The launcher words extend ps::has_launcher's list with the interpreters that -# take a command string (`bash`, `sh`, `wsl`, `node`, `python`) and with the -# cmdlets that run a program WITHOUT a call operator — `Invoke-Item`/`ii` opens a -# path, `Start-Job`/`sajb` runs a script block elsewhere, `Register-ScheduledTask` -# and `New-Service` install a command line, `Invoke-WmiMethod`/`Invoke-CimMethod` -# reach Win32_Process Create, and `New-Object` constructs a Process. Each would -# otherwise turn the compared string back into a command word with none of the -# shapes above present. Unlike the -# computed-launcher probe at the end of ps::might_invoke_git they need no -# `(`/`$` operand — `cmd /c $_.Name` reaches git through an argument the -# operand-shaped probe never sees. A QUOTE is not in the predecessor class, so a -# launcher name that is itself comparison DATA (`-in @('git.exe','bash.exe')`) -# does not disqualify; a call of that same quoted name (`& 'bash' -c …`) does, -# through the call-target arm above it. -ps::_can_execute_computed_value() { - local lc="${1//\`/}" +# True (0) when TOK is a read-only cmdlet, alias or keyword — the allowlist +# ps::_is_readonly_cmdlet_pipeline admits at a command position. Every entry +# INTERROGATES: it reports, filters, formats, converts or compares, and none of +# them runs a program named by its input. Any Get-* verb is admitted as a class +# (`Get-Process`, `Get-CimInstance`, `Get-Content`, `Get-Command`), because Get +# is defined as retrieval and a PowerShell command name is verb-qualified. +ps::_is_readonly_cmdlet() { + case "$1" in + gps | ps | gcim | gwmi | gci | ls | dir | gi | gc | cat | type | gsv | gcm | gmo | gv | gl | pwd) return 0 ;; + where-object | where | '?') return 0 ;; + select-object | select) return 0 ;; + foreach-object | foreach | '%') return 0 ;; + sort-object | sort | measure-object | measure | group-object | group) return 0 ;; + format-table | ft | format-list | fl | format-wide | fw) return 0 ;; + out-string | out-host | oh | out-null) return 0 ;; + write-output | write | echo | write-host) return 0 ;; + select-string | sls) return 0 ;; + test-path | resolve-path | rvpa | split-path | join-path) return 0 ;; + compare-object | compare | diff) return 0 ;; + convertto-json | convertfrom-json | convertto-csv) return 0 ;; + if | else | elseif | in | return) return 0 ;; + *) ;; + esac + [[ "$1" =~ ^get-[a-z0-9]+$ ]] +} + +# True (0) when the whole command is provably a READ-ONLY CMDLET PIPELINE: every +# token standing at a command position is an allowlisted interrogator, and the +# command carries none of the constructs that turn a value into a command. This +# is ps::might_invoke_git's gate on the comparison-operand exemption. +# +# DENY BY DEFAULT, AND THAT IS THE POINT. Asking instead whether a known executor +# is PRESENT is structurally under-inclusive: PowerShell reaches a program +# through `[Diagnostics.Process]::Start(…)`, +# `$ExecutionContext.InvokeCommand.InvokeScript(…)`, +# `[scriptblock]::Create(…).Invoke()`, an alias minted at run time +# (`Set-Alias zz $_.Name; zz push --force`), WMI/CIM `Win32_Process Create`, a +# scheduled-task action, a service binary path, and every launcher that happens +# to be on PATH (`npx`, `dotnet`, `cscript`, `explorer`, `ssh`, `wmic`, +# `schtasks`) — a set no enumeration converges on. Inverted, an unrecognized +# command word is REFUSED rather than admitted, so a new executor costs an +# over-block instead of a bypass. +# +# THE SCAN, in order, over the command with every quoted string replaced by an +# opaque placeholder (so a `[` or an `&` inside message text is data, not a +# construct): +# 1. refuse outright on a construct no token scan can settle — a surviving +# BACKTICK (escape), `<#` (block comment), `--%` (stop-parsing), `::` +# (static member, the `[Type]::Method` executor family), `[` (type +# literal), `&` (call operator), and a `.` immediately before `(` (a method +# call such as `.Invoke(` / `.InvokeScript(`); +# 2. walk the remainder token by token, tracking COMMAND POSITION — the start +# of input, and anything after `|`, `;`, `{`, `}`, `(`, `=` or a newline; +# 3. at a command position the token must be allowlisted. A `$variable` or +# property chain, a `-parameter` or operator, an opaque string placeholder +# and a bare number are inert and skipped; anything else at a command +# position refuses — `zz`, `npx`, `iwmi`, `nsv`, `schtasks`, `wmic`, +# `set-alias`, `new-object`, `iex`, a `.` dot-source, a `.\path.exe`, and +# `git` itself. +# +# ARGUMENTS ARE NOT COMMAND WORDS, so a token away from a command position is +# skipped and `Get-CimInstance Win32_Process`, `Select-Object ProcessId` and +# `Select-Object Id` stay admissible. An argument cannot execute on its own: +# reaching a program through one takes a command word that accepts it, and that +# command word sits at a command position and must be allowlisted. +# +# FAIL CLOSED ON DOUBT. An over-long command is refused rather than scanned. The +# walk is per-character and the exemption is a convenience, so the ceiling costs +# an over-block on a command that keeps the quote-intact probe it had anyway. +ps::_is_readonly_cmdlet_pipeline() { + local lc i n ch tok="" cmdpos=1 + ps::opaque_quoted_spans_to lc "$1" lc="${lc,,}" - # The target class is stated as a NEGATION — anything that is not a bare-word - # character — so a call spelled around a quote, a variable, a subexpression or - # a path (`& 'bash'`, `& $x`, `& (…)`, `& .\$_.Name`) is one arm, not four. - [[ "$lc" =~ (^|[[:space:]\;\{\}\(\|\&=])[.\&][[:space:]]*[^[:space:][:alnum:]_-] ]] && return 0 - [[ "$lc" =~ (^|[^[:alnum:]_-])(iex|invoke-expression|invoke-command|icm)([^[:alnum:]_-]|$) ]] && return 0 - [[ "$lc" =~ (^|[[:space:]\;\|\&\(\{\}=])(start-process|saps|start|pwsh|powershell|cmd|bash|sh|wsl|node|python|python3|invoke-item|ii|start-job|sajb|register-scheduledtask|new-service|invoke-wmimethod|invoke-cimmethod|new-object)(\.exe)?([^[:alnum:]_.-]|$) ]] && return 0 - return 1 + case "$lc" in + *'`'* | *'<#'* | *'--%'* | *'::'* | *'['* | *'&'*) return 1 ;; + *) ;; + esac + [[ "$lc" =~ \.[a-z0-9_]*\( ]] && return 1 + ((${#lc} > 4096)) && return 1 + # A trailing newline is a command-position separator, so the last token + # flushes inside the loop instead of in a second copy of the token test. + lc+=$'\n' + n=${#lc} + for ((i = 0; i < n; i++)); do + ch="${lc:i:1}" + case "$ch" in + '|' | ';' | '{' | '}' | '(' | '=' | $'\n' | ' ' | $'\t' | $'\r' | ',' | '@' | ')' | '>' | '<' | '+' | '*') ;; + *) + tok+="$ch" + continue + ;; + esac + if [[ -n "$tok" ]]; then + case "$tok" in + '$'* | -* | '_q_') ;; + *) + if [[ "$tok" =~ ^[0-9]+$ ]]; then + : + elif ((cmdpos)); then + ps::_is_readonly_cmdlet "$tok" || return 1 + fi + ;; + esac + tok="" + cmdpos=0 + fi + case "$ch" in + '|' | ';' | '{' | '}' | '(' | '=' | $'\n') cmdpos=1 ;; + *) ;; + esac + done + return 0 } -# Sibling of ps::blank_quoted_spans_to for ONE consumer: -# ps::computed_call_has_positional_write_signal. Same left-to-right pairing — +# Sibling of ps::blank_quoted_spans_to for the two consumers that need a string +# to stay PRESENT while its content stays opaque: +# ps::computed_call_has_positional_write_signal and +# ps::_is_readonly_cmdlet_pipeline. Same left-to-right pairing — # first opener owns its span, ambiguity copies the rest of the line verbatim — # but a FOUND span is replaced by a classified placeholder instead of deleted. # @@ -581,8 +661,10 @@ ps::_can_execute_computed_value() { # blanking both operands left the probe with nothing to count. The placeholder # keeps the operand PRESENT so it still counts, while its content stays OPAQUE # so a quoted `>` or `-value` in message text cannot become a different signal. -# That is why this string is handed ONLY to the positional probe: the redirect -# and `-va*` probes still need the deletion semantics. +# That is why this string never reaches the redirect and `-va*` probes, which +# still need the deletion semantics. The read-only-pipeline gate wants the same +# property for the same reason: a string has to stay a TOKEN so the walk can tell +# a command word from an argument, while its content stays out of the scan. # # Classification, interpolating-dash FIRST (about_Quoting_Rules + about_Parsing): # 1. DOUBLE-quoted, starts with `-`, AND contains `$` → `-_q_` @@ -1100,12 +1182,13 @@ ps::call_target_is_interpolating_string() { # comparison operator, reached through an optional `(` / `@(` and through # `,`-separated earlier elements of the same list # (ps::_is_comparison_operand_context; right-hand operands only); -# (b) the whole command carries no call or dot-source of a non-bare-word target, -# no expression evaluator, and no launcher / nested shell as a bare command -# word (ps::_can_execute_computed_value). +# (b) the whole command is provably a READ-ONLY CMDLET PIPELINE — every token at +# a command position is an allowlisted interrogator and no construct turns a +# value into a command (ps::_is_readonly_cmdlet_pipeline), which is an +# allowlist, not a list of known executors. # Under (b) the only thing the matched text can DO with the string is compare it: -# `Get-Process | Where-Object { $_.Name -eq 'git' }` filters objects, and no -# operator in what remains turns the filtered value back into a command word. The +# `Get-Process | Where-Object { $_.Name -eq 'git' }` filters objects, and nothing +# in what remains turns the filtered value back into a command word. The # false-positive class this retires is read-only PowerShell that merely NAMES git # — `-eq 'git'`, `-in @('git.exe','bash.exe')`, `-like 'git*'`, `-match "^git\.exe$"` # — which the script block alone had already routed to the sink. @@ -1113,18 +1196,25 @@ ps::call_target_is_interpolating_string() { # WHAT KEEPS THE QUOTE-INTACT PROBE, and why each one must: # `& 'git' commit`, `git 'commit'` the literal is a call target or an # argument, never a comparison operand; -# `Start-Process 'git' …`, `saps 'git'` likewise, and (b) fails on the launcher; -# `cmd /c 'git push --force'` (b) fails on the nested shell; -# `… -eq 'git' … | % { & $_.Name … }` (b) fails: the call target is computed, -# `… -eq 'git' … | % { . $_.Name … }` so the compared string becomes the -# `… -eq 'git' … | % { iex $_.Name }` command word after all; -# `… -eq 'git' … | % { cmd /c $_.Name }` (b) fails on the launcher word, which -# `… -eq 'git' … | % { bash -c $_.Name }` reaches git through an ARGUMENT the -# computed-launcher probe below never sees; +# `'git' -in $names` a LEFT-hand operand, which `& 'git' -eq $x` +# makes undecidable, so it is not exempt; +# `Start-Process 'git' …`, `saps 'git'` likewise, and (b) refuses the command word; +# `cmd /c 'git push --force'` (b) refuses the nested shell; +# and every shape that turns the compared value back into a command word, each +# refused by (b) because its command word is not an interrogator or its +# construct is not a pipeline at all: +# `| % { & $_.Name … }`, `| % { . $_.Name … }`, `| % { iex $_.Name }`, +# `| % { cmd /c $_.Name }`, `| % { bash -c $_.Name }`, `| % { npx $_.Name }`, +# `| % { dotnet/cscript/explorer/ssh … $_.Name }`, +# `| % { Invoke-Item $_.Name }` and its `ii` alias, `Start-Job`, `New-Object`, +# `New-Service`/`nsv`, `Register-ScheduledTask`/`New-ScheduledTaskAction`, +# `iwmi`/`Invoke-CimMethod` Win32_Process Create, `schtasks /tr`, `wmic +# process call create`, `Set-Alias zz $_.Name; zz push --force`, +# `[Diagnostics.Process]::Start($_.Name, …)`, +# `[scriptblock]::Create($_.Name).Invoke()`, +# `$ExecutionContext.InvokeCommand.InvokeScript($_.Name)`; # `$n = 'git'; & $n push -f` `=` is not a comparison operator, and (b) -# fails on the variable call target; -# `'git' | % { & $_ push -f }` the literal is pipeline input, not an -# operand, and (b) fails. +# refuses the call operator. ps::might_invoke_git() { local recovered="${1//\`/}" lc narrowed lc="${recovered,,}" @@ -1132,10 +1222,12 @@ ps::might_invoke_git() { # counts (Codex #2592 review) and `=` so `$x=git …` (no space) still counts # (Claude #2592 review), while `.git` stays inert (`.` is not listed). if [[ "$lc" =~ (^|[[:space:]\;\|\&\(\{\}\"\'/\\:=])git([.]exe)?([^[:alnum:]_/\\]|$) ]]; then - # A git token is visible. It is exempt only as comparison DATA in a command - # that cannot execute a computed value — the walk runs only here, so a - # command with no git token pays nothing for it. - ps::_can_execute_computed_value "$recovered" && return 0 + # A git token is visible. It is exempt only as comparison DATA inside a + # provably read-only cmdlet pipeline — the walks run only here, so a command + # with no git token pays nothing for them. The gate is handed the RAW text, + # not the backtick-recovered copy, because a surviving backtick is itself one + # of the constructs it refuses. + ps::_is_readonly_cmdlet_pipeline "$1" || return 0 ps::_blank_comparison_operand_literals_to narrowed "$lc" # The re-probe is the SAME pattern spelled again, not shared through a # variable — the fail-OPEN reason the sibling predicates record: a pattern 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 06/15] fix(guardrails): predeclare the nameref-assigned SUBJECT for shellcheck hook::extract_bash_subject_to assigns SUBJECT through a nameref, which shellcheck 0.11 cannot follow; CI's lint lane failed SC2154 on the two guards that read it in emit_tel. Declaring the variable empty first is the idiom block-hook-bypass already uses. block-no-verify 256/0 and flag-commit-pr-skill-bypass 35/0 unchanged. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/hooks/block-no-verify.sh | 1 + plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh index d42160391f..2365c8402d 100755 --- a/plugins/guardrails/hooks/block-no-verify.sh +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -137,6 +137,7 @@ TOOL_NAME="${HOOK_JQ_FIELDS[1]:-Bash}" # commands are well under it). The linear parser keeps normal commands cheap. MAX_COMMAND_LEN=16384 +SUBJECT="" # predeclared: the _to helper assigns through a nameref (SC2154) hook::extract_bash_subject_to SUBJECT "$TOOL_NAME" "$COMMAND" # Hook-manager env-var disable prefixes, built once into a regex alternation. diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index 4dc97e3402..cbab8bc223 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -124,6 +124,7 @@ fi # keep an assignment VALUE out of the subject — a quoted value spanning the # whitespace the tokenizer splits on, and a bare/trailing `NAME=value` no # following command consumed — hold here too (#3372). +SUBJECT="" # predeclared: the _to helper assigns through a nameref (SC2154) hook::extract_bash_subject_to SUBJECT "$TOOL_NAME" "$COMMAND" # Emit one telemetry envelope per run. Advisory guards always report status From 04fc869f892d4209334c32bb30355783fe78e67a Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:00:40 -0400 Subject: [PATCH 07/15] test(lib): use a portable cwd in the fast-fields payload fixture CI's machine-specific-paths hygiene check refuses a Windows user path in a fixture. The value is opaque to the parser under test; hook-utils 504/0 unchanged. Co-Authored-By: Claude Fable 5.1 --- lib/hook-utils.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 9df1ba8918..6c4d5c0685 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -4496,7 +4496,7 @@ fast_fields_is_jq() { # fail "fast fields ($desc): fast [$(printf '%q ' "${fast[@]}")] jq [$(printf '%q ' "${slow[@]:1}")]" fi } -fast_fields_is_jq "Bash payload" '{"session_id":"s","cwd":"C:\\Users\\me","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","description":"probe"}}' +fast_fields_is_jq "Bash payload" '{"session_id":"s","cwd":"C:\\work\\repo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","description":"probe"}}' # shellcheck disable=SC2016 # the $_ is PowerShell's, inside a JSON payload fast_fields_is_jq "PowerShell payload with braces and backslashes" '{"tool_name":"PowerShell","cwd":"C:\\Dev","tool_input":{"command":"Get-ChildItem C:\\Dev | Where-Object { $_.Name -like \"*x*\" }"}}' fast_fields_is_jq "escapes in the command" '{"tool_name":"Bash","tool_input":{"command":"git commit -m \"a\\nb\" && echo \"\\t\"\\\\x"}}' From c83d1c859d4e4c4c4e6dd3bf60806691496ab515 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:11:39 -0400 Subject: [PATCH 08/15] fix(guardrails): refuse the compared-literal exemption on any expandable string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comparison-operand exemption is gated on a provably read-only cmdlet pipeline, and that gate walks the command with every quoted string replaced by an opaque placeholder. A DOUBLE-quoted string collapses to `$q` (or `-_q_`), which the walk treats as inert, but PowerShell evaluates an expandable string where it is written: `"$( … )"` runs its subexpression to BUILD the value, so the operand is itself a command position and the placeholder is exactly what hides it. A security review turned that one hole into BLOCKED -> ALLOWED flips through `cmd /c`, `bash -c`, `powershell -c`, `Start-Process`, `& 'git'`, `Start-Job`, `Invoke-Item`, `New-Object`, `node -e` and `schtasks`, under `-eq`, `-like` and `-in`, inside a second `Where-Object`, behind `Get-Content`, and interpolated beside `${env:ComSpec}`. A bare `"$(git push --force)"` and the `schtasks` shape were open before the gate change as well. ps::_is_readonly_cmdlet_pipeline now refuses on the presence of ANY expandable string, whatever is inside it. The witness is the quote character that OPENED the span, published by the single walk (PS_QUOTED_SPAN_SAW_EXPANDABLE) and read by its immediate caller. Neither shortcut is exact: the opaque placeholder kind shares `_q_` between `'git'` and `"git"`, and a raw `"` scan misreads the `"` inside `'he said "hi"'`. Verbatim `'…'` operands stay exempt, so the five designed shapes (`-eq 'git'`, `-ceq 'git'`, `-in @('git.exe','bash.exe')`, `-notin @('git','node')`, `-like 'git*'`) are unchanged. The other expandable form needed a second disqualifier, because no change to that gate can reach it: a `@"` … `"@` here-string is blanked at INTAKE, so the git token leaves with the body and ps::might_invoke_git is never consulted. ps::blank_herestrings records that an expandable body was blanked (PS_HERESTRING_EXPANDABLE) and ps::classify_git_command refuses at the sink, where a NO from the git probe is a statement about text the command does not have rather than a proof of git-freedom. A verbatim `@'` … `'@` body, and an unbalanced opener, which leaves the raw command in view for the probe, are both untouched. Corrected while in there: the exemption's comment listed `-match "^git\.exe$"` among the false positives it retires. That command is allowed on both roots because `^` is not a command-position predecessor, so no git token is visible and the exemption never runs on it. The eighteen reviewed shapes are counterexample tests, plus predicate pins for the two cases the inexact detectors get wrong, a sink-trigger pin for the here-string, a verbatim here-string allow, and a recorded-residual pin that `ForEach-Object -MemberName Kill` / `-ArgumentList` method dispatch stays where it is. block-dangerous-git 579 PASS / 0 FAIL, block-no-verify 256 / 0, block-hook-bypass 645 / 2 (the pre-existing symlink pair). shellcheck clean. Two-root differentials against the pre-exemption tree: cases=653 mismatches=6 root_strings_normalized=0 exit_codes={2: 385, 0: 253, 256: 15} seconds_per_arm={'baseline': 2914, 'candidate': 385} cases=15 mismatches=0 root_strings_normalized=0 exit_codes={0: 8, 2: 7} seconds_per_arm={'baseline': 34, 'candidate': 6} cases=34 mismatches=0 root_strings_normalized=0 exit_codes={0: 15, 2: 19} seconds_per_arm={'baseline': 144, 'candidate': 38} cases=65 mismatches=6 root_strings_normalized=0 exit_codes={0: 7, 2: 58} seconds_per_arm={'baseline': 347, 'candidate': 31} The 653-command harvested corpus reported 6 mismatches, every one of them on a row whose BASELINE arm returned the abnormal rc 256 with empty output, 15 such rows in all; the second line is those 15 re-run, identical on both roots, so the harvested corpus carries 0 decision changes. Predicted 0, because no command in it holds a double-quoted string together with a compared git literal. The perf baseline's 17-command corpus is identical across both tool modes. The designed corpus plus the executor shapes and the 18 new ones, 65 commands, flips 6: the 5 verbatim exempt shapes BLOCKED -> ALLOWED, and the `@"…"@` operand ALLOWED -> BLOCKED, with 6 allowed on both roots and 53 blocked byte-for-byte. Its 47-command subset is 5 flips, 6 allowed on both roots and 36 blocked byte-for-byte. The chain still creates 3 processes on a PowerShell call that reaches the sink. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/CHANGELOG.md | 2 +- .../hooks/block-dangerous-git.test.sh | 77 ++++++++++++++++++ .../guardrails/lib/powershell/ps-command.sh | 80 +++++++++++++++++-- 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 47b956ac3c..dfdbe821a0 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to the `guardrails` plugin are documented here. Format follo ### Changed - lib/powershell/ps-command.sh spawns nothing. Every `$(ps::…)` capture is now an out-parameter helper that assigns with `printf -v` — `ps::blank_quoted_spans_to`, `ps::opaque_quoted_spans_to`, `ps::call_site_operand_region_to`, `ps::blank_bracket_interiors_to`, `ps::fold_escaped_brace_closers_to` and the three `ps::_skip_*_to` index walkers, the `_to` convention hook-utils.sh already uses — and every `printf | sed` pipeline and `< <(printf …)` line reader is a pure-bash substitution or split (`ps::_gsub_to`, which applies an ERE per line exactly as sed hands its regex one line at a time, and `ps::_split_lines_to`). On Windows Git Bash the PowerShell lane's PreToolUse chain went from 80 process creations to 3 — the harness's `bash -c`, the `env` of the shebang, and bash itself — and 2422 ms to 300 ms isolated p50; a PowerShell command that carries a script block and a git token, so it reaches the fail-closed sink, from 44 creations and 1433 ms to 3 and 299 ms. The Bash lane, which never loads this library, stays at 3 creations and 288 ms to 274 ms. Every deny and allow is byte-identical (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over 653 commands harvested from the guard suites on each lane. Two host behaviors the captured forms carried are reproduced rather than dropped, because a PowerShell command arrives with Windows line endings and PS_SAFE_COMMAND goes on to a Bash tokenizer: `$(…)` here eats a trailing CRLF whole, not just its LF, and this host's `sed` reads in text mode, so a CRLF line ending loses its CR. -- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command is provably a READ-ONLY CMDLET PIPELINE. That second half is an ALLOWLIST, not a list of known executors: with every quoted string replaced by an opaque placeholder, the command is refused outright on a surviving backtick, `<#`, `--%`, `::`, `[`, `&` or a `.` before `(`, and is then walked token by token so that every token standing at a command position (the start of input, or after `|`, `;`, `{`, `}`, `(`, `=` or a newline) is an allowlisted interrogator — any `Get-*` verb, `gps`/`ps`/`gcim`/`gwmi`/`gci`/`ls`/`dir`/`gi`/`gc`/`cat`/`type`/`gsv`/`gcm`/`gmo`/`gv`/`gl`/`pwd`, `Where-Object`/`?`, `Select-Object`, `ForEach-Object`/`%`, `Sort-Object`, `Measure-Object`, `Group-Object`, the `Format-*` and `Out-*` set, `Write-Output`/`Write-Host`/`echo`, `Select-String`/`sls`, the path helpers, `Compare-Object`/`diff`, the `ConvertTo-*`/`ConvertFrom-*` pair, and the `if`/`else`/`elseif`/`in`/`return` keywords. `$variable` chains, `-parameter` tokens, string placeholders, numbers and ARGUMENTS (`Get-CimInstance Win32_Process`, `Select-Object ProcessId`) are inert; any other command word refuses. Inverting the test is what makes it sound — a blocklist of executors is structurally under-inclusive, and an unrecognized command word now costs an over-block instead of a bypass. Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, a command past the scan's length ceiling, and every `… -eq 'git' … | % { $_.Name }` form — `&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, `npx`, `dotnet`, `cscript`, `explorer`, `ssh`, `wmic process call create`, `schtasks /tr`, `Invoke-Item`/`ii`, `Start-Job`, `New-Object`, `New-Service`/`nsv`, `New-ScheduledTaskAction`, `iwmi -Class Win32_Process -Name Create`, `Set-Alias zz $_.Name; zz push --force`, `[Diagnostics.Process]::Start($_.Name, …)`, `[scriptblock]::Create($_.Name).Invoke()` and `$ExecutionContext.InvokeCommand.InvokeScript($_.Name)`, none of them named by the rule and all of them refused by it. Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted — no command in it carries a git literal in operand position) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes; a designed-case corpus of the exempt and counterexample shapes flips exactly 5 commands, every one BLOCKED→ALLOWED with the sink message gone, and leaves its other 18 blocked byte-for-byte. +- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command is provably a READ-ONLY CMDLET PIPELINE. That second half is an ALLOWLIST, not a list of known executors: with every quoted string replaced by an opaque placeholder, the command is refused outright when it carries ANY EXPANDABLE STRING anywhere (a double-quoted `"…"` span, or a `@"…"@` here-string), on a surviving backtick, `<#`, `--%`, `::`, `[`, `&` or a `.` before `(`, and is then walked token by token so that every token standing at a command position (the start of input, or after `|`, `;`, `{`, `}`, `(`, `=` or a newline) is an allowlisted interrogator — any `Get-*` verb, `gps`/`ps`/`gcim`/`gwmi`/`gci`/`ls`/`dir`/`gi`/`gc`/`cat`/`type`/`gsv`/`gcm`/`gmo`/`gv`/`gl`/`pwd`, `Where-Object`/`?`, `Select-Object`, `ForEach-Object`/`%`, `Sort-Object`, `Measure-Object`, `Group-Object`, the `Format-*` and `Out-*` set, `Write-Output`/`Write-Host`/`echo`, `Select-String`/`sls`, the path helpers, `Compare-Object`/`diff`, the `ConvertTo-*`/`ConvertFrom-*` pair, and the `if`/`else`/`elseif`/`in`/`return` keywords. `$variable` chains, `-parameter` tokens, string placeholders, numbers and ARGUMENTS (`Get-CimInstance Win32_Process`, `Select-Object ProcessId`) are inert; any other command word refuses. The expandable-string disqualifier is there because such a string is EVALUATED where it is written: `"$( … )"` runs its subexpression to build the value, so the operand itself is a command position, and the very placeholder that keeps the scan honest about message text is what hides it. A fresh security review reproduced the whole executor family through that one hole, under `-eq`, `-ceq`, `-like`, `-in`, inside a second `Where-Object`, behind `Get-Content`, and interpolated beside `${env:ComSpec}`: `"$(cmd /c git push --force)"`, `"$(bash -c 'git push --force')"`, `"$(powershell -c 'git reset --hard')"`, `"$(Start-Process git -ArgumentList push,--force)"`, `"$(& 'git' push -f)"`, `"$(Start-Job { git push -f })"`, `"$(Invoke-Item git.exe)"`, `"$(New-Object …)"`, `"$(node -e 'x')"`, `"$(schtasks /create … /tr 'git push -f')"` and a bare `"$(git push --force)"`. The refusal is on the `"` itself, so it does not depend on recognizing any of them. A `@"…"@` here-string is blanked out of the command at intake, taking its git token with it, so that form is refused at the fail-closed sink instead, where the blanking happened; a verbatim `@'…'@` body carries no command position and is untouched. VERBATIM `'…'` operands, which is every shape this bullet exempts, are unaffected. Inverting the test is what makes it sound — a blocklist of executors is structurally under-inclusive, and an unrecognized command word now costs an over-block instead of a bypass. Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, a command past the scan's length ceiling, and every `… -eq 'git' … | % { $_.Name }` form — `&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, `npx`, `dotnet`, `cscript`, `explorer`, `ssh`, `wmic process call create`, `schtasks /tr`, `Invoke-Item`/`ii`, `Start-Job`, `New-Object`, `New-Service`/`nsv`, `New-ScheduledTaskAction`, `iwmi -Class Win32_Process -Name Create`, `Set-Alias zz $_.Name; zz push --force`, `[Diagnostics.Process]::Start($_.Name, …)`, `[scriptblock]::Create($_.Name).Invoke()` and `$ExecutionContext.InvokeCommand.InvokeScript($_.Name)`, none of them named by the rule and all of them refused by it. Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted — no command in it carries a git literal in operand position, and none carries a double-quoted string together with a compared git literal) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes. The designed corpus of exempt, counterexample and executor shapes, 47 commands, flips exactly 5, every one BLOCKED→ALLOWED with the sink message gone, allows 6 on both roots and leaves the other 36 blocked byte-for-byte. Extended with the 18 reviewed expandable shapes, 65 commands, it flips 6: those same 5, plus the `@"…"@` here-string operand ALLOWED→BLOCKED, with 6 allowed on both roots and 53 blocked byte-for-byte. ## [0.34.0] diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index c1eafc0d40..2e0cd031c7 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -1344,6 +1344,83 @@ pin_predicate "ps::_is_readonly_cmdlet_pipeline: a cmdlet argument is not a comm pin_sink_trigger "classify: the comparison pipeline still enters the special-construct sink" \ "Get-Process | Where-Object { \$_.Name -eq 'git' }" "special-construct" +# --- an EXPANDABLE operand is a command position, not data -------------------- +# A double-quoted string is evaluated where it is written, so `"$( … )"` runs a +# program to build the value the comparison then reads. The walk replaces that +# span with an inert placeholder, which is precisely what hid the executor from +# the read-only-pipeline token scan: a security review reproduced the whole +# family through it. Every shape below therefore stays blocked, and the +# disqualifier is the `"` itself, not recognition of the executor inside it. +run_pwsh "PS cmp: expandable operand running cmd /c (blocked)" \ + "Write-Output (\"x\" -eq \"\$(cmd /c git push --force)\")" 2 +run_pwsh "PS cmp: expandable operand in a Where-Object block (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(cmd /c git push --force)\" }" 2 +run_pwsh "PS cmp: expandable operand running bash -c (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(bash -c 'git push --force')\" }" 2 +run_pwsh "PS cmp: expandable operand running powershell -c (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(powershell -c 'git reset --hard')\" }" 2 +run_pwsh "PS cmp: expandable operand running Start-Process (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(Start-Process git -ArgumentList push,--force)\" }" 2 +run_pwsh "PS cmp: expandable operand calling a quoted git literal (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(& 'git' push -f)\" }" 2 +run_pwsh "PS cmp: expandable operand starting a job (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(Start-Job { git push -f })\" }" 2 +run_pwsh "PS cmp: expandable operand invoking git.exe (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(Invoke-Item git.exe)\" }" 2 +run_pwsh "PS cmp: expandable operand constructing an object beside a git literal (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(New-Object System.Diagnostics.Process)\" -and \$_.Name -eq 'git' }" 2 +run_pwsh "PS cmp: expandable operand running node beside a git literal (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(node -e 'x')\" -and \$_.Name -eq 'git' }" 2 +run_pwsh "PS cmp: expandable operand interpolated into a -like pattern (blocked)" \ + "Get-Process | Where-Object { \$_.Name -like \"*\$(cmd /c git push -f)*\" }" 2 +run_pwsh "PS cmp: expandable operand as a list element under -in (blocked)" \ + "Get-Process | Where-Object { \$_.Name -in @(\"\$(cmd /c git reset --hard)\",'git') }" 2 +run_pwsh "PS cmp: expandable operand behind Get-Content and the ? alias (blocked)" \ + "Get-Content x.txt | ? { \$_ -eq \"\$(cmd /c git clean -fdx)\" }" 2 +run_pwsh "PS cmp: expandable operand in a second Where-Object after an exempt one (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq 'git' } | Where-Object { \$_.Path -eq \"\$(cmd /c git push -f)\" }" 2 +run_pwsh "PS cmp: expandable operand mixing a variable and a subexpression (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\${env:ComSpec} \$(cmd /c git push -f)\" }" 2 +# Holes the executor-list gate left open too: the subexpression names git +# directly, or names an executor no list carried. +run_pwsh "PS cmp: expandable operand running git directly (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(git push --force)\" }" 2 +run_pwsh "PS cmp: expandable operand scheduling a git task (blocked)" \ + "Get-Process | Where-Object { \$_.Name -eq \"\$(schtasks /create /tn x /tr 'git push -f' /sc once /st 00:00)\" }" 2 +# A `@"` here-string is the OTHER expandable form, and it never reaches the +# exemption: its body is blanked at intake, so the git token is gone before the +# probe runs. The refusal therefore sits at the sink, where the blanking happened. +run_pwsh "PS cmp: expandable here-string operand (blocked)" \ + "$(printf '%s\n%s\n%s' "Get-Process | Where-Object { \$_.Name -eq @\"" "\$(cmd /c git push --force)" "\"@ }")" 2 +# A VERBATIM here-string body carries no command position and is unchanged. +run_pwsh "PS cmp: verbatim here-string operand stays data (allowed)" \ + "$(printf '%s\n%s\n%s' "Get-Process | Where-Object { \$_.Name -eq @'" "git" "'@ }")" 0 + +# Predicate pins for the disqualifier itself. A hook rc of 2 can hide "blocked by +# something else entirely", so the gate is asserted directly, including the two +# cases a raw `"` scan and the opaque placeholder kind each get wrong. +pin_predicate "ps::_is_readonly_cmdlet_pipeline: an expandable operand refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq \"git\" }" 1 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a verbatim operand still accepts" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq 'git' }" 0 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: a double quote INSIDE a verbatim string is not an expandable string" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq 'he said \"hi\"' }" 0 +pin_predicate "ps::_is_readonly_cmdlet_pipeline: an apostrophe INSIDE an expandable string still refuses" \ + ps::_is_readonly_cmdlet_pipeline "Get-Process | Where-Object { \$_.Name -eq \"it's\" }" 1 +pin_predicate "ps::might_invoke_git: an expandable operand is not data" \ + ps::might_invoke_git "Get-Process | Where-Object { \$_.Name -eq \"\$(cmd /c git push --force)\" }" 0 +pin_sink_trigger "classify: the expandable here-string still enters the special-construct sink" \ + "$(printf '%s\n%s\n%s' "Get-Process | Where-Object { \$_.Name -eq @\"" "\$(cmd /c git push --force)" "\"@ }")" "special-construct" + +# RECORDED RESIDUAL, not an endorsement: `-MemberName` dispatch calls a METHOD on +# the filtered object rather than running a program named by the compared value, +# so the read-only allowlist admits it and these stay allowed. Pinned so a later +# narrowing that reaches method dispatch flips a test instead of passing silently. +run_pwsh "PS cmp: ForEach-Object -MemberName Kill stays where it is (allowed)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | ForEach-Object -MemberName Kill" 0 +run_pwsh "PS cmp: ForEach-Object -MemberName with -ArgumentList stays where it is (allowed)" \ + "Get-Process | ? { \$_.Name -eq 'git' } | ForEach-Object -MemberName Start -ArgumentList push,--force" 0 + malformed_rc=0 (cd "$REPO_SHA1" && bash "$HOOK" <<<'not json at all' >/dev/null 2>&1) || malformed_rc=$? assert_exit "malformed JSON payload (blocked)" 2 "$malformed_rc" diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 121c5ba432..3b5daa16be 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -85,6 +85,16 @@ PS_HERESTRING_UNBALANCED=0 # it knows which opener was left hanging — naming `'@` for a `@"` body sends the # operator to a terminator PowerShell will not accept. PS_HERESTRING_QUOTE="" +# 1 when a properly-delimited EXPANDABLE here-string (`@"` … `"@`) was blanked out +# of the command. A verbatim `@'` … `'@` body is inert text, but an expandable +# body is evaluated where it is written, so a `$( … )` inside it is a COMMAND +# POSITION that the placeholder hides. Read by ps::classify_git_command, which +# will not accept a git-freedom proof taken over text with that body removed. +PS_HERESTRING_EXPANDABLE=0 +# 1 when the last ps::_walk_quoted_spans_to pass crossed a DOUBLE-quote opener, +# i.e. the walked text carries an expandable string. Written by the walk and read +# by its IMMEDIATE caller; any later walk overwrites it. +PS_QUOTED_SPAN_SAW_EXPANDABLE=0 # Set by ps::classify_git_command when a command routes to the fail-closed sink: # which of the four triggers fired (`herestring-unbalanced`, `special-construct`, # `dynamic-invocation`, `launcher`), empty otherwise. Read by the block messages @@ -291,6 +301,7 @@ ps::blank_herestrings() { local -a hs_lines=() PS_HERESTRING_UNBALANCED=0 PS_HERESTRING_QUOTE="" + PS_HERESTRING_EXPANDABLE=0 ps::_split_lines_to hs_lines "$cmd" for line in "${hs_lines[@]}"; do @@ -321,6 +332,10 @@ ps::blank_herestrings() { ps::_gsub_to opener_scan "$opener_scan" '"([^"\\]|\\.)*"' '' if [[ "$opener_scan" == *"@'" || "$opener_scan" == *'@"' ]]; then hs_quote="${line: -1}" # ' or " + # `@"` opens an EXPANDABLE body, evaluated where it is written, so the + # placeholder about to replace it stands for text that can contain a + # command position. `@'` opens a verbatim body and stands for inert text. + [[ "$hs_quote" == '"' ]] && PS_HERESTRING_EXPANDABLE=1 pending="${line%??}${PS_HERESTRING_PLACEHOLDER}" in_hs=1 continue @@ -335,6 +350,9 @@ ps::blank_herestrings() { PS_HERESTRING_UNBALANCED=1 PS_HERESTRING_QUOTE="$hs_quote" PS_BLANKED="$cmd" + # Nothing was blanked, so no expandable body is hidden: PS_BLANKED is the raw + # command and every body in it stays in view for the callers' own probes. + PS_HERESTRING_EXPANDABLE=0 return 0 fi PS_BLANKED="${out%$'\n'}" @@ -415,9 +433,18 @@ ps::blank_herestrings() { ps::_walk_quoted_spans_to() { local text="$2" mode="$3" out="" i=0 n j q found c inner n=${#text} + # EXPANDABLE-STRING WITNESS. The walk is the only place that knows which quote + # character OPENED a span, and that is exactly what tells an expandable string + # from a verbatim one: a `"` inside a single-quoted span is never examined as an + # opener, and an apostrophe inside a double-quoted span is never examined as + # one either, so `'he said "hi"'` witnesses nothing and `"it's"` witnesses an + # expandable string. Neither a raw `"` scan nor the opaque placeholder kind can + # say that: `_q_` is shared by `'git'` and by `"git"`. + PS_QUOTED_SPAN_SAW_EXPANDABLE=0 while ((i < n)); do q="${text:i:1}" if [[ "$q" == "'" || "$q" == '"' ]]; then + [[ "$q" == '"' ]] && PS_QUOTED_SPAN_SAW_EXPANDABLE=1 found=0 for ((j = i + 1; j < n; j++)); do c="${text:j:1}" @@ -578,9 +605,27 @@ ps::_is_readonly_cmdlet() { # command word is REFUSED rather than admitted, so a new executor costs an # over-block instead of a bypass. # +# AN EXPANDABLE STRING IS A COMMAND POSITION, so its presence anywhere in the +# command refuses. A double-quoted `"…"` span (and the `@"…"@` here-string form) +# is EVALUATED where it is written: `"$( … )"` runs the subexpression to build +# the string, so `-eq "$(cmd /c git push --force)"` executes a program before any +# comparison happens. The walk blanks that span to an inert placeholder, which is +# exactly what makes the executor invisible to a token scan of the blanked text. +# A security review reproduced the whole executor family through it (`cmd /c`, +# `bash -c`, `powershell -c`, `Start-Process`, `& 'git'`, `Start-Job`, +# `Invoke-Item`, `New-Object`, `node -e`, a bare `git`, `schtasks`), under `-eq`, +# `-like`, `-in` and a second `Where-Object`, and inside an interpolated +# `"${env:ComSpec} $( … )"`. A VERBATIM `'…'` string has no such evaluation +# (about_Quoting_Rules), so the single-quoted operands the exemption exists for +# stay exempt. The witness is the walk's own opener, not the raw text and not the +# placeholder kind: `_q_` is shared by `'git'` and `"git"`, and a raw `"` scan +# would misread the `"` inside `'he said "hi"'`. +# # THE SCAN, in order, over the command with every quoted string replaced by an # opaque placeholder (so a `[` or an `&` inside message text is data, not a # construct): +# 0. refuse when the walk that produced the placeholders crossed a DOUBLE-quote +# opener, the expandable-string disqualifier above; # 1. refuse outright on a construct no token scan can settle — a surviving # BACKTICK (escape), `<#` (block comment), `--%` (stop-parsing), `::` # (static member, the `[Type]::Method` executor family), `[` (type @@ -607,6 +652,8 @@ ps::_is_readonly_cmdlet() { ps::_is_readonly_cmdlet_pipeline() { local lc i n ch tok="" cmdpos=1 ps::opaque_quoted_spans_to lc "$1" + # Read IMMEDIATELY: any later walk overwrites the witness. + ((PS_QUOTED_SPAN_SAW_EXPANDABLE)) && return 1 lc="${lc,,}" case "$lc" in *'`'* | *'<#'* | *'--%'* | *'::'* | *'['* | *'&'*) return 1 ;; @@ -1183,15 +1230,21 @@ ps::call_target_is_interpolating_string() { # `,`-separated earlier elements of the same list # (ps::_is_comparison_operand_context; right-hand operands only); # (b) the whole command is provably a READ-ONLY CMDLET PIPELINE — every token at -# a command position is an allowlisted interrogator and no construct turns a -# value into a command (ps::_is_readonly_cmdlet_pipeline), which is an -# allowlist, not a list of known executors. +# a command position is an allowlisted interrogator, the command carries no +# EXPANDABLE `"…"` span (whose `$( … )` is itself a command position), and +# no construct turns a value into a command +# (ps::_is_readonly_cmdlet_pipeline), which is an allowlist, not a list of +# known executors. +# The other expandable form, a `@"…"@` here-string, never reaches (b): it is +# blanked out of the command at intake, so ps::classify_git_command refuses it at +# the sink instead, where the blanking happened. # Under (b) the only thing the matched text can DO with the string is compare it: # `Get-Process | Where-Object { $_.Name -eq 'git' }` filters objects, and nothing # in what remains turns the filtered value back into a command word. The # false-positive class this retires is read-only PowerShell that merely NAMES git -# — `-eq 'git'`, `-in @('git.exe','bash.exe')`, `-like 'git*'`, `-match "^git\.exe$"` -# — which the script block alone had already routed to the sink. +# in a VERBATIM literal (`-eq 'git'`, `-ceq 'git'`, `-in @('git.exe','bash.exe')`, +# `-notin @('git','node')`, `-like 'git*'`), which the script block alone had +# already routed to the sink. # # WHAT KEEPS THE QUOTE-INTACT PROBE, and why each one must: # `& 'git' commit`, `git 'commit'` the literal is a call target or an @@ -1214,7 +1267,12 @@ ps::call_target_is_interpolating_string() { # `[scriptblock]::Create($_.Name).Invoke()`, # `$ExecutionContext.InvokeCommand.InvokeScript($_.Name)`; # `$n = 'git'; & $n push -f` `=` is not a comparison operator, and (b) -# refuses the call operator. +# refuses the call operator; +# `-eq "$(cmd /c git push --force)"` the operand is EXPANDABLE, so building +# it runs a program, and (b) refuses the +# whole command on the `"` alone. The +# executor inside is never the thing being +# recognized. ps::might_invoke_git() { local recovered="${1//\`/}" lc narrowed lc="${recovered,,}" @@ -1591,6 +1649,16 @@ ps::classify_git_command() { # probe) so a quoted or backtick-obfuscated `git` is still seen; an unbalanced # here-string leaves PS_BLANKED as the raw command so a trailing pipeline is # scanned, not swallowed. + # + # A BLANKED EXPANDABLE HERE-STRING SUSPENDS THE PROOF. `@"` … `"@` is + # evaluated where it is written, so `$( … )` in its body is a command + # position: `-eq @"` / `$(cmd /c git push --force)` / `"@` runs git at + # construction time. The body is gone from PS_BLANKED, so a NO from + # ps::might_invoke_git is a statement about text the command does not have, + # not a proof of git-freedom. Refuse by shape instead, exactly as for every + # other construct this sink cannot settle. A verbatim `@'` … `'@` body is + # inert text and is unaffected. + ((PS_HERESTRING_EXPANDABLE)) && return 2 ps::might_invoke_git "$PS_BLANKED" || return 1 if [[ "$sink_scope" == "readonly-ok" ]] && ps::git_command_is_readonly "$PS_BLANKED"; then return 1 From 551629e9b40bc82179606b021864badeeb73e2ae Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:26:12 -0400 Subject: [PATCH 09/15] test(lib): keep the fixture cwd free of a \w escape for the portability lint The shell-portability lint reads the backslash-w in the previous spelling as a GNU-only regex class. The value is opaque to the parser under test. Co-Authored-By: Claude Fable 5.1 --- lib/hook-utils.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 6c4d5c0685..f32271546d 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -4496,7 +4496,7 @@ fast_fields_is_jq() { # fail "fast fields ($desc): fast [$(printf '%q ' "${fast[@]}")] jq [$(printf '%q ' "${slow[@]:1}")]" fi } -fast_fields_is_jq "Bash payload" '{"session_id":"s","cwd":"C:\\work\\repo","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","description":"probe"}}' +fast_fields_is_jq "Bash payload" '{"session_id":"s","cwd":"C:\\code\\proj","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","description":"probe"}}' # shellcheck disable=SC2016 # the $_ is PowerShell's, inside a JSON payload fast_fields_is_jq "PowerShell payload with braces and backslashes" '{"tool_name":"PowerShell","cwd":"C:\\Dev","tool_input":{"command":"Get-ChildItem C:\\Dev | Where-Object { $_.Name -like \"*x*\" }"}}' fast_fields_is_jq "escapes in the command" '{"tool_name":"Bash","tool_input":{"command":"git commit -m \"a\\nb\" && echo \"\\t\"\\\\x"}}' From 919bf661384d6f61a82d1270f67f9acc06806945 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:30:09 -0400 Subject: [PATCH 10/15] docs(guardrails): drop the em dashes from the 0.35.0 entry plugins/guardrails/*.md is declared purged in scripts/em-dash-purged-paths.txt, so the lint lane treats one as a regression. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index dfdbe821a0..9d4019c9ba 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -7,8 +7,8 @@ All notable changes to the `guardrails` plugin are documented here. Format follo ### Changed -- lib/powershell/ps-command.sh spawns nothing. Every `$(ps::…)` capture is now an out-parameter helper that assigns with `printf -v` — `ps::blank_quoted_spans_to`, `ps::opaque_quoted_spans_to`, `ps::call_site_operand_region_to`, `ps::blank_bracket_interiors_to`, `ps::fold_escaped_brace_closers_to` and the three `ps::_skip_*_to` index walkers, the `_to` convention hook-utils.sh already uses — and every `printf | sed` pipeline and `< <(printf …)` line reader is a pure-bash substitution or split (`ps::_gsub_to`, which applies an ERE per line exactly as sed hands its regex one line at a time, and `ps::_split_lines_to`). On Windows Git Bash the PowerShell lane's PreToolUse chain went from 80 process creations to 3 — the harness's `bash -c`, the `env` of the shebang, and bash itself — and 2422 ms to 300 ms isolated p50; a PowerShell command that carries a script block and a git token, so it reaches the fail-closed sink, from 44 creations and 1433 ms to 3 and 299 ms. The Bash lane, which never loads this library, stays at 3 creations and 288 ms to 274 ms. Every deny and allow is byte-identical (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over 653 commands harvested from the guard suites on each lane. Two host behaviors the captured forms carried are reproduced rather than dropped, because a PowerShell command arrives with Windows line endings and PS_SAFE_COMMAND goes on to a Bash tokenizer: `$(…)` here eats a trailing CRLF whole, not just its LF, and this host's `sed` reads in text mode, so a CRLF line ending loses its CR. -- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command is provably a READ-ONLY CMDLET PIPELINE. That second half is an ALLOWLIST, not a list of known executors: with every quoted string replaced by an opaque placeholder, the command is refused outright when it carries ANY EXPANDABLE STRING anywhere (a double-quoted `"…"` span, or a `@"…"@` here-string), on a surviving backtick, `<#`, `--%`, `::`, `[`, `&` or a `.` before `(`, and is then walked token by token so that every token standing at a command position (the start of input, or after `|`, `;`, `{`, `}`, `(`, `=` or a newline) is an allowlisted interrogator — any `Get-*` verb, `gps`/`ps`/`gcim`/`gwmi`/`gci`/`ls`/`dir`/`gi`/`gc`/`cat`/`type`/`gsv`/`gcm`/`gmo`/`gv`/`gl`/`pwd`, `Where-Object`/`?`, `Select-Object`, `ForEach-Object`/`%`, `Sort-Object`, `Measure-Object`, `Group-Object`, the `Format-*` and `Out-*` set, `Write-Output`/`Write-Host`/`echo`, `Select-String`/`sls`, the path helpers, `Compare-Object`/`diff`, the `ConvertTo-*`/`ConvertFrom-*` pair, and the `if`/`else`/`elseif`/`in`/`return` keywords. `$variable` chains, `-parameter` tokens, string placeholders, numbers and ARGUMENTS (`Get-CimInstance Win32_Process`, `Select-Object ProcessId`) are inert; any other command word refuses. The expandable-string disqualifier is there because such a string is EVALUATED where it is written: `"$( … )"` runs its subexpression to build the value, so the operand itself is a command position, and the very placeholder that keeps the scan honest about message text is what hides it. A fresh security review reproduced the whole executor family through that one hole, under `-eq`, `-ceq`, `-like`, `-in`, inside a second `Where-Object`, behind `Get-Content`, and interpolated beside `${env:ComSpec}`: `"$(cmd /c git push --force)"`, `"$(bash -c 'git push --force')"`, `"$(powershell -c 'git reset --hard')"`, `"$(Start-Process git -ArgumentList push,--force)"`, `"$(& 'git' push -f)"`, `"$(Start-Job { git push -f })"`, `"$(Invoke-Item git.exe)"`, `"$(New-Object …)"`, `"$(node -e 'x')"`, `"$(schtasks /create … /tr 'git push -f')"` and a bare `"$(git push --force)"`. The refusal is on the `"` itself, so it does not depend on recognizing any of them. A `@"…"@` here-string is blanked out of the command at intake, taking its git token with it, so that form is refused at the fail-closed sink instead, where the blanking happened; a verbatim `@'…'@` body carries no command position and is untouched. VERBATIM `'…'` operands, which is every shape this bullet exempts, are unaffected. Inverting the test is what makes it sound — a blocklist of executors is structurally under-inclusive, and an unrecognized command word now costs an over-block instead of a bypass. Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, a command past the scan's length ceiling, and every `… -eq 'git' … | % { $_.Name }` form — `&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, `npx`, `dotnet`, `cscript`, `explorer`, `ssh`, `wmic process call create`, `schtasks /tr`, `Invoke-Item`/`ii`, `Start-Job`, `New-Object`, `New-Service`/`nsv`, `New-ScheduledTaskAction`, `iwmi -Class Win32_Process -Name Create`, `Set-Alias zz $_.Name; zz push --force`, `[Diagnostics.Process]::Start($_.Name, …)`, `[scriptblock]::Create($_.Name).Invoke()` and `$ExecutionContext.InvokeCommand.InvokeScript($_.Name)`, none of them named by the rule and all of them refused by it. Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted — no command in it carries a git literal in operand position, and none carries a double-quoted string together with a compared git literal) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes. The designed corpus of exempt, counterexample and executor shapes, 47 commands, flips exactly 5, every one BLOCKED→ALLOWED with the sink message gone, allows 6 on both roots and leaves the other 36 blocked byte-for-byte. Extended with the 18 reviewed expandable shapes, 65 commands, it flips 6: those same 5, plus the `@"…"@` here-string operand ALLOWED→BLOCKED, with 6 allowed on both roots and 53 blocked byte-for-byte. +- lib/powershell/ps-command.sh spawns nothing. Every `$(ps::…)` capture is now an out-parameter helper that assigns with `printf -v` (`ps::blank_quoted_spans_to`, `ps::opaque_quoted_spans_to`, `ps::call_site_operand_region_to`, `ps::blank_bracket_interiors_to`, `ps::fold_escaped_brace_closers_to` and the three `ps::_skip_*_to` index walkers, the `_to` convention hook-utils.sh already uses), and every `printf | sed` pipeline and `< <(printf …)` line reader is a pure-bash substitution or split (`ps::_gsub_to`, which applies an ERE per line exactly as sed hands its regex one line at a time, and `ps::_split_lines_to`). On Windows Git Bash the PowerShell lane's PreToolUse chain went from 80 process creations to 3, the harness's `bash -c`, the `env` of the shebang, and bash itself, and from 2422 ms to 300 ms isolated p50; a PowerShell command that carries a script block and a git token, so it reaches the fail-closed sink, from 44 creations and 1433 ms to 3 and 299 ms. The Bash lane, which never loads this library, stays at 3 creations and 288 ms to 274 ms. Every deny and allow is byte-identical (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over 653 commands harvested from the guard suites on each lane. Two host behaviors the captured forms carried are reproduced rather than dropped, because a PowerShell command arrives with Windows line endings and PS_SAFE_COMMAND goes on to a Bash tokenizer: `$(…)` here eats a trailing CRLF whole, not just its LF, and this host's `sed` reads in text mode, so a CRLF line ending loses its CR. +- A quoted `git` string literal in COMPARISON-OPERAND position no longer engages the PowerShell fail-closed sink. `Get-Process | Where-Object { $_.Name -eq 'git' }`, `… -ceq 'git'`, `… -in @('git.exe','bash.exe')`, `… -notin @('git','node')` and `… -like 'git*'` are read-only pipelines that merely NAME git, and the script block alone routed them to "cannot be parsed with confidence and could reach git"; they are now allowed. The literal is blanked before the git probe only when BOTH hold: its nearest preceding non-whitespace token is a PowerShell comparison operator (`-eq -ne -in -notin -contains -notcontains -like -notlike -match -notmatch -lt -le -gt -ge`, with an optional `c`/`i` case prefix, reached through an optional `(` / `@(` and through `,`-separated earlier elements of the same list; RIGHT-hand operands only, because `& 'git' -eq $x` is a call, not a comparison), AND the whole command is provably a READ-ONLY CMDLET PIPELINE. That second half is an ALLOWLIST, not a list of known executors: with every quoted string replaced by an opaque placeholder, the command is refused outright when it carries ANY EXPANDABLE STRING anywhere (a double-quoted `"…"` span, or a `@"…"@` here-string), on a surviving backtick, `<#`, `--%`, `::`, `[`, `&` or a `.` before `(`, and is then walked token by token so that every token standing at a command position (the start of input, or after `|`, `;`, `{`, `}`, `(`, `=` or a newline) is an allowlisted interrogator: any `Get-*` verb, `gps`/`ps`/`gcim`/`gwmi`/`gci`/`ls`/`dir`/`gi`/`gc`/`cat`/`type`/`gsv`/`gcm`/`gmo`/`gv`/`gl`/`pwd`, `Where-Object`/`?`, `Select-Object`, `ForEach-Object`/`%`, `Sort-Object`, `Measure-Object`, `Group-Object`, the `Format-*` and `Out-*` set, `Write-Output`/`Write-Host`/`echo`, `Select-String`/`sls`, the path helpers, `Compare-Object`/`diff`, the `ConvertTo-*`/`ConvertFrom-*` pair, and the `if`/`else`/`elseif`/`in`/`return` keywords. `$variable` chains, `-parameter` tokens, string placeholders, numbers and ARGUMENTS (`Get-CimInstance Win32_Process`, `Select-Object ProcessId`) are inert; any other command word refuses. The expandable-string disqualifier is there because such a string is EVALUATED where it is written: `"$( … )"` runs its subexpression to build the value, so the operand itself is a command position, and the very placeholder that keeps the scan honest about message text is what hides it. A fresh security review reproduced the whole executor family through that one hole, under `-eq`, `-ceq`, `-like`, `-in`, inside a second `Where-Object`, behind `Get-Content`, and interpolated beside `${env:ComSpec}`: `"$(cmd /c git push --force)"`, `"$(bash -c 'git push --force')"`, `"$(powershell -c 'git reset --hard')"`, `"$(Start-Process git -ArgumentList push,--force)"`, `"$(& 'git' push -f)"`, `"$(Start-Job { git push -f })"`, `"$(Invoke-Item git.exe)"`, `"$(New-Object …)"`, `"$(node -e 'x')"`, `"$(schtasks /create … /tr 'git push -f')"` and a bare `"$(git push --force)"`. The refusal is on the `"` itself, so it does not depend on recognizing any of them. A `@"…"@` here-string is blanked out of the command at intake, taking its git token with it, so that form is refused at the fail-closed sink instead, where the blanking happened; a verbatim `@'…'@` body carries no command position and is untouched. VERBATIM `'…'` operands, which is every shape this bullet exempts, are unaffected. Inverting the test is what makes it sound: a blocklist of executors is structurally under-inclusive, and an unrecognized command word now costs an over-block instead of a bypass. Everything else keeps the quote-intact probe and stays blocked: `& 'git' commit --no-verify`, `git 'commit'`, `Start-Process 'git' reset --hard`, `saps 'git' -ArgumentList 'push -f'`, `cmd /c 'git push --force'`, `$n = 'git'; & $n push -f`, `'git' | % { & $_ push -f }`, `'git' -in $names` (left-hand operand), an operand list long enough to exhaust the bounded walk, a command past the scan's length ceiling, and every `… -eq 'git' … | % { $_.Name }` form, that is `&`, `.`, `iex`, `Invoke-Command`, `cmd /c`, `bash -c`, `npx`, `dotnet`, `cscript`, `explorer`, `ssh`, `wmic process call create`, `schtasks /tr`, `Invoke-Item`/`ii`, `Start-Job`, `New-Object`, `New-Service`/`nsv`, `New-ScheduledTaskAction`, `iwmi -Class Win32_Process -Name Create`, `Set-Alias zz $_.Name; zz push --force`, `[Diagnostics.Process]::Start($_.Name, …)`, `[scriptblock]::Create($_.Name).Invoke()` and `$ExecutionContext.InvokeCommand.InvokeScript($_.Name)`, none of them named by the rule and all of them refused by it. Bounded by a two-root differential against the pre-narrowing tree: the 653-command corpus harvested from the guard suites is byte-identical on the PowerShell lane (0 decision changes, and 0 predicted: no command in it carries a git literal in operand position, and none carries a double-quoted string together with a compared git literal) and on the Bash lane, which never consults this function, and the perf baseline's 17-command corpus is identical across both tool modes. The designed corpus of exempt, counterexample and executor shapes, 47 commands, flips exactly 5, every one BLOCKED→ALLOWED with the sink message gone, allows 6 on both roots and leaves the other 36 blocked byte-for-byte. Extended with the 18 reviewed expandable shapes, 65 commands, it flips 6: those same 5, plus the `@"…"@` here-string operand ALLOWED→BLOCKED, with 6 allowed on both roots and 53 blocked byte-for-byte. ## [0.34.0] From b0377b9d35aa7132cadb73997eb2eef1bf1a3c91 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:44:21 -0400 Subject: [PATCH 11/15] chore(guardrails): satisfy the machine-path and portability lint lanes A designed test case used a Windows repo path as its fixture, and the read-only cmdlet case pattern was read as a sort -V invocation. Neither changes a decision; block-dangerous-git suite unchanged. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/lib/powershell/ps-command.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 3b5daa16be..9bfa5ef0f4 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -574,7 +574,7 @@ ps::_is_readonly_cmdlet() { where-object | where | '?') return 0 ;; select-object | select) return 0 ;; foreach-object | foreach | '%') return 0 ;; - sort-object | sort | measure-object | measure | group-object | group) return 0 ;; + sort-object | sort | measure-object | measure | group-object | group) return 0 ;; # portability-ok: cmdlet names in a case pattern, not a sort -V invocation format-table | ft | format-list | fl | format-wide | fw) return 0 ;; out-string | out-host | oh | out-null) return 0 ;; write-output | write | echo | write-host) return 0 ;; From 702c629efcf14c239de671bb86dca0df8d531520 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:47:44 -0400 Subject: [PATCH 12/15] test(guardrails): use a portable Windows path in the read-only pipeline case CI's machine-specific-paths check refuses a Windows repo path in a fixture; the value is opaque to the guard. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/hooks/block-dangerous-git.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 2e0cd031c7..808128eb15 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -1219,7 +1219,7 @@ run_pwsh "PS cmp: no git token at all (allowed)" \ run_pwsh "PS cmp: Get-Process git as an argument (allowed)" \ "Get-Process git | Select-Object Id" 0 run_pwsh "PS cmp: GitHub path component is not a git command (allowed)" \ - "Get-ChildItem C:\\Dev\\GitHub | Where-Object { \$_.PSIsContainer }" 0 + "Get-ChildItem C:\\code\\proj | Where-Object { \$_.PSIsContainer }" 0 # Counterexamples: every one keeps the quote-intact probe and stays blocked. run_pwsh "PS cmp: bare git in call position beside a comparison (blocked)" \ From 6e16c10484eb47ccebb9d3f333f58526007872fd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:04:14 -0400 Subject: [PATCH 13/15] fix(hook-utils): gate the builtin field parser on Bash 4.0 and size its key bound hook::_fast_fields indexes the payload's key strings with an associative array, which Bash added in 4.0, and it ran on every hook::jq_fields call. On the 3.2 shell macOS ships, and which these hooks document support for, `local -A` fails per call. hook::_fast_fields_supported is the predicate, split out the way hook::read_supports_nchars is so a test can force the below-floor branch on a modern host, and hook::jq_fields_uncached asks it before entering the fast path. Below the floor jq answers, unchanged. The index loop also skipped any string body longer than a fixed 60 bytes before decoding it, while nothing capped the key names a caller may ask for. Two wrong answers came out of that: a requested key longer than 60 characters was proven ABSENT while present, and a key of 11 or more characters spelled with \u escapes (hook_event_name is 15, 90 escaped) was missed the same way. The bound is now six times the longest requested key name, the width of `\uXXXX` per identifier character, which is the bound the header comment always described. The suite gains four cases: the below-floor branch forced with the fast path replaced by a tripwire, a present 70-character key, an absent one, and hook_event_name spelled entirely in \u escapes. Each compares the fast path against jq rather than against an expectation. Co-Authored-By: Claude Fable 5.1 --- lib/hook-utils.sh | 34 +++++++- lib/hook-utils.test.sh | 81 ++++++++++++++++++- plugins/actionlint/CHANGELOG.md | 2 + plugins/actionlint/hooks/hook-utils.sh | 34 +++++++- plugins/autonomy/CHANGELOG.md | 2 + plugins/autonomy/hooks/hook-utils.sh | 34 +++++++- plugins/bash-format/CHANGELOG.md | 2 + plugins/bash-format/hooks/hook-utils.sh | 34 +++++++- plugins/biome-format/CHANGELOG.md | 2 + plugins/biome-format/hooks/hook-utils.sh | 34 +++++++- plugins/claude-ops/CHANGELOG.md | 2 + plugins/claude-ops/hooks/hook-utils.sh | 34 +++++++- plugins/context-guard/CHANGELOG.md | 2 + plugins/context-guard/hooks/hook-utils.sh | 34 +++++++- plugins/desktop-notification/CHANGELOG.md | 2 + .../desktop-notification/hooks/hook-utils.sh | 34 +++++++- plugins/eol-normalizer/CHANGELOG.md | 2 + plugins/eol-normalizer/hooks/hook-utils.sh | 34 +++++++- plugins/go-format/CHANGELOG.md | 2 + plugins/go-format/hooks/hook-utils.sh | 34 +++++++- plugins/guardrails/CHANGELOG.md | 2 + plugins/guardrails/hooks/hook-utils.sh | 34 +++++++- plugins/instruction-placement/CHANGELOG.md | 2 + .../instruction-placement/hooks/hook-utils.sh | 34 +++++++- plugins/markdown-format/CHANGELOG.md | 2 + plugins/markdown-format/hooks/hook-utils.sh | 34 +++++++- plugins/powershell-format/CHANGELOG.md | 2 + plugins/powershell-format/hooks/hook-utils.sh | 34 +++++++- plugins/rate-limit-guard/CHANGELOG.md | 2 + plugins/rate-limit-guard/hooks/hook-utils.sh | 34 +++++++- plugins/ruff-format/CHANGELOG.md | 2 + plugins/ruff-format/hooks/hook-utils.sh | 34 +++++++- plugins/source-control/CHANGELOG.md | 2 + plugins/source-control/hooks/hook-utils.sh | 34 +++++++- plugins/typos-format/CHANGELOG.md | 2 + plugins/typos-format/hooks/hook-utils.sh | 34 +++++++- 36 files changed, 672 insertions(+), 55 deletions(-) diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index f32271546d..3f6de78785 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -4559,7 +4559,86 @@ if [[ "${HOOK_JQ_FIELDS[0]}" == "true" && "${HOOK_JQ_FIELDS[1]}" == "Bash" && "$ else fail "jq_fields common payload: [$(printf '%q ' "${HOOK_JQ_FIELDS[@]}")] nul=$HOOK_JQ_FIELDS_NUL" fi -unset ff_case ff_rc + +# The skip bound is six times the longest REQUESTED key name, not a fixed 60: +# an ASCII identifier character's longest escaped spelling is `\uXXXX`. Each +# case is a differential, so what is pinned is jq's answer rather than this +# suite's belief about it. +ff_pair_check() { # + local desc="$1" payload="$2" filter="$3" want="$4" rc=0 src=0 slow + hook::_fast_fields "$payload" "$filter" || rc=$? + if ((rc != 0)); then + fail "$desc: the fast path did not prove it (rc=$rc)" + return + fi + slow=$( + hook::_fast_fields() { return 2; } + hook::jq_fields_uncached "$payload" "$filter" || exit $? + printf '%s' "${HOOK_JQ_FIELDS[0]}" + ) || src=$? + if ((src != 0)); then + fail "$desc: proven by the fast path but the jq path returned $src" + return + fi + if [[ "${HOOK_JQ_FIELDS[0]}" == "$want" && "$slow" == "$want" ]]; then + ok "$desc" + else + fail "$desc: fast=[${HOOK_JQ_FIELDS[0]}] jq=[$slow] want=[$want]" + fi +} +ff_long="" +ff_gone="" +for ((ff_i = 0; ff_i < 70; ff_i++)); do + ff_long+=k + ff_gone+=z +done +ff_pair_check "fast fields: a present key name longer than 60 characters is not skipped" \ + "{\"$ff_long\":\"present\",\"tool_name\":\"Bash\"}" ".$ff_long" "present" +ff_pair_check "fast fields: a key name longer than 60 characters that is absent is proven empty" \ + "{\"$ff_long\":\"present\",\"tool_name\":\"Bash\"}" ".$ff_gone" "" +# `hook_event_name` is 15 characters, so its fully escaped spelling is 90 bytes: +# past the old fixed bound, inside six times the name's own length. +ff_esc="" +ff_name=hook_event_name +for ((ff_i = 0; ff_i < ${#ff_name}; ff_i++)); do + printf -v ff_ch '\\u%04x' "'${ff_name:ff_i:1}" + ff_esc+=$ff_ch +done +ff_pair_check "fast fields: a key spelled entirely with \\u escapes is recognized" \ + "{\"$ff_esc\":\"PreToolUse\",\"tool_name\":\"Bash\"}" ".$ff_name" "PreToolUse" + +# The Bash 4.0 floor. hook::_fast_fields indexes with an associative array, so +# below 4.0 the call site must skip the whole fast path and let jq answer. +# Forced here by overriding the predicate; hook::_fast_fields is replaced by a +# tripwire, so a gate that is not wired shows up as a failure rather than as +# two paths that happen to agree. +ff_floor_payload='{"session_id":"s","cwd":"/x","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"true","file_path":"/a b","content":"c\nd"}}' +ff_floor_rc=0 +ff_floor=() +mapfile -d '' ff_floor < <( + hook::_fast_fields_supported() { return 1; } + hook::_fast_fields() { + printf 'FAST-PATH-RAN-BELOW-FLOOR\0' + builtin exit 99 + } + hook::jq_fields "$ff_floor_payload" "${FF_FILTERS[@]}" || exit $? + printf '%s\0' "$HOOK_JQ_FIELDS_NUL" "${HOOK_JQ_FIELDS[@]}" +) +hook::jq_fields "$ff_floor_payload" "${FF_FILTERS[@]}" || ff_floor_rc=$? +ff_same=1 +((ff_floor_rc == 0)) || ff_same=0 +((${#ff_floor[@]} == ${#FF_FILTERS[@]} + 1)) || ff_same=0 +[[ "${ff_floor[0]-}" == "$HOOK_JQ_FIELDS_NUL" ]] || ff_same=0 +for ((ff_i = 0; ff_i < ${#HOOK_JQ_FIELDS[@]}; ff_i++)); do + [[ "${ff_floor[ff_i + 1]-}" == "${HOOK_JQ_FIELDS[ff_i]}" ]] || ff_same=0 +done +if ((ff_same)); then + ok "jq_fields: below the Bash 4.0 floor the fast path is skipped and jq answers the same" +else + fail "jq_fields below the floor: got [$(printf '%q ' "${ff_floor[@]}")] want nul=$HOOK_JQ_FIELDS_NUL [$(printf '%q ' "${HOOK_JQ_FIELDS[@]}")]" +fi +unset ff_case ff_rc ff_long ff_gone ff_esc ff_name ff_ch ff_i ff_same ff_floor ff_floor_rc ff_floor_payload +unset -f ff_pair_check # --- Test 23: hook::emit_document is the one stdout path -------------------- ed_out=$(hook::emit_channels PreToolUse "ctx" "sys") diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index 31cbb049f1..35633d366c 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `actionlint` plugin are documented here. Format follo ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.8.50] diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index a865072cad..36a24da235 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `autonomy` plugin are documented here. Format follows ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.23.12] diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index 1923836384..2c7c99aabb 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `bash-format` plugin are documented here. Format foll ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.7.50] diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 78efee231f..5947dbb4b2 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `biome-format` plugin are documented here. Format fol ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.48] diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 12b45389ba..699f4c1233 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `claude-ops` plugin are documented here. Format follo ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.56.13] diff --git a/plugins/claude-ops/hooks/hook-utils.sh b/plugins/claude-ops/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/claude-ops/hooks/hook-utils.sh +++ b/plugins/claude-ops/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index cb5ab66268..aa619369b3 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -10,6 +10,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.7.63] diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index 8592d9fe5d..60476b8244 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `desktop-notification` plugin are documented here. Fo ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.43] diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index ae84a2d84d..da2c64e5ee 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format f ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.49] diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index 7def03a1f0..95ba97b255 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `go-format` plugin are documented here. Format follow ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.3.53] diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index ab6a246b80..414f8284d0 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to the `guardrails` plugin are documented here. Format follo - run-guards.sh runs its guards inside its own shell instead of one command-substitution subshell per guard. A sourced guard's `exit` is a dispatcher function that records the status and runs the next guard from inside the call; a guard's stdout document is collected through `hook::emit_document` rather than captured from a subshell; a guard that dies of a hard error hands its status to the abort boundary's new chain slot (`_GAB_CONTINUE`, abort-boundary.sh), which settles that guard's posture and runs the guards still owed in one subshell. With the library's builtin field parser answering the primed fields, a benign Bash tool call's chain spawns nothing: on Windows Git Bash the chain went from 23 process creations to 3 (the harness's `bash -c`, the `env` of the shebang, and bash itself), 880 ms to 297 ms isolated p50; the PowerShell lane from 100 to 80 creations, 3.3 s to 2.4 s, with its remaining forks inside `lib/powershell/ps-command.sh`. Every deny and allow is byte-identical before and after (rc, stdout and stderr) over 34 Bash and PowerShell invocations of the perf baseline's 17-command corpus and over the commands harvested from the guard suites. block-windows-drive-tmp masks quoted redirects in-shell (`mask_quoted_redirect_ops_to`); block-no-verify and flag-commit-pr-skill-bypass resolve their telemetry subject in-shell. - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value); `hook::jq_fields_uncached` names the same body for the dispatcher's cache; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.33.11] diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/instruction-placement/CHANGELOG.md b/plugins/instruction-placement/CHANGELOG.md index 1560240f49..69fbe1412e 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `instruction-placement` plugin are documented here. F ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.13.10] diff --git a/plugins/instruction-placement/hooks/hook-utils.sh b/plugins/instruction-placement/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/instruction-placement/hooks/hook-utils.sh +++ b/plugins/instruction-placement/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 55c356aeab..f73243964c 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `markdown-format` plugin are documented here. Format ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.11.58] diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index f7f0139c7a..8f689d9a93 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `powershell-format` plugin are documented here. Forma ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.7.51] diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index 14c99d2cca..5e3988191d 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.8.19] diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index d13fb0d612..54d834c74b 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `ruff-format` plugin are documented here. Format foll ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.49] diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index 9324215eec..37e58f0576 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `source-control` plugin are documented here. Format f ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.55.88] diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/source-control/hooks/hook-utils.sh +++ b/plugins/source-control/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() diff --git a/plugins/typos-format/CHANGELOG.md b/plugins/typos-format/CHANGELOG.md index 6b4a691691..d7cc1dc51c 100644 --- a/plugins/typos-format/CHANGELOG.md +++ b/plugins/typos-format/CHANGELOG.md @@ -8,6 +8,8 @@ All notable changes to the `typos-format` plugin are documented here. Format fol ### Changed - hook-utils.sh: `hook::jq_fields` answers a well-formed payload's plain-string fields with the library's builtin JSON parser and spawns jq only for a shape it cannot prove (a NUL escape, a duplicate key, a non-string value), so a hook that reads `.tool_input.command` and `.tool_name` from an ordinary payload spawns nothing; `hook::jq_fields_uncached` names the same body for a dispatcher that caches in front of it; `hook::emit_document` is the one function every stdout document goes through; `hook::extract_bash_subject_to` is the in-shell form of the telemetry subject. Every hook's decision is unchanged: the builtin answer is proven equal to jq's, or jq runs. +- hook-utils.sh: the builtin field parser is gated on Bash 4.0, the floor its associative-array index needs. A 3.2 shell (what macOS ships, and the floor these hooks document support for) goes straight to jq instead of failing `local -A` on every `hook::jq_fields` call. +- hook-utils.sh: the builtin field parser skips a string body without decoding it only past six times the longest REQUESTED key name, the width of `\uXXXX` per identifier character, rather than past a fixed 60 bytes. A requested key longer than 60 characters is no longer proven absent while it is present, and a key of 11 or more characters spelled entirely with `\u` escapes is still recognized. ## [0.6.54] diff --git a/plugins/typos-format/hooks/hook-utils.sh b/plugins/typos-format/hooks/hook-utils.sh index 0b66f9e041..3a5f9d9cb2 100644 --- a/plugins/typos-format/hooks/hook-utils.sh +++ b/plugins/typos-format/hooks/hook-utils.sh @@ -1054,6 +1054,17 @@ hook::_fast_file_path_to() { return 0 } +# The associative-array availability guard for hook::_fast_fields below, split +# out as its own predicate so the pre-4.0 path stays reachable in tests on a +# modern host: BASH_VERSINFO is readonly, so it cannot be shadowed, but a test +# can override this function after sourcing. Same class as +# hook::read_supports_nchars. macOS ships Bash 3.2 and these hooks document +# 3.2+ support, so below the floor the answer is jq's, not a wrong one. Not a +# consumer seam. +hook::_fast_fields_supported() { + ((BASH_VERSINFO[0] >= 4)) +} + # hook::_fast_fields ... # The builtin answer to hook::jq_fields' jq program for the filters that # program is usually given: `.key` and `.key.sub`, identifier keys only. @@ -1074,19 +1085,33 @@ hook::_fast_file_path_to() { # whose escapes hook::json_unescape_to does not decode (a NUL, a \u past # U+007F) likewise. Key strings are compared after decoding, so a key spelled # with \u escapes is still recognized; a body longer than any escaped spelling -# of a key name is skipped without decoding. +# of a requested key name is skipped without decoding. That bound is six times +# the longest requested key name: a filter key is an ASCII identifier, and an +# identifier character's longest escaped spelling is `\uXXXX`, six bytes. +# +# Bash 4.0+ only (the `local -A` index below), so the call site gates it on +# hook::_fast_fields_supported and a 3.2 shell runs jq instead. hook::_fast_fields() { local __hu_s="$1" shift local -a __hu_k1=() __hu_k2=() local __hu_f __hu_i __hu_n __hu_part __hu_body __hu_re __hu_raw __hu_val __hu_j local __hu_ident='[A-Za-z_][A-Za-z0-9_]*' + local __hu_cap=0 __hu_re="^\\.($__hu_ident)(\\.($__hu_ident))?\$" for __hu_f in "$@"; do [[ "$__hu_f" =~ $__hu_re ]] || return 2 __hu_k1+=("${BASH_REMATCH[1]}") __hu_k2+=("${BASH_REMATCH[3]}") + ((${#BASH_REMATCH[1]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[1]} + ((${#BASH_REMATCH[3]} > __hu_cap)) && __hu_cap=${#BASH_REMATCH[3]} done + # The skip bound: six bytes per character of the longest requested key name, + # the width of `\uXXXX`. A shorter bound proves the wrong thing rather than + # costing time: a key whose body exceeds it is never decoded, so a key that + # IS present is reported absent, and a key of 11 or more characters spelled + # entirely in \u escapes is missed the same way. + __hu_cap=$((__hu_cap * 6)) hook::_json_skeleton "$__hu_s" || return 2 [[ "$_HOOK_JSON_SK" == \{* ]] || return 2 # Every string body that decodes to a requested key name, by name: the part @@ -1099,7 +1124,7 @@ hook::_fast_fields() { __hu_n=${#_HOOK_JSON_PARTS[@]} for ((__hu_i = 1; __hu_i < __hu_n; __hu_i += 2)); do __hu_part=${_HOOK_JSON_PARTS[__hu_i]} - ((${#__hu_part} <= 60)) || continue + ((${#__hu_part} <= __hu_cap)) || continue __hu_body=${__hu_s:${_HOOK_JSON_OFF[__hu_i]}:${#__hu_part}} if [[ "$__hu_body" == *\\* ]]; then hook::json_unescape_to __hu_body "$__hu_body" || continue @@ -2110,7 +2135,10 @@ hook::jq_fields_uncached() { HOOK_JQ_FIELDS_NUL=0 (($#)) || return 1 command -v jq >/dev/null 2>&1 || return 1 - if hook::_fast_fields "$input" "$@"; then + # The floor first: hook::_fast_fields indexes with an associative array, which + # is Bash 4.0+. Below it the whole fast path is skipped and jq answers, rather + # than `local -A` failing per call on a shell these hooks support. + if hook::_fast_fields_supported && hook::_fast_fields "$input" "$@"; then return 0 fi HOOK_JQ_FIELDS=() From 915423ac49c84843e3f805d4c4d0587773a35738 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:04:28 -0400 Subject: [PATCH 14/15] docs(guardrails): name run-guards.sh as the chain slot's one exception The chain-slot paragraph asks a function plugged into _GAB_CONTINUE to be builtins only, never exit, and never touch the trap, and the slot's own comment says it must not return. run-guards.sh's consumer does none of that: run_guards::guard_died forks a subshell for the guards still owed, spawns jq to merge their documents, and ends at `builtin exit`. Say so. run-guards.sh is the one documented exception, and it is one because it is the dispatcher finishing the run the process owes rather than a hook doing exit-time work. The discipline is unchanged for everyone else, and the slot comment now matches it: a chained function that returns hands control back and the handler settles the status. Comment only. Co-Authored-By: Claude Fable 5.1 --- plugins/guardrails/hooks/abort-boundary.sh | 27 ++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/plugins/guardrails/hooks/abort-boundary.sh b/plugins/guardrails/hooks/abort-boundary.sh index 05a8ed2a6b..2511062fda 100644 --- a/plugins/guardrails/hooks/abort-boundary.sh +++ b/plugins/guardrails/hooks/abort-boundary.sh @@ -55,11 +55,21 @@ # naming EXIT in a registered hook or in a library it sources; this file is the # only one allowed to touch it. No hook needs exit-time work today (each is # builtins plus one jq read, nothing to clean up). The supported way for one -# that does is to chain through this library, not around it: add a chain slot -# here that guard::_abort_on_exit calls before it decides (the chained -# function under the same handler discipline: builtins only, never exits, -# never touches the trap), with a suite case beside the others, in the same -# change. A release on purpose goes through guard::abort_boundary_release. +# that does is to chain through this library, not around it: fill the chain slot +# below (_GAB_CONTINUE) that guard::_abort_on_exit calls before it decides, the +# chained function under the same handler discipline (builtins only, never +# exits, never touches the trap), with a suite case beside the others, in the +# same change. A release on purpose goes through guard::abort_boundary_release. +# +# run-guards.sh is the one documented exception to that discipline, and it is +# an exception because it is not a hook's exit-time work: its +# run_guards::guard_died is the DISPATCHER settling a dead guard and then +# finishing the run the process still owes, so it forks a subshell for the +# guards left, spawns jq to merge their documents, and ends at `builtin exit` +# without returning. Nothing is left for the handler to decide, which is why +# the slot may swallow control here and nowhere else. Any other consumer keeps +# the discipline above: a chained function that RETURNS, so that +# guard::_abort_on_exit still settles the status and exits. # # Under run-guards.sh the guards are sourced into the dispatcher's own shell, # and `exit` there is a function of the dispatcher's. A guard that ends through @@ -123,8 +133,11 @@ guard::_abort_json_escape_to() { # and this handler is what runs. With a function name here the handler hands # that guard's status to it instead of deciding the process's fate itself; the # dispatcher settles the guard's boundary, runs the guards still owed, and -# exits on the aggregate. The function must not return. Empty (every guard -# run alone), the handler decides as documented above. +# exits on the aggregate. run-guards.sh is the one consumer that does not +# return from here, and the header above says why it may; a chained function +# that DOES return hands control back and the handler settles the status as +# documented there. Empty (every guard run alone), the handler decides as +# documented above. _GAB_CONTINUE="" guard::_abort_on_exit() { From a1589257388aa5af9905f8a8ed30724b95a623e0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 16 Sep 2026 00:32:47 -0400 Subject: [PATCH 15/15] refactor(guardrails): prefix the locals of the out-parameter helpers Four `_to` helpers added on this branch kept unprefixed locals, breaking the reliability contract the file's header states: `printf -v` walks bash's dynamic scope outward, so a caller passing one of those names as the out-parameter would have its own variable left untouched while the callee's local took the value. No current call site collides; `out` is the file's dominant accumulator name, so a future one would. ps::_walk_quoted_spans_to, ps::fold_escaped_brace_closers_to, ps::call_site_operand_region_to and ps::blank_bracket_interiors_to now use `__wq_`, `__fb_`, `__cs_` and `__bb_` prefixes, matching ps::_gsub_to and the ps::_skip_*_to walkers. The globals they set are unchanged. The guard suite gains a shadowing self-check: each of the four is called with `out` as the destination from a scope holding its own `out` local, and the result has to match a reference call whose destination collides with nothing. Co-Authored-By: Claude Fable 5.1 --- .../hooks/block-dangerous-git.test.sh | 22 +++ .../guardrails/lib/powershell/ps-command.sh | 138 +++++++++--------- 2 files changed, 91 insertions(+), 69 deletions(-) diff --git a/plugins/guardrails/hooks/block-dangerous-git.test.sh b/plugins/guardrails/hooks/block-dangerous-git.test.sh index 808128eb15..5e25b2a815 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.test.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.test.sh @@ -1124,6 +1124,28 @@ pin_sink_trigger "classify: git -c section.key=cmd does not enter launcher sink" pin_sink_trigger "classify: \$out=pwsh \$script still enters launcher sink" \ '$out=pwsh $script' "launcher" +# --- A `_to` helper assigns the CALLER's variable, never its own local -------- +# `printf -v` walks bash's dynamic scope outward, so a helper whose own locals +# share a name with the destination the caller passed assigns that local and +# leaves the caller's variable untouched: a silent wrong answer rather than an +# error. `out` is this library's dominant accumulator name, so it is the +# destination a future caller is most likely to pass. The reference call names a +# variable no helper declares, so the two results have to agree. +ps_shadow_probe() { + local out="shadowed" + "$1" out "$2" + printf '%s' "$out" +} +# shellcheck disable=SC2016 +ps_shadow_input='x ${a`}b} ("y") w; q' +for ps_shadow_fn in ps::blank_quoted_spans_to ps::fold_escaped_brace_closers_to \ + ps::call_site_operand_region_to ps::blank_bracket_interiors_to; do + "$ps_shadow_fn" ps_shadow_ref "$ps_shadow_input" + # shellcheck disable=SC2154 # assigned indirectly, by the helper's `printf -v` + assert_eq "$ps_shadow_fn assigns the caller's out, not its own local" \ + "$ps_shadow_ref" "$(ps_shadow_probe "$ps_shadow_fn" "$ps_shadow_input")" +done + # --- #2662: fail-closed headlines must not assert a git command is present ----- # The sink is possibly-git (iex / computed call / computed launcher can fire with # no git token). Assert the softened headline on both the no-git-token path and a diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index 9bfa5ef0f4..b551a13948 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -431,8 +431,8 @@ ps::blank_herestrings() { # exempt: PowerShell gives them no escape at all, so a backtick inside one is an # ordinary character and the pairing is genuinely unambiguous. ps::_walk_quoted_spans_to() { - local text="$2" mode="$3" out="" i=0 n j q found c inner - n=${#text} + local __wq_text="$2" __wq_mode="$3" __wq_out="" __wq_i=0 __wq_n __wq_j __wq_q __wq_found __wq_c __wq_inner + __wq_n=${#__wq_text} # EXPANDABLE-STRING WITNESS. The walk is the only place that knows which quote # character OPENED a span, and that is exactly what tells an expandable string # from a verbatim one: a `"` inside a single-quoted span is never examined as an @@ -441,69 +441,69 @@ ps::_walk_quoted_spans_to() { # expandable string. Neither a raw `"` scan nor the opaque placeholder kind can # say that: `_q_` is shared by `'git'` and by `"git"`. PS_QUOTED_SPAN_SAW_EXPANDABLE=0 - while ((i < n)); do - q="${text:i:1}" - if [[ "$q" == "'" || "$q" == '"' ]]; then - [[ "$q" == '"' ]] && PS_QUOTED_SPAN_SAW_EXPANDABLE=1 - found=0 - for ((j = i + 1; j < n; j++)); do - c="${text:j:1}" - [[ "$c" == $'\n' ]] && break + while ((__wq_i < __wq_n)); do + __wq_q="${__wq_text:__wq_i:1}" + if [[ "$__wq_q" == "'" || "$__wq_q" == '"' ]]; then + [[ "$__wq_q" == '"' ]] && PS_QUOTED_SPAN_SAW_EXPANDABLE=1 + __wq_found=0 + for ((__wq_j = __wq_i + 1; __wq_j < __wq_n; __wq_j++)); do + __wq_c="${__wq_text:__wq_j:1}" + [[ "$__wq_c" == $'\n' ]] && break # Ambiguous escape context in a double-quoted span — stop looking and # fall through to the delete-nothing branch below. - if [[ "$q" == '"' && "$c" == '`' ]]; then - for (( ; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done + if [[ "$__wq_q" == '"' && "$__wq_c" == '`' ]]; then + for (( ; __wq_j < __wq_n; __wq_j++)); do [[ "${__wq_text:__wq_j:1}" == $'\n' ]] && break; done break fi - if [[ "$c" == "$q" ]]; then + if [[ "$__wq_c" == "$__wq_q" ]]; then # A DOUBLED quote is PowerShell's other escape for a delimiter # (`'it''s'`, `"say ""hi"""`), so this candidate closer may not be one. # Same resolution as the backtick: refuse the question, delete nothing. - if [[ "${text:j+1:1}" == "$q" ]]; then - for (( ; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done + if [[ "${__wq_text:__wq_j+1:1}" == "$__wq_q" ]]; then + for (( ; __wq_j < __wq_n; __wq_j++)); do [[ "${__wq_text:__wq_j:1}" == $'\n' ]] && break; done break fi - found=1 + __wq_found=1 break fi done - if ((found)); then - if [[ "$mode" == "opaque" ]]; then - inner="${text:i+1:j-i-1}" - if [[ -n "$inner" ]]; then - if [[ "$inner" == -* && "$q" == '"' && "$inner" == *'$'* ]]; then - out+='-_q_' - elif [[ "$q" == '"' && "$inner" == *'$'* ]]; then - out+="\$q" + if ((__wq_found)); then + if [[ "$__wq_mode" == "opaque" ]]; then + __wq_inner="${__wq_text:__wq_i+1:__wq_j-__wq_i-1}" + if [[ -n "$__wq_inner" ]]; then + if [[ "$__wq_inner" == -* && "$__wq_q" == '"' && "$__wq_inner" == *'$'* ]]; then + __wq_out+='-_q_' + elif [[ "$__wq_q" == '"' && "$__wq_inner" == *'$'* ]]; then + __wq_out+="\$q" else - out+='_q_' + __wq_out+='_q_' fi fi - elif [[ "$mode" == "cmpoperand" ]]; then - # `out` is both the result and the CONTEXT: every span already walked + elif [[ "$__wq_mode" == "cmpoperand" ]]; then + # `__wq_out` is both the result and the CONTEXT: every span already walked # is present in it (as `_q_` when blanked, verbatim when kept), so the # operand test reads the reduced prefix rather than the raw text — which # is what lets an earlier list element be skipped as one token. - if ps::_is_comparison_operand_context "$out"; then - out+='_q_' + if ps::_is_comparison_operand_context "$__wq_out"; then + __wq_out+='_q_' else - out+="${text:i:j-i+1}" + __wq_out+="${__wq_text:__wq_i:__wq_j-__wq_i+1}" fi fi - i=$((j + 1)) + __wq_i=$((__wq_j + 1)) continue fi # Unterminated (or escape-ambiguous) on this line: extent is ambiguous, so - # delete nothing. `j` already sits on the newline (or at the end), so copy + # delete nothing. `__wq_j` already sits on the newline (or at the end), so copy # the rest verbatim in one slice — this also keeps the walk linear. - out+="${text:i:j-i}" - i=$j + __wq_out+="${__wq_text:__wq_i:__wq_j-__wq_i}" + __wq_i=$__wq_j continue fi - out+="$q" - i=$((i + 1)) + __wq_out+="$__wq_q" + __wq_i=$((__wq_i + 1)) done - ps::_chomp_to "$1" "$out" + ps::_chomp_to "$1" "$__wq_out" } # Crude, SCAN-ONLY strip of single- and double-quoted spans, so that structural @@ -762,20 +762,20 @@ ps::opaque_quoted_spans_to() { # the real closer still terminates it — and removing a `{` other probes count # would be a change outside this finding. ps::fold_escaped_brace_closers_to() { - local s="$2" out="" i n ch - n=${#s} - for ((i = 0; i < n; i++)); do - ch="${s:i:1}" - if [[ "$ch" == '`' ]] && ((i + 1 < n)); then - case "${s:i+1:1}" in + local __fb_s="$2" __fb_out="" __fb_i __fb_n __fb_ch + __fb_n=${#__fb_s} + for ((__fb_i = 0; __fb_i < __fb_n; __fb_i++)); do + __fb_ch="${__fb_s:__fb_i:1}" + if [[ "$__fb_ch" == '`' ]] && ((__fb_i + 1 < __fb_n)); then + case "${__fb_s:__fb_i+1:1}" in '}') - out+='_' - i=$((i + 1)) + __fb_out+='_' + __fb_i=$((__fb_i + 1)) continue ;; '`') - out+='``' - i=$((i + 1)) + __fb_out+='``' + __fb_i=$((__fb_i + 1)) continue ;; *) @@ -784,9 +784,9 @@ ps::fold_escaped_brace_closers_to() { ;; esac fi - out+="$ch" + __fb_out+="$__fb_ch" done - ps::_chomp_to "$1" "$out" + ps::_chomp_to "$1" "$__fb_out" } # True (0) when the (quote-stripped) text carries a PowerShell construct the Bash @@ -981,27 +981,27 @@ ps::call_target_is_bare_subexpression() { # to end of string; the callers stay conservative on what they can still see, and # such a command does not parse in PowerShell to begin with. ps::call_site_operand_region_to() { - local s="$2" out="" i ch depth=0 - for ((i = 0; i < ${#s}; i++)); do - ch="${s:i:1}" - case "$ch" in + local __cs_s="$2" __cs_out="" __cs_i __cs_ch __cs_depth=0 + for ((__cs_i = 0; __cs_i < ${#__cs_s}; __cs_i++)); do + __cs_ch="${__cs_s:__cs_i:1}" + case "$__cs_ch" in '{' | '(') - depth=$((depth + 1)) + __cs_depth=$((__cs_depth + 1)) ;; '}' | ')') - ((depth == 0)) && break - depth=$((depth - 1)) + ((__cs_depth == 0)) && break + __cs_depth=$((__cs_depth - 1)) ;; ';' | '|' | '&') - ((depth == 0)) && break + ((__cs_depth == 0)) && break ;; *) # Ordinary operand text — copied through with no depth effect. ;; esac - out+="$ch" + __cs_out+="$__cs_ch" done - ps::_chomp_to "$1" "$out" + ps::_chomp_to "$1" "$__cs_out" } # Blank the INTERIOR of every balanced bracket group in a call's operand region, @@ -1011,27 +1011,27 @@ ps::call_site_operand_region_to() { # walk reaches on its own iteration, and the interior of `${script:Path}` holds no # operands at all. ps::blank_bracket_interiors_to() { - local s="$2" out="" i ch depth=0 - for ((i = 0; i < ${#s}; i++)); do - ch="${s:i:1}" - case "$ch" in + local __bb_s="$2" __bb_out="" __bb_i __bb_ch __bb_depth=0 + for ((__bb_i = 0; __bb_i < ${#__bb_s}; __bb_i++)); do + __bb_ch="${__bb_s:__bb_i:1}" + case "$__bb_ch" in '{' | '(') - out+="$ch" - depth=$((depth + 1)) + __bb_out+="$__bb_ch" + __bb_depth=$((__bb_depth + 1)) continue ;; '}' | ')') - ((depth > 0)) && depth=$((depth - 1)) - out+="$ch" + ((__bb_depth > 0)) && __bb_depth=$((__bb_depth - 1)) + __bb_out+="$__bb_ch" continue ;; *) # Ordinary text — kept at depth 0, blanked inside a group. ;; esac - if ((depth > 0)); then out+=" "; else out+="$ch"; fi + if ((__bb_depth > 0)); then __bb_out+=" "; else __bb_out+="$__bb_ch"; fi done - ps::_chomp_to "$1" "$out" + ps::_chomp_to "$1" "$__bb_out" } ps::computed_call_has_positional_write_signal() {