From 4d158183123012186824308ec593f49c8e0c929d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:00:42 -0400 Subject: [PATCH 1/8] perf(hooks): dispatch the guardrails guards through one process and if-gate the path-specific hooks Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011boRpMmkKhritwmR4ZPMvP --- plugins/actionlint/.claude-plugin/plugin.json | 2 +- plugins/actionlint/CHANGELOG.md | 9 + plugins/actionlint/hooks/hooks.json | 8 + .../bash-format/.claude-plugin/plugin.json | 2 +- plugins/bash-format/CHANGELOG.md | 8 + plugins/bash-format/hooks/hooks.json | 8 + .../biome-format/.claude-plugin/plugin.json | 2 +- plugins/biome-format/CHANGELOG.md | 9 + plugins/biome-format/hooks/hooks.json | 64 ++++++ .../disk-hygiene/.claude-plugin/plugin.json | 2 +- plugins/disk-hygiene/CHANGELOG.md | 9 + plugins/disk-hygiene/hooks/hooks.json | 1 + plugins/go-format/.claude-plugin/plugin.json | 2 +- plugins/go-format/CHANGELOG.md | 8 + plugins/go-format/hooks/hooks.json | 1 + plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 18 ++ .../hooks/block-windows-drive-tmp.test.sh | 1 + plugins/guardrails/hooks/hooks.json | 83 +------- plugins/guardrails/hooks/run-guards.sh | 195 ++++++++++++++++++ plugins/guardrails/hooks/run-guards.test.sh | 144 +++++++++++++ .../.claude-plugin/plugin.json | 2 +- plugins/instruction-placement/CHANGELOG.md | 8 + .../instruction-placement/hooks/hooks.json | 1 + .../.claude-plugin/plugin.json | 2 +- plugins/powershell-format/CHANGELOG.md | 8 + plugins/powershell-format/hooks/hooks.json | 15 ++ .../ruff-format/.claude-plugin/plugin.json | 2 +- plugins/ruff-format/CHANGELOG.md | 8 + plugins/ruff-format/hooks/hooks.json | 8 + .../source-control/.claude-plugin/plugin.json | 2 +- plugins/source-control/CHANGELOG.md | 11 + plugins/source-control/hooks/hooks.json | 3 + 33 files changed, 561 insertions(+), 87 deletions(-) create mode 100644 plugins/guardrails/hooks/run-guards.sh create mode 100644 plugins/guardrails/hooks/run-guards.test.sh diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index 268511462f..864d049313 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.26", + "version": "0.8.27", "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 8b1c960cfc..f032cf8a97 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,15 @@ 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.27] + +### Changed + +- **The hook carries `if` filters, `Edit(**/.github/workflows/*.yml)` and + `Edit(**/.github/workflows/*.yaml)`.** Those are the only paths the hook checks, so a + Write/Edit of any other file no longer spawns it; behavior on workflow files is + unchanged. + ## [0.8.26] ### Fixed diff --git a/plugins/actionlint/hooks/hooks.json b/plugins/actionlint/hooks/hooks.json index 821c6335c2..194b2e8ad9 100644 --- a/plugins/actionlint/hooks/hooks.json +++ b/plugins/actionlint/hooks/hooks.json @@ -7,6 +7,14 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/actionlint-check.sh", + "if": "Edit(**/.github/workflows/*.yml)", + "timeout": 15, + "statusMessage": "Checking workflow with actionlint..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/actionlint-check.sh", + "if": "Edit(**/.github/workflows/*.yaml)", "timeout": 15, "statusMessage": "Checking workflow with actionlint..." } diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index d1dd44bfa6..3fc83c94fc 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.27", + "version": "0.7.28", "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 886e222500..b45367f1e4 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,14 @@ 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.28] + +### Changed + +- **The hook carries `if` filters, `Edit(*.sh)` and `Edit(*.bash)`.** These are exactly + the extensions the hook formats, so a Write/Edit of any other file no longer spawns it; + behavior on shell scripts is unchanged. + ## [0.7.27] ### Fixed diff --git a/plugins/bash-format/hooks/hooks.json b/plugins/bash-format/hooks/hooks.json index 45109a6d34..f9d7b452bf 100644 --- a/plugins/bash-format/hooks/hooks.json +++ b/plugins/bash-format/hooks/hooks.json @@ -7,6 +7,14 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/bash-format.sh", + "if": "Edit(*.sh)", + "timeout": 15, + "statusMessage": "Formatting shell script..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/bash-format.sh", + "if": "Edit(*.bash)", "timeout": 15, "statusMessage": "Formatting shell script..." } diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index 2b42836381..2d6687eeda 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.26", + "version": "0.6.27", "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 7c67afcfa4..49e9ce26d4 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,15 @@ 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.27] + +### Changed + +- **The hook carries one `if` filter per extension it formats** (`Edit(*.ts)`, + `Edit(*.tsx)`, `Edit(*.js)`, `Edit(*.jsx)`, `Edit(*.mjs)`, `Edit(*.cjs)`, `Edit(*.mts)`, + `Edit(*.cts)`, `Edit(*.json)`, `Edit(*.jsonc)`), so a Write/Edit of any other file no + longer spawns it; behavior on those files is unchanged. + ## [0.6.26] ### Fixed diff --git a/plugins/biome-format/hooks/hooks.json b/plugins/biome-format/hooks/hooks.json index daa1fe87aa..741ff95ced 100644 --- a/plugins/biome-format/hooks/hooks.json +++ b/plugins/biome-format/hooks/hooks.json @@ -7,6 +7,70 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.ts)", + "timeout": 15, + "statusMessage": "Formatting with Biome..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.tsx)", + "timeout": 15, + "statusMessage": "Formatting with Biome..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.js)", + "timeout": 15, + "statusMessage": "Formatting with Biome..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.jsx)", + "timeout": 15, + "statusMessage": "Formatting with Biome..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.mjs)", + "timeout": 15, + "statusMessage": "Formatting with Biome..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.cjs)", + "timeout": 15, + "statusMessage": "Formatting with Biome..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.mts)", + "timeout": 15, + "statusMessage": "Formatting with Biome..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.cts)", + "timeout": 15, + "statusMessage": "Formatting with Biome..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.json)", + "timeout": 15, + "statusMessage": "Formatting with Biome..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/biome-format.sh", + "if": "Edit(*.jsonc)", "timeout": 15, "statusMessage": "Formatting with Biome..." } diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index 2dedc046bd..5b16251fca 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "disk-hygiene", - "version": "0.21.0", + "version": "0.21.1", "description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.", "author": { "name": "Melodic Software", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index a2fbbbc84c..fd35482154 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.21.1] + +### Changed + +- **The engine gate carries an `if` filter, `Bash(*hygiene.py*)`.** The gate only ever + judges a command that carries the engine's file name (`_engine_gate_relevant`), so the + filter is a superset of its own relevance check; every other Bash call no longer pays + a Python interpreter start to be told it is irrelevant. + ## [0.21.0] ### Changed diff --git a/plugins/disk-hygiene/hooks/hooks.json b/plugins/disk-hygiene/hooks/hooks.json index 725a9a883a..0fb306dabd 100644 --- a/plugins/disk-hygiene/hooks/hooks.json +++ b/plugins/disk-hygiene/hooks/hooks.json @@ -7,6 +7,7 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", + "if": "Bash(*hygiene.py*)", "shell": "bash", "timeout": 60 } diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index 67ff3bc9fa..0fa6767704 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.29", + "version": "0.3.30", "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 e7c9dc1410..523b9fb9f6 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,14 @@ 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.30] + +### Changed + +- **The hook carries an `if` filter, `Edit(*.go)`.** That is the one extension the hook + formats, so a Write/Edit of any other file no longer spawns it; behavior on Go files + is unchanged. + ## [0.3.29] ### Fixed diff --git a/plugins/go-format/hooks/hooks.json b/plugins/go-format/hooks/hooks.json index 2b5ee942da..5ceea04939 100644 --- a/plugins/go-format/hooks/hooks.json +++ b/plugins/go-format/hooks/hooks.json @@ -7,6 +7,7 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/go-format.sh", + "if": "Edit(*.go)", "timeout": 15, "statusMessage": "Formatting Go imports..." } diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 27c08d0702..b36f7834d5 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.30.0" + "version": "0.31.0" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index cd1cd967ca..510662d981 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,24 @@ 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.31.0] + +### Changed + +- **One hook process per event instead of one per guard.** The always-on guards are + now registered through `hooks/run-guards.sh`, a dispatcher that reads stdin once, + extracts the payload fields with one `jq` process, and sources each guard in turn + inside that one bash process. Per Bash/PowerShell call this is one hook process + where there were eight; per Write/Edit it is one where there were three on + PreToolUse and one where there were three on PostToolUse. Every guard still ships as + its own script with its own contract test, kill switch, and telemetry envelope, and + decides exactly as before; the dispatcher owns only the spawn shape, the exit-code + aggregation (2 wins, every guard still runs), and the merge of several guards' + `additionalContext` into the one JSON document a hook process may emit. Measured on + the reference Windows host with a benign `git status` payload the per-Bash-call set + fell from ≈ 3.0 s of summed hook time to under 1 s; the hook-budget accounting in the + README carries the per-guard figures. + ## [0.30.0] ### Fixed diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh index b9a40410c1..4880b496c1 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.test.sh @@ -235,6 +235,7 @@ reg_cmd=$(jq -r --arg h "block-windows-drive-tmp.sh" ' [ .hooks.PreToolUse[].hooks[].command | select(contains($h)) ] | first // ""' \ "$HOOKS_JSON" 2>/dev/null) reg_rel="${reg_cmd##*\"/}" +reg_rel="${reg_rel%% *}" # the dispatcher form carries the guard as an argument assert_eq "the registered command resolves to a file on disk" "yes" \ "$([[ -n "$reg_rel" && -f "$HOOK_DIR/../$reg_rel" ]] && echo yes || echo no)" diff --git a/plugins/guardrails/hooks/hooks.json b/plugins/guardrails/hooks/hooks.json index e16072f17a..1597c568d0 100644 --- a/plugins/guardrails/hooks/hooks.json +++ b/plugins/guardrails/hooks/hooks.json @@ -1,31 +1,14 @@ { "hooks": { "PreToolUse": [ - { - "matcher": "Write|Edit|NotebookEdit", - "hooks": [ - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/secret-pattern-detection.sh", - "timeout": 60, - "statusMessage": "Checking for secrets..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/hardcoded-path-check.sh", - "timeout": 60, - "statusMessage": "Checking for hardcoded paths..." - } - ] - }, { "matcher": "Write|Edit|MultiEdit|NotebookEdit", "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-windows-drive-tmp.sh", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-guards.sh secret-pattern-detection.sh hardcoded-path-check.sh block-windows-drive-tmp.sh", "timeout": 60, - "statusMessage": "Checking for Windows drive-root /tmp writes..." + "statusMessage": "Checking for secrets, hardcoded paths, and Windows drive-root /tmp writes..." } ] }, @@ -34,51 +17,9 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-no-verify.sh", - "timeout": 60, - "statusMessage": "Checking for --no-verify bypass..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-dangerous-git.sh", - "timeout": 60, - "statusMessage": "Checking for dangerous git commands..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-hook-bypass.sh", - "timeout": 60, - "statusMessage": "Checking for hook-bypass attempts..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/flag-commit-pr-skill-bypass.sh", - "timeout": 60, - "statusMessage": "Checking commit/PR skill usage..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-noncanonical-commit.sh", - "timeout": 60, - "statusMessage": "Checking commit-message convention..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-convention-violation.sh", - "timeout": 60, - "statusMessage": "Checking commit subject / PR title against the team convention..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-windows-drive-tmp.sh", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-guards.sh --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", "timeout": 60, - "statusMessage": "Checking for Windows drive-root /tmp writes..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/block-exported-msys-pathconv.sh", - "timeout": 60, - "statusMessage": "Checking for an exported MSYS path-conversion suppressor..." + "statusMessage": "Checking the command against the git, hook-bypass, commit-convention, and Windows path guards..." } ] }, @@ -100,21 +41,9 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/cli-flag-verify.sh", - "timeout": 30, - "statusMessage": "Verifying CLI flags..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/skill-reference-verify.sh", - "timeout": 30, - "statusMessage": "Verifying skill references..." - }, - { - "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/stale-path-verify.sh", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-guards.sh cli-flag-verify.sh skill-reference-verify.sh stale-path-verify.sh", "timeout": 30, - "statusMessage": "Verifying cited paths..." + "statusMessage": "Verifying CLI flags, skill references, and cited paths..." } ] } diff --git a/plugins/guardrails/hooks/run-guards.sh b/plugins/guardrails/hooks/run-guards.sh new file mode 100644 index 0000000000..3f99bd2e03 --- /dev/null +++ b/plugins/guardrails/hooks/run-guards.sh @@ -0,0 +1,195 @@ +#!/usr/bin/env bash +# Dispatcher: run several guardrails guards for ONE hook event inside ONE hook +# process, instead of registering each guard as its own always-on hook. +# +# run-guards.sh [--lib ]... ... +# +# Every guard named on the command line still ships as its own script with its +# own contract test, kill switch, and telemetry envelope; nothing about a guard's +# decision logic lives here. What this file owns is the per-event spawn shape +# (#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. +# +# HOW A GUARD RUNS UNCHANGED INSIDE ONE PROCESS +# +# * stdin is read and validated ONCE (hook::buffer_stdin), then every guard's +# own `hook::buffer_stdin` call is answered from that buffer with the same +# return code the guard would have seen on its own (0 payload, 1 empty, +# 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. +# +# AGGREGATION (the one deliberate delta from N separate hooks) +# +# * Exit: 2 if any guard exited 2 (block); else the highest non-zero code any +# 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 +# document was delivered on its own. +# +# The overrides are scoped to this process: a guard run directly (its tests, +# `bash hooks/.sh`) uses the library functions untouched. + +set -uo pipefail + +# shellcheck source=hook-utils.sh +source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$HOOK_DIR/.." && pwd)}" + +# Every guard opens with `source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh"`. +# `dirname` is an external program, and on Windows Git Bash one exec is ~80 ms, +# paid once per guard. A shell function with the same answer for the one +# argument shape the guards use (a path with a directory part) removes that +# exec from every guard sourced below; the `source` it feeds returns at once on +# the library's double-source guard anyway. +dirname() { + local p="${1%/}" + if [[ "$p" == */* ]]; then printf '%s\n' "${p%/*}"; else printf '.\n'; fi +} + +GUARDS=() +while (($#)); do + case "$1" in + --lib) + # A library several guards source (the PowerShell classifier). Its own + # double-source guard makes every later `source` a no-op, so the parse is + # paid once here instead of once per guard. + # shellcheck disable=SC1090 + source "$PLUGIN_ROOT/$2" + shift 2 + ;; + *) + GUARDS+=("$1") + shift + ;; + esac +done +((${#GUARDS[@]})) || exit 0 + +# --- stdin once --------------------------------------------------------------- +RUN_GUARDS_STDIN_RC=0 +RUN_GUARDS_INPUT=$(hook::buffer_stdin) || RUN_GUARDS_STDIN_RC=$? +# Nothing arrived: every guard would take its empty-stdin skip. Take it once. +((RUN_GUARDS_STDIN_RC == 1)) && exit 0 + +# shellcheck disable=SC2329 # invoked by every guard sourced below +hook::buffer_stdin() { + ((RUN_GUARDS_STDIN_RC == 0)) || return "$RUN_GUARDS_STDIN_RC" + printf '%s' "$RUN_GUARDS_INPUT" +} + +# --- jq once ------------------------------------------------------------------ +# Keep the library's implementation reachable under another name so the cache +# miss path is the library's own code, not a re-implementation of it. +eval "$(declare -f hook::jq_fields | sed '1s/^hook::jq_fields/hook::jq_fields_uncached/')" + +RUN_GUARDS_PRIMED=0 +RUN_GUARDS_FILTERS=() +RUN_GUARDS_VALUES=() +PRIME_FILTERS=( + '.tool_input.command' '.tool_name' '.cwd' + '.tool_input.file_path' '.tool_input.notebook_path' + '.tool_input.content' '.tool_input.new_string' '.tool_input.new_source' +) +if ((RUN_GUARDS_STDIN_RC == 0)) && hook::jq_fields_uncached "$RUN_GUARDS_INPUT" "${PRIME_FILTERS[@]}" && + ((HOOK_JQ_FIELDS_NUL == 0)); then + RUN_GUARDS_PRIMED=1 + RUN_GUARDS_FILTERS=("${PRIME_FILTERS[@]}") + RUN_GUARDS_VALUES=("${HOOK_JQ_FIELDS[@]}") +fi + +# shellcheck disable=SC2329 # invoked by every guard sourced below +hook::jq_fields() { + local input="$1" + shift + (($#)) || return 1 + if ((RUN_GUARDS_PRIMED)) && [[ "$input" == "$RUN_GUARDS_INPUT" ]]; then + local -a out=() + local filter i hit + for filter in "$@"; do + hit=0 + for i in "${!RUN_GUARDS_FILTERS[@]}"; do + if [[ "${RUN_GUARDS_FILTERS[i]}" == "$filter" ]]; then + out+=("${RUN_GUARDS_VALUES[i]}") + hit=1 + break + fi + done + ((hit)) || break + done + if ((${#out[@]} == $#)); then + HOOK_JQ_FIELDS=("${out[@]}") + HOOK_JQ_FIELDS_NUL=0 + return 0 + fi + fi + hook::jq_fields_uncached "$input" "$@" +} + +# --- 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 + fi + rc=0 + t0=${EPOCHREALTIME:-0} + # shellcheck disable=SC1090 + guard_out=$(source "$path" &2 + fi + [[ -n "$guard_out" ]] && OUTS+=("$guard_out") + if ((rc == 2)); then + RC=2 + elif ((rc != 0 && RC != 2 && rc > RC)); then + RC=$rc + fi +done + +if ((${#OUTS[@]} == 1)); then + printf '%s\n' "${OUTS[0]}" +elif ((${#OUTS[@]} > 1)); then + 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) && [[ -n "$merged" ]] && + printf '%s\n' "$merged" || + printf '%s\n' "${OUTS[@]}" +fi + +exit "$RC" diff --git a/plugins/guardrails/hooks/run-guards.test.sh b/plugins/guardrails/hooks/run-guards.test.sh new file mode 100644 index 0000000000..b4c66e2946 --- /dev/null +++ b/plugins/guardrails/hooks/run-guards.test.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Contract tests for hooks/run-guards.sh, the one-process dispatcher that runs +# several guards for one hook event. The guards' own decisions are covered by +# their own *.test.sh; this file covers what the dispatcher owns: stdin read +# once and re-served with the same rc, jq answered from one cache with a +# byte-identical fallback, every guard run to completion, exit aggregation, +# and the merge of several stdout documents into one. +set -uo pipefail + +TEST_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/run-guards-test.XXXXXX")" +trap 'rm -rf "$TEST_TMPDIR"' EXIT + +HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DISPATCH="$HOOK_DIR/run-guards.sh" +# shellcheck source=guardrails-test-helpers.sh +source "$HOOK_DIR/guardrails-test-helpers.sh" + +export CLAUDE_PLUGIN_ROOT="$HOOK_DIR/.." +export CLAUDE_PLUGIN_DATA="$TEST_TMPDIR/data" + +if ! command -v jq >/dev/null 2>&1; then + echo "FAIL: jq is required for these tests" >&2 + exit 1 +fi + +# Stub guards. Each one sources the real library exactly as a shipped guard +# does, so the dispatcher's overrides are exercised through the same seam. +stub() { + local name="$1" body="$2" + { + printf '#!/usr/bin/env bash\nset -uo pipefail\nsource "%s/hook-utils.sh"\n' "$HOOK_DIR" + printf '%s\n' "$body" + } >"$TEST_TMPDIR/$name" + chmod +x "$TEST_TMPDIR/$name" +} +SEEN="$TEST_TMPDIR/seen" +stub allow.sh 'INPUT=$(hook::buffer_stdin) || { rc=$?; ((rc == 2)) && exit 2; exit 0; } +hook::jq_fields "$INPUT" ".tool_input.command" ".tool_name" || exit 0 +printf "%s\n" "${HOOK_JQ_FIELDS[@]}" >>"'"$SEEN"'" +exit 0' +stub block.sh 'echo "BLOCKED: stub" >&2; exit 2' +stub ctx1.sh 'hook::emit_channels PreToolUse "ctx one" ""; exit 0' +stub ctx2.sh 'hook::emit_channels PreToolUse "ctx two" "sys two"; exit 0' +stub crash.sh 'exit 3' +stub nul.sh 'INPUT=$(hook::buffer_stdin) || exit 0 +hook::jq_fields "$INPUT" ".tool_input.command" || exit 0 +printf "nul=%s cmd=%s\n" "$HOOK_JQ_FIELDS_NUL" "$HOOK_JQ_FIELDS" >>"'"$SEEN"'"' +stub miss.sh 'INPUT=$(hook::buffer_stdin) || exit 0 +hook::jq_fields "$INPUT" ".session_id" ".tool_name" || exit 0 +printf "%s\n" "${HOOK_JQ_FIELDS[@]}" >>"'"$SEEN"'"' +stub lib.sh 'printf "ps=%s\n" "${_GUARDRAILS_PS_COMMAND_LOADED:-unset}" >>"'"$SEEN"'"' + +PAYLOAD=$(jq -n '{session_id:"s-1",tool_name:"Bash",cwd:"/x",tool_input:{command:"git status --short"}}') + +run() { # run ... -> stdout captured, stderr to $ERR, rc in $RC + local input="$1" + shift + : >"$SEEN" + RC=0 + OUT=$(bash "$DISPATCH" "$@" <<<"$input" 2>"$TEST_TMPDIR/err") || RC=$? + ERR=$(cat "$TEST_TMPDIR/err") +} + +# --- benign payload, one allowing guard --------------------------------------- +run "$PAYLOAD" "$TEST_TMPDIR/allow.sh" +assert_exit "allow-only exits 0" 0 "$RC" +assert_silent "allow-only prints nothing" "$OUT$ERR" +assert_eq "guard read its fields from the shared cache" \ + $'git status --short\nBash' "$(cat "$SEEN")" + +# --- a block does not stop the later guards, and wins the exit code ---------- +run "$PAYLOAD" "$TEST_TMPDIR/block.sh" "$TEST_TMPDIR/allow.sh" +assert_exit "block wins the exit code" 2 "$RC" +assert_contains "block reason reaches stderr" "$ERR" "BLOCKED: stub" +assert_eq "the guard after the block still ran" $'git status --short\nBash' "$(cat "$SEEN")" + +# --- exit aggregation: a non-block failure surfaces, 2 still dominates ------ +run "$PAYLOAD" "$TEST_TMPDIR/crash.sh" "$TEST_TMPDIR/allow.sh" +assert_exit "highest non-block code surfaces" 3 "$RC" +run "$PAYLOAD" "$TEST_TMPDIR/crash.sh" "$TEST_TMPDIR/block.sh" +assert_exit "2 dominates a higher non-block code" 2 "$RC" + +# --- stdout: one emitter passes through verbatim ----------------------------- +standalone=$(bash "$TEST_TMPDIR/ctx1.sh" <<<"$PAYLOAD") +run "$PAYLOAD" "$TEST_TMPDIR/ctx1.sh" +assert_eq "single emitter is passed through verbatim" "$standalone" "$OUT" + +# --- stdout: several emitters merge into ONE document ------------------------ +run "$PAYLOAD" "$TEST_TMPDIR/ctx1.sh" "$TEST_TMPDIR/allow.sh" "$TEST_TMPDIR/ctx2.sh" +assert_exit "merge run exits 0" 0 "$RC" +assert_eq "merged output is exactly one JSON document" "1" "$(jq -s 'length' <<<"$OUT")" +merged_ctx=$(jq -r '.hookSpecificOutput.additionalContext' <<<"$OUT") +assert_eq "merged additionalContext carries both guards, in order" $'ctx one\n\nctx two' "$merged_ctx" +assert_eq "merged hookEventName kept" "PreToolUse" "$(jq -r '.hookSpecificOutput.hookEventName' <<<"$OUT")" +assert_eq "merged systemMessage carries the one guard that set it" "sys two" "$(jq -r '.systemMessage' <<<"$OUT")" + +# --- stdin posture is re-served, not re-read --------------------------------- +run "" "$TEST_TMPDIR/allow.sh" "$TEST_TMPDIR/block.sh" +assert_exit "empty stdin: every guard's empty-stdin skip, taken once" 0 "$RC" +assert_silent "empty stdin prints nothing" "$OUT$ERR" +run "not json" "$TEST_TMPDIR/allow.sh" +assert_exit "malformed stdin: a fail-closed guard's rc-2 path is reached" 2 "$RC" +assert_contains "malformed stdin names the reason once" "$ERR" "not valid JSON" +assert_eq "malformed-stdin reason printed exactly once" "1" "$(grep -c 'not valid JSON' <<<"$ERR")" + +# --- jq cache: a NUL-bearing payload bypasses the cache ---------------------- +nul_payload=$(jq -n '{tool_name:"Bash",tool_input:{command:("git " + ([0] | implode) + "x")}}') +run "$nul_payload" "$TEST_TMPDIR/nul.sh" +assert_eq "NUL flag reaches the guard through the real jq path" "nul=1 cmd=git x" "$(cat "$SEEN")" + +# --- jq cache: an un-primed filter falls through to the library -------------- +run "$PAYLOAD" "$TEST_TMPDIR/miss.sh" +assert_eq "cache miss serves the right values" $'s-1\nBash' "$(cat "$SEEN")" + +# --- --lib preloads a shared library once ------------------------------------ +run "$PAYLOAD" --lib lib/powershell/ps-command.sh "$TEST_TMPDIR/lib.sh" +assert_eq "--lib library is loaded before the guards run" "ps=1" "$(cat "$SEEN")" + +# --- an unknown guard is reported, the rest still run ------------------------ +run "$PAYLOAD" "$TEST_TMPDIR/nope.sh" "$TEST_TMPDIR/allow.sh" +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")" + +# --- a real guard decides the same inside the dispatcher as alone ------------ +bypass=$(command_json 'git commit --no-verify -m x') +alone_rc=0 +alone_err=$(bash "$HOOK_DIR/block-no-verify.sh" <<<"$bypass" 2>&1 >/dev/null) || alone_rc=$? +run "$bypass" --lib lib/powershell/ps-command.sh block-no-verify.sh block-dangerous-git.sh +assert_exit "real guard blocks through the dispatcher" 2 "$RC" +assert_eq "real guard: same exit alone and dispatched" "$alone_rc" "$RC" +assert_eq "real guard: same stderr alone and dispatched" "$alone_err" "$ERR" + +# --- hooks.json wires every guard through the dispatcher by file name --------- +for g in secret-pattern-detection hardcoded-path-check 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 \ + cli-flag-verify skill-reference-verify stale-path-verify; do + n=$(jq -r --arg g "$g.sh" '[.hooks[][] | .hooks[] | .command | select(contains("run-guards.sh") and contains(" " + $g))] | length' "$HOOK_DIR/hooks.json") + if ((n > 0)); then ok "hooks.json dispatches $g"; else bad "hooks.json does not dispatch $g"; fi + [[ -f "$HOOK_DIR/$g.sh" ]] && ok "$g.sh exists on disk" || bad "$g.sh missing on disk" +done + +report diff --git a/plugins/instruction-placement/.claude-plugin/plugin.json b/plugins/instruction-placement/.claude-plugin/plugin.json index 878456468a..da649a7cbc 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.11.16", + "version": "0.11.17", "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 \u2014 conventions keyed to one file type or one subtree sitting in an always-loaded CLAUDE.md or AGENTS.md \u2014 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 and deferred surfaces are invisible inside subagents and absent after compaction until re-triggered. Every accepted move regenerates an always-loaded index of deferred surfaces, which is what keeps a demoted rule reachable from a subagent that never receives its injection. 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 \u2014 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 9f9fbfaad9..56540c7821 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -3,6 +3,14 @@ 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.11.17] + +### Changed + +- **`index-drift` carries an `if` filter, `Edit(**/.claude/rules/*.md)`.** The hook only + ever acts on a rule file under `.claude/rules/`, which is also its own first check, so + every other Write/Edit no longer spawns it. + ## [0.11.16] ### Changed diff --git a/plugins/instruction-placement/hooks/hooks.json b/plugins/instruction-placement/hooks/hooks.json index d9914df50b..c56ae9ebd8 100644 --- a/plugins/instruction-placement/hooks/hooks.json +++ b/plugins/instruction-placement/hooks/hooks.json @@ -7,6 +7,7 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/index-drift.sh", + "if": "Edit(**/.claude/rules/*.md)", "timeout": 10, "statusMessage": "Checking rules index..." } diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index e4fe9c9d07..c538e4fcfa 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.29", + "version": "0.7.30", "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 659787bc39..6c99f4a674 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,14 @@ 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.30] + +### Changed + +- **The hook carries `if` filters, `Edit(*.ps1)`, `Edit(*.psm1)` and `Edit(*.psd1)`.** + These are exactly the extensions the hook formats, so a Write/Edit of any other file no + longer spawns it; behavior on PowerShell files is unchanged. + ## [0.7.29] ### Fixed diff --git a/plugins/powershell-format/hooks/hooks.json b/plugins/powershell-format/hooks/hooks.json index 3376a28cc9..9ab19b244e 100644 --- a/plugins/powershell-format/hooks/hooks.json +++ b/plugins/powershell-format/hooks/hooks.json @@ -7,6 +7,21 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/powershell-format.sh", + "if": "Edit(*.ps1)", + "timeout": 30, + "statusMessage": "Formatting PowerShell..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/powershell-format.sh", + "if": "Edit(*.psm1)", + "timeout": 30, + "statusMessage": "Formatting PowerShell..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/powershell-format.sh", + "if": "Edit(*.psd1)", "timeout": 30, "statusMessage": "Formatting PowerShell..." } diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index 7b1c8ab66b..1d71abd57e 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.27", + "version": "0.6.28", "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 3a90c51cc6..51b87fd320 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,14 @@ 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.28] + +### Changed + +- **The hook carries `if` filters, `Edit(*.py)` and `Edit(*.pyi)`.** These are exactly + the extensions the hook formats, so a Write/Edit of any other file no longer spawns it; + behavior on Python files is unchanged. + ## [0.6.27] ### Fixed diff --git a/plugins/ruff-format/hooks/hooks.json b/plugins/ruff-format/hooks/hooks.json index 1592a6bae0..c954b9f573 100644 --- a/plugins/ruff-format/hooks/hooks.json +++ b/plugins/ruff-format/hooks/hooks.json @@ -7,6 +7,14 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/ruff-format.sh", + "if": "Edit(*.py)", + "timeout": 15, + "statusMessage": "Formatting Python with Ruff..." + }, + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/ruff-format.sh", + "if": "Edit(*.pyi)", "timeout": 15, "statusMessage": "Formatting Python with Ruff..." } diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 40d3e2bdd8..37acfab9dd 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.35", + "version": "0.55.36", "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 \u2014 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 \u2014 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 \u2014 interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep \u2014 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 14835d6304..263bb76ab5 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,17 @@ 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.36] + +### Changed + +- **The Bash gates carry `if` filters, so they spawn only for the commands they judge.** + `pr-body-linkage-gate` runs under `Bash(gh *)`, and `worktree-add-containment-gate` + and `worktree-add-claim-gate` under `Bash(*worktree*)`; each filter is a superset of + the gate's own first check (`gh` on the command line; `worktree` in the command), so + no command the gate would have judged is skipped. A plain `git status` no longer + costs three hook processes. + ## [0.55.35] ### Changed diff --git a/plugins/source-control/hooks/hooks.json b/plugins/source-control/hooks/hooks.json index 1f343f91c4..f0fd832e82 100644 --- a/plugins/source-control/hooks/hooks.json +++ b/plugins/source-control/hooks/hooks.json @@ -7,12 +7,14 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/pr-body-linkage-gate.sh", + "if": "Bash(gh *)", "timeout": 15, "statusMessage": "Checking PR body against the repo's pr-issue-linkage gate..." }, { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/worktree-add-containment-gate.sh", + "if": "Bash(*worktree*)", "timeout": 15, "statusMessage": "Checking git worktree add target against the nesting invariant..." } @@ -37,6 +39,7 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/worktree-add-claim-gate.sh", + "if": "Bash(*worktree*)", "timeout": 15, "statusMessage": "Claiming a newly added worktree..." } From a423e2c0508a2840e34fcb00cc330a09597e549d Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:03:41 -0400 Subject: [PATCH 2/8] docs(hooks): record the dispatcher and the if gates in the plugin READMEs and changelogs Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011boRpMmkKhritwmR4ZPMvP --- plugins/actionlint/README.md | 4 ++- plugins/bash-format/README.md | 3 +++ plugins/biome-format/README.md | 4 +++ plugins/disk-hygiene/README.md | 4 ++- plugins/go-format/README.md | 2 ++ plugins/guardrails/README.md | 30 +++++++++++++++++++++ plugins/guardrails/hooks/run-guards.sh | 9 +++++-- plugins/guardrails/hooks/run-guards.test.sh | 1 + plugins/powershell-format/README.md | 3 +++ plugins/ruff-format/README.md | 3 +++ plugins/source-control/README.md | 13 +++++++++ 11 files changed, 72 insertions(+), 4 deletions(-) diff --git a/plugins/actionlint/README.md b/plugins/actionlint/README.md index a45879e890..2aa2815b2c 100644 --- a/plugins/actionlint/README.md +++ b/plugins/actionlint/README.md @@ -16,7 +16,9 @@ your `PATH`. via `additionalContext`; they never reject the edit. Make a commit hook or CI your hard gate. - **Scoped to workflows.** Only files matching `.github/workflows/*.yml` and - `.github/workflows/*.yaml` are linted. Other YAML is left alone. + `.github/workflows/*.yaml` are linted. Other YAML is left alone. The registration + carries the matching `if` filters (`Edit(**/.github/workflows/*.yml)` and the + `.yaml` twin), so a Write/Edit of any other file never starts a hook process for it. - **External run-block linters disabled (`-shellcheck= -pyflakes=`).** actionlint's embedded-bash ShellCheck and `shell: python` pyflakes integrations are turned off. Each spawns a subprocess per `run:` block. diff --git a/plugins/bash-format/README.md b/plugins/bash-format/README.md index 82472a2c86..2a2c2a7c6e 100644 --- a/plugins/bash-format/README.md +++ b/plugins/bash-format/README.md @@ -11,6 +11,9 @@ and `.editorconfig` for formatting. It ships no rules of its own. ## Behavior +- **Spawned only for shell files.** The hook is registered with the `if` filters + `Edit(*.sh)` and `Edit(*.bash)`, so a Write/Edit of any other file never starts a + hook process for it; the extension check inside the script is unchanged. - **Lint on edit (always).** ShellCheck (`warning` severity and above) runs on every edit. It is non-mutating; it only reports. - **Format on edit (opt-in).** `shfmt` runs **only when an `.editorconfig` diff --git a/plugins/biome-format/README.md b/plugins/biome-format/README.md index cd311025ee..150f9d49aa 100644 --- a/plugins/biome-format/README.md +++ b/plugins/biome-format/README.md @@ -12,6 +12,10 @@ runs only when your repo has opted into Biome. ## Behavior +- **Spawned only for the files it formats.** The hook is registered with one `if` + filter per extension (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts`, + `.json`, `.jsonc`), so a Write/Edit of any other file never starts a hook process + for it; the extension check inside the script is unchanged. - **Opt-in on `biome.json`.** Biome runs **only when a `biome.json` or `biome.jsonc` governs the edited file**, found by walking up from the file to the repository root. A repo without a Biome config is left untouched rather diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index 11f82cc634..6d95f251b2 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -92,7 +92,9 @@ enforces nothing. The launcher resolves Python itself instead (#1504). The guard registers on two surfaces: a plugin-level **engine gate** (`hooks/hooks.json`) that acts only on commands referencing the engine, deferring everything else instantly, and enforces the kill -switch and data-root authority; and the skill-scoped **belt** inside the `clean` skill's context, +switch and data-root authority (since **0.21.1** the registration also carries the `if` filter +`Bash(*hygiene.py*)`, a superset of the gate's own relevance check, so a command that does not +name the engine no longer spawns the Python interpreter to be deferred); and the skill-scoped **belt** inside the `clean` skill's context, which adds the deny-by-default Bash and deletion-spelling PowerShell discipline during active cleanup work. Both surfaces resolve the kill switch by reading `disk_hygiene_enabled` from user-scope `pluginConfigs` in `settings.json` (located from `${CLAUDE_PLUGIN_ROOT}`, honored only diff --git a/plugins/go-format/README.md b/plugins/go-format/README.md index ccd3af0dc0..36479383b8 100644 --- a/plugins/go-format/README.md +++ b/plugins/go-format/README.md @@ -17,6 +17,8 @@ surfaces the syntax error back to Claude as advisory context. unconfigured. Running it does not impose a style choice a repo hasn't made, the same reasoning that makes `gofmt` itself safe to run unconditionally. +- **Spawned only for Go files.** The hook is registered with the `if` filter + `Edit(*.go)`, so a Write/Edit of any other file never starts a hook process for it. - **Extension-scoped.** Only `.go` files trigger the hook (like `ruff-format`'s `*.py`/`*.pyi` filter; unlike `typos-format`'s language-agnostic scope). diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 7fa157f1b0..69866980ec 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -22,6 +22,21 @@ Each guard is independently toggleable, so you run exactly the subset you want. ## The guards +Since **0.31.0** the always-on guards are registered through one dispatcher per event, +`hooks/run-guards.sh`, which reads the payload once, extracts its fields with one `jq` +process, and sources each guard in turn inside that one bash process. The table below +still names every guard, and every guard still ships as its own script with its own +contract test, kill switch, and telemetry envelope, deciding exactly as it did as a +standalone hook. `hooks/hooks.json` lists each guard by file name as an argument of the +dispatcher line for its event, so the registration stays readable per guard. What the +dispatcher owns: the spawn shape (one hook process per Bash/PowerShell call where there +were eight, one per Write/Edit PreToolUse where there were three, one per Write/Edit +PostToolUse where there were three), the exit code (2 if any guard blocks, and every +guard still runs so a command that trips two guards shows both reasons), and the merge +of several guards' `additionalContext` into the one JSON document a hook process may +emit. The [hook budget accounting](#hook-budget-accounting) carries the measurement. +`workflow-resilience-check` is not always-on and is registered on its own. + | Guard | Event / matcher | Behavior | What it catches | |-------|-----------------|----------|-----------------| | **secret-pattern-detection** | PreToolUse · Write \| Edit \| NotebookEdit | **Blocks** (exit 2) | High-confidence secret/credential patterns (AWS/GitHub/GitLab/Slack/Stripe/OpenAI keys, PEM private keys) in new file content. | @@ -290,6 +305,21 @@ out of scope until such a signal exists. ### Hook budget accounting +**0.31.0, the dispatcher.** Measured with the fleet's hook fan-out harness +(`dotfiles/common/measure-claude-hook-fanout.sh`, one hook process per line, median of +3 runs, benign `git status --short` payload, Windows 11 + Git Bash, 2026-09-02, host +under concurrent agent load). Before: the eight per-Bash-call guards were eight hook +processes summing to **≈ 2,450 ms** (412 / 403 / 362 / 350 / 336 / 311 / 198 / 78 ms). +After: one hook process; `RUN_GUARDS_PROFILE=1` reports the per-guard slices inside it +as ≈ 167 / 163 / 197 / 18 / 233 / 253 / 156 / 37 ms, **≈ 1,220 ms** in total, the +remainder being fork cost, since each guard still runs in its own subshell so that its +`exit` and `trap` behave as they do standalone. That is a halving of the per-Bash-call +CPU cost and a drop from eight process spawns to one; it is still above the +convention's ≤ 1 s typical ceiling on a loaded host, and the remaining remediation is +the guards' own per-call work (the subshell fork itself, and the external programs a +guard spawns on its hot path), not the dispatcher. The per-Write sets fell the same way: +three PreToolUse processes to one, three PostToolUse processes to one. + Per [`docs/conventions/hook-budget/README.md`](../../docs/conventions/hook-budget/README.md) rule 1, widening an always-on hook's matcher states its measured share of the fleet budget. `block-windows-drive-tmp` moved from `Bash|PowerShell` to that set diff --git a/plugins/guardrails/hooks/run-guards.sh b/plugins/guardrails/hooks/run-guards.sh index 3f99bd2e03..597710e86b 100644 --- a/plugins/guardrails/hooks/run-guards.sh +++ b/plugins/guardrails/hooks/run-guards.sh @@ -187,9 +187,14 @@ elif ((${#OUTS[@]} > 1)); then 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) && [[ -n "$merged" ]] && - printf '%s\n' "$merged" || + | 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 printf '%s\n' "${OUTS[@]}" + fi fi exit "$RC" diff --git a/plugins/guardrails/hooks/run-guards.test.sh b/plugins/guardrails/hooks/run-guards.test.sh index b4c66e2946..d5ba1b15d0 100644 --- a/plugins/guardrails/hooks/run-guards.test.sh +++ b/plugins/guardrails/hooks/run-guards.test.sh @@ -90,6 +90,7 @@ run "$PAYLOAD" "$TEST_TMPDIR/ctx1.sh" "$TEST_TMPDIR/allow.sh" "$TEST_TMPDIR/ctx2 assert_exit "merge run exits 0" 0 "$RC" assert_eq "merged output is exactly one JSON document" "1" "$(jq -s 'length' <<<"$OUT")" merged_ctx=$(jq -r '.hookSpecificOutput.additionalContext' <<<"$OUT") +merged_ctx="${merged_ctx//$'\r'/}" # the Windows jq build writes CRLF assert_eq "merged additionalContext carries both guards, in order" $'ctx one\n\nctx two' "$merged_ctx" assert_eq "merged hookEventName kept" "PreToolUse" "$(jq -r '.hookSpecificOutput.hookEventName' <<<"$OUT")" assert_eq "merged systemMessage carries the one guard that set it" "sys two" "$(jq -r '.systemMessage' <<<"$OUT")" diff --git a/plugins/powershell-format/README.md b/plugins/powershell-format/README.md index fdbeb353af..70c97f2881 100644 --- a/plugins/powershell-format/README.md +++ b/plugins/powershell-format/README.md @@ -11,6 +11,9 @@ and runs only when your repo has opted into a `PSScriptAnalyzerSettings.psd1`. ## Behavior +- **Spawned only for PowerShell files.** The hook is registered with the `if` filters + `Edit(*.ps1)`, `Edit(*.psm1)` and `Edit(*.psd1)`, so a Write/Edit of any other file + never starts a hook process for it; the extension check inside the script is unchanged. - **Opt-in on a settings file.** PSScriptAnalyzer runs **only when a `PSScriptAnalyzerSettings.psd1` governs the edited file**, found by walking up from the file to the repository root and stopping at the closest one. Unlike diff --git a/plugins/ruff-format/README.md b/plugins/ruff-format/README.md index e01b58bfa7..34c4d8301b 100644 --- a/plugins/ruff-format/README.md +++ b/plugins/ruff-format/README.md @@ -11,6 +11,9 @@ own and runs only when your repo has opted into Ruff. ## Behavior +- **Spawned only for Python files.** The hook is registered with the `if` filters + `Edit(*.py)` and `Edit(*.pyi)`, so a Write/Edit of any other file never starts a + hook process for it; the extension check inside the script is unchanged. - **Opt-in on a Ruff config.** Ruff runs **only when a `.ruff.toml`, `ruff.toml`, or `pyproject.toml` with a `[tool.ruff]` section governs the edited file**, found by walking up from the file to the repository root, the diff --git a/plugins/source-control/README.md b/plugins/source-control/README.md index 6cd0de37b4..08781ed05e 100644 --- a/plugins/source-control/README.md +++ b/plugins/source-control/README.md @@ -160,6 +160,12 @@ invocation, or a call following a `cd`/`pushd` on the same command line, which moves the directory the gate file and any relative `--body-file` resolved against. Set `pr_body_linkage_gate_enabled` to `false` to turn it off. +The registration carries an `if` filter, `Bash(gh *)`, so the hook process is spawned +only for a command that runs `gh` (Claude Code checks each subcommand of a compound +command, and runs the hook regardless when it cannot tell what a command expands to). +That is a superset of the hook's own first check, so nothing it would have judged is +skipped; a plain `git status` no longer pays for it. + #### Telemetry (opt-in) The hook emits one structured @@ -207,6 +213,13 @@ can read; it does not block concurrent writes (git-worktree(1)). stops. Set `worktree_add_claim_gate_enabled` to `false` to turn the hook off; the script remains the documented gate. +This hook and its `PreToolUse` sibling `worktree-add-containment-gate` are registered +with the `if` filter `Bash(*worktree*)`: the hook process is spawned only for a command +whose text carries `worktree`, which is also each hook's own first check, so every +`git worktree add` spelling they judged before (including `git -C worktree add` +and wrapped forms) still reaches them, and every other Bash call no longer pays for +two hook processes. + ## Works in any repo - **Self-contained.** Everything runs on `git`, `gh` (authenticated), `jq`, From f0c5ffc5801246c0a7d227e59ef6088ab47911c7 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:54:36 -0400 Subject: [PATCH 3/8] fix(guardrails): satisfy the hygiene lane for the dispatcher (exec bits, shellcheck info findings) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011boRpMmkKhritwmR4ZPMvP --- plugins/guardrails/hooks/run-guards.sh | 1 + plugins/guardrails/hooks/run-guards.test.sh | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) mode change 100644 => 100755 plugins/guardrails/hooks/run-guards.sh mode change 100644 => 100755 plugins/guardrails/hooks/run-guards.test.sh diff --git a/plugins/guardrails/hooks/run-guards.sh b/plugins/guardrails/hooks/run-guards.sh old mode 100644 new mode 100755 index 597710e86b..7883902f09 --- a/plugins/guardrails/hooks/run-guards.sh +++ b/plugins/guardrails/hooks/run-guards.sh @@ -62,6 +62,7 @@ PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$HOOK_DIR/.." && pwd)}" # argument shape the guards use (a path with a directory part) removes that # exec from every guard sourced below; the `source` it feeds returns at once on # the library's double-source guard anyway. +# shellcheck disable=SC2329 # invoked by every guard sourced below dirname() { local p="${1%/}" if [[ "$p" == */* ]]; then printf '%s\n' "${p%/*}"; else printf '.\n'; fi diff --git a/plugins/guardrails/hooks/run-guards.test.sh b/plugins/guardrails/hooks/run-guards.test.sh old mode 100644 new mode 100755 index d5ba1b15d0..54100363c9 --- a/plugins/guardrails/hooks/run-guards.test.sh +++ b/plugins/guardrails/hooks/run-guards.test.sh @@ -34,6 +34,9 @@ stub() { chmod +x "$TEST_TMPDIR/$name" } SEEN="$TEST_TMPDIR/seen" +# The stub bodies are written verbatim into the stub scripts, so the `$` in them +# must NOT expand here. +# shellcheck disable=SC2016 stub allow.sh 'INPUT=$(hook::buffer_stdin) || { rc=$?; ((rc == 2)) && exit 2; exit 0; } hook::jq_fields "$INPUT" ".tool_input.command" ".tool_name" || exit 0 printf "%s\n" "${HOOK_JQ_FIELDS[@]}" >>"'"$SEEN"'" @@ -139,7 +142,7 @@ for g in secret-pattern-detection hardcoded-path-check block-no-verify block-dan cli-flag-verify skill-reference-verify stale-path-verify; do n=$(jq -r --arg g "$g.sh" '[.hooks[][] | .hooks[] | .command | select(contains("run-guards.sh") and contains(" " + $g))] | length' "$HOOK_DIR/hooks.json") if ((n > 0)); then ok "hooks.json dispatches $g"; else bad "hooks.json does not dispatch $g"; fi - [[ -f "$HOOK_DIR/$g.sh" ]] && ok "$g.sh exists on disk" || bad "$g.sh missing on disk" + if [[ -f "$HOOK_DIR/$g.sh" ]]; then ok "$g.sh exists on disk"; else bad "$g.sh missing on disk"; fi done report From 09f58f38d3e01388ffd58859e2a4bd4496df3500 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:55:03 -0400 Subject: [PATCH 4/8] fix(guardrails): scope the SC2016 waiver to the whole dispatcher test file Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011boRpMmkKhritwmR4ZPMvP --- plugins/guardrails/hooks/run-guards.test.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/guardrails/hooks/run-guards.test.sh b/plugins/guardrails/hooks/run-guards.test.sh index 54100363c9..4f374c5934 100755 --- a/plugins/guardrails/hooks/run-guards.test.sh +++ b/plugins/guardrails/hooks/run-guards.test.sh @@ -5,6 +5,10 @@ # once and re-served with the same rc, jq answered from one cache with a # byte-identical fallback, every guard run to completion, exit aggregation, # and the merge of several stdout documents into one. +# +# The stub guard bodies below are single-quoted on purpose: they are written +# verbatim into stub scripts, so their `$` must not expand here. +# shellcheck disable=SC2016 set -uo pipefail TEST_TMPDIR="$(mktemp -d "${TMPDIR:-/tmp}/run-guards-test.XXXXXX")" From 121c222d3df6bc5e549ba4491c505b7eff698686 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 2 Sep 2026 02:45:06 -0400 Subject: [PATCH 5/8] docs(hooks): state the best-effort residual of the Bash if filters A pattern beyond a bare command name still spawns the process on a command containing a substitution; the harness reports it as RAN(best-effort). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011boRpMmkKhritwmR4ZPMvP --- plugins/disk-hygiene/README.md | 4 +++- plugins/source-control/README.md | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index 6d95f251b2..dbe418cadf 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -94,7 +94,9 @@ The guard registers on two surfaces: a plugin-level **engine gate** (`hooks/hook only on commands referencing the engine, deferring everything else instantly, and enforces the kill switch and data-root authority (since **0.21.1** the registration also carries the `if` filter `Bash(*hygiene.py*)`, a superset of the gate's own relevance check, so a command that does not -name the engine no longer spawns the Python interpreter to be deferred); and the skill-scoped **belt** inside the `clean` skill's context, +name the engine no longer spawns the Python interpreter to be deferred, except that a command +containing `$()`, a backtick or `$VAR` still spawns it, because the filter cannot see what the +substitution expands to); and the skill-scoped **belt** inside the `clean` skill's context, which adds the deny-by-default Bash and deletion-spelling PowerShell discipline during active cleanup work. Both surfaces resolve the kill switch by reading `disk_hygiene_enabled` from user-scope `pluginConfigs` in `settings.json` (located from `${CLAUDE_PLUGIN_ROOT}`, honored only diff --git a/plugins/source-control/README.md b/plugins/source-control/README.md index 08781ed05e..a19bd9e490 100644 --- a/plugins/source-control/README.md +++ b/plugins/source-control/README.md @@ -164,7 +164,11 @@ The registration carries an `if` filter, `Bash(gh *)`, so the hook process is sp only for a command that runs `gh` (Claude Code checks each subcommand of a compound command, and runs the hook regardless when it cannot tell what a command expands to). That is a superset of the hook's own first check, so nothing it would have judged is -skipped; a plain `git status` no longer pays for it. +skipped; a plain `git status` no longer pays for it. The filter is best-effort on the +Bash side: a pattern beyond a bare command name still spawns the process on any command +containing `$()`, a backtick or `$VAR`, because Claude Code cannot tell what such a +command expands to. The dotfiles fan-out harness reports those spawns as +`RAN(best-effort)` on its `$()` sample. #### Telemetry (opt-in) @@ -218,7 +222,9 @@ with the `if` filter `Bash(*worktree*)`: the hook process is spawned only for a whose text carries `worktree`, which is also each hook's own first check, so every `git worktree add` spelling they judged before (including `git -C worktree add` and wrapped forms) still reaches them, and every other Bash call no longer pays for -two hook processes. +two hook processes. The same best-effort caveat applies: a command containing `$()`, a +backtick or `$VAR` spawns both processes whatever its text, since the filter cannot see +what the substitution expands to. ## Works in any repo From 87931d60b9c099b6ae620f8fc9901daae8edf8c0 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:53:10 -0400 Subject: [PATCH 6/8] fix(hooks): address the PR #3621 review threads on the if filters and the dispatcher - source-control: widen the linkage gate's filter to Bash(*gh *) so wrapped calls (env, sudo, bash -c) launch the gate; README caveat states what the filter covers and what it still cannot see. - disk-hygiene: register the engine gate once per tool. The Bash entry keeps its if filter; a separate PowerShell entry carries none, since an if filter is tool-scoped and PowerShell compound-command matching would skip the kill-switch guard silently. - guardrails: the dispatcher no longer defines a function named dirname (it was inherited by every sourced guard and diverged from GNU); the helper is run_guards::script_dir, used only for the dispatcher's own path. Without jq several guard documents are never concatenated: the blocking one wins, else the first, and the rest go to stderr with a run-guards: dropped prefix. Tests cover both. - guardrails CHANGELOG states the README's measured figures (about 2,450 ms to about 1,220 ms) and that the set remains above the 1 s ceiling. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011boRpMmkKhritwmR4ZPMvP --- plugins/disk-hygiene/CHANGELOG.md | 7 ++ plugins/disk-hygiene/README.md | 14 ++-- plugins/disk-hygiene/hooks/hooks.json | 13 ++- plugins/guardrails/CHANGELOG.md | 20 ++++- plugins/guardrails/hooks/run-guards.sh | 91 ++++++++++++++------- plugins/guardrails/hooks/run-guards.test.sh | 58 +++++++++++++ plugins/source-control/CHANGELOG.md | 10 ++- plugins/source-control/README.md | 25 ++++-- plugins/source-control/hooks/hooks.json | 2 +- 9 files changed, 193 insertions(+), 47 deletions(-) diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 5808f6fea8..f1b16dc962 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -11,6 +11,13 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format fol judges a command that carries the engine's file name (`_engine_gate_relevant`), so the filter is a superset of its own relevance check; every other Bash call no longer pays a Python interpreter start to be told it is irrelevant. +- **The engine gate is registered once per tool.** An `if` filter is scoped to the tool + it names, so the single `Bash|PowerShell` entry carrying a `Bash(...)` filter never + launched the gate for a PowerShell call. The `Bash` entry keeps the filter; a separate + `PowerShell` entry carries none, because PowerShell filtering must match every + subcommand of a compound command and would skip this kill-switch guard silently on a + mixed line. Every PowerShell call therefore still pays the interpreter start, as it + did before 0.21.4. ## [0.21.3] diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index b41c525b60..144f64763d 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -92,11 +92,15 @@ enforces nothing. The launcher resolves Python itself instead (#1504). The guard registers on two surfaces: a plugin-level **engine gate** (`hooks/hooks.json`) that acts only on commands referencing the engine, deferring everything else instantly, and enforces the kill -switch and data-root authority (since **0.21.1** the registration also carries the `if` filter -`Bash(*hygiene.py*)`, a superset of the gate's own relevance check, so a command that does not -name the engine no longer spawns the Python interpreter to be deferred, except that a command -containing `$()`, a backtick or `$VAR` still spawns it, because the filter cannot see what the -substitution expands to); and the skill-scoped **belt** inside the `clean` skill's context, +switch and data-root authority (since **0.21.4** the gate is registered once per tool: the `Bash` +entry carries the `if` filter `Bash(*hygiene.py*)`, a superset of the gate's own relevance check, +so a Bash command that does not name the engine no longer spawns the Python interpreter to be +deferred, except that a command containing `$()`, a backtick or `$VAR` still spawns it, because +the filter cannot see what the substitution expands to; the `PowerShell` entry carries no `if`, +because an `if` filter is scoped to the tool it names, so a Bash filter would leave every +PowerShell call unguarded, and a PowerShell filter must match every subcommand of a compound +command, which would skip this kill-switch guard silently on a mixed line, so every PowerShell +call still pays the interpreter start); and the skill-scoped **belt** inside the `clean` skill's context, which adds the deny-by-default Bash and deletion-spelling PowerShell discipline during active cleanup work. Both surfaces resolve the kill switch by reading `disk_hygiene_enabled` from user-scope `pluginConfigs` in `settings.json` (located from `${CLAUDE_PLUGIN_ROOT}`, honored only diff --git a/plugins/disk-hygiene/hooks/hooks.json b/plugins/disk-hygiene/hooks/hooks.json index 0fb306dabd..1afa3e34ea 100644 --- a/plugins/disk-hygiene/hooks/hooks.json +++ b/plugins/disk-hygiene/hooks/hooks.json @@ -2,7 +2,7 @@ "hooks": { "PreToolUse": [ { - "matcher": "Bash|PowerShell", + "matcher": "Bash", "hooks": [ { "type": "command", @@ -12,6 +12,17 @@ "timeout": 60 } ] + }, + { + "matcher": "PowerShell", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/run-python-hook.sh \"${CLAUDE_PLUGIN_ROOT}\"/skills/clean/scripts/destructive_guard.py --mode engine-gate --plugin-root \"${CLAUDE_PLUGIN_ROOT}\" --authorized-data-root \"${CLAUDE_PLUGIN_DATA}\"", + "shell": "bash", + "timeout": 60 + } + ] } ], "Stop": [ diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index db4a5144d3..e9c534218a 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -18,8 +18,24 @@ All notable changes to the `guardrails` plugin are documented here. Format follo aggregation (2 wins, every guard still runs), and the merge of several guards' `additionalContext` into the one JSON document a hook process may emit. Measured on the reference Windows host with a benign `git status` payload the per-Bash-call set - fell from ≈ 3.0 s of summed hook time to under 1 s; the hook-budget accounting in the - README carries the per-guard figures. + fell from about 2,450 ms of summed hook time across eight processes to about + 1,220 ms in one; that is still above the hook-budget convention's 1 s typical + ceiling, and the README's "Hook budget accounting" section carries the per-guard + figures and the remaining remediation. +- **The dispatcher no longer shadows `dirname`.** Its spawn-saving `dirname` shell + function was inherited by every guard it sourced, and diverged from GNU `dirname` + for `/foo` (empty, not `/`) and `/a/b//` (`/a/b`, not `/a`). The helper is now + `run_guards::script_dir`, used only to locate the dispatcher's own directory, so a + dispatched guard's `dirname` is the real command again. Each guard's opening + `dirname` exec therefore returns (about 80 ms each on the reference Windows host); + the README's accounting was measured with the shadow in place and has not been + re-measured. +- **Several guard documents without `jq` are never concatenated.** A hook process may + emit exactly one JSON document, so when two or more guards emit and `jq` is absent + (or the merge fails) the dispatcher emits the document carrying a blocking decision + (`"decision":"block"`, `"permissionDecision":"deny"`, then `"ask"`), else the first, + and prints each dropped document to stderr with a `run-guards: dropped without jq:` + prefix so the drop stays visible. ## [0.30.5] diff --git a/plugins/guardrails/hooks/run-guards.sh b/plugins/guardrails/hooks/run-guards.sh index 7883902f09..14327f666c 100755 --- a/plugins/guardrails/hooks/run-guards.sh +++ b/plugins/guardrails/hooks/run-guards.sh @@ -43,31 +43,36 @@ # `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 -# document was delivered on its own. +# 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"`, +# `"permissionDecision":"deny"`, then `"ask"`) is emitted, else the first, +# and every dropped document is echoed to stderr with a `run-guards:` +# prefix so it stays visible in debug output. # # The overrides are scoped to this process: a guard run directly (its tests, # `bash hooks/.sh`) uses the library functions untouched. set -uo pipefail -# shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" - -HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$HOOK_DIR/.." && pwd)}" - -# Every guard opens with `source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh"`. -# `dirname` is an external program, and on Windows Git Bash one exec is ~80 ms, -# paid once per guard. A shell function with the same answer for the one -# argument shape the guards use (a path with a directory part) removes that -# exec from every guard sourced below; the `source` it feeds returns at once on -# the library's double-source guard anyway. -# shellcheck disable=SC2329 # invoked by every guard sourced below -dirname() { +# `dirname` is an external program, and on Windows Git Bash one exec is ~80 ms. +# This helper answers the one argument shape used at the two call sites below +# (this file's own path) without the exec. It is deliberately NOT a function +# named `dirname`: a function of that name would be inherited by every guard +# sourced below and shadow the real command for the guard's own calls, with an +# answer that diverges from GNU for `/foo` (empty, not `/`) and for `/a/b//` +# (`/a/b`, not `/a`). Each guard's own `dirname` therefore stays the real one. +run_guards::script_dir() { local p="${1%/}" if [[ "$p" == */* ]]; then printf '%s\n' "${p%/*}"; else printf '.\n'; fi } +# shellcheck source=hook-utils.sh +source "$(run_guards::script_dir "${BASH_SOURCE[0]}")/hook-utils.sh" + +HOOK_DIR="$(cd "$(run_guards::script_dir "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$HOOK_DIR/.." && pwd)}" + GUARDS=() while (($#)); do case "$1" in @@ -178,23 +183,53 @@ for guard in "${GUARDS[@]}"; do fi done +# 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 +# document carrying `"decision":"block"` or `"permissionDecision":"deny"` wins, +# then one carrying `"permissionDecision":"ask"`, else the first document. The +# rest go to stderr, prefixed, so the drop is visible in debug output. +run_guards::emit_one() { + local why="$1" pick=-1 i doc + local re_block='"decision"[[:space:]]*:[[:space:]]*"block"' + local re_deny='"permissionDecision"[[:space:]]*:[[:space:]]*"deny"' + local re_ask='"permissionDecision"[[:space:]]*:[[:space:]]*"ask"' + for i in "${!OUTS[@]}"; do + doc="${OUTS[i]}" + if [[ "$doc" =~ $re_block || "$doc" =~ $re_deny ]]; then + pick=$i + break + fi + ((pick < 0)) && [[ "$doc" =~ $re_ask ]] && pick=$i + done + ((pick < 0)) && pick=0 + printf '%s\n' "${OUTS[pick]}" + for i in "${!OUTS[@]}"; do + ((i == pick)) && continue + printf 'run-guards: dropped %s: %s\n' "$why" "${OUTS[i]}" >&2 + done +} + if ((${#OUTS[@]} == 1)); then printf '%s\n' "${OUTS[0]}" elif ((${#OUTS[@]} > 1)); then - 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" + if ! command -v jq >/dev/null 2>&1; then + run_guards::emit_one "without jq" else - printf '%s\n' "${OUTS[@]}" + 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 diff --git a/plugins/guardrails/hooks/run-guards.test.sh b/plugins/guardrails/hooks/run-guards.test.sh index 4f374c5934..8ec555bf03 100755 --- a/plugins/guardrails/hooks/run-guards.test.sh +++ b/plugins/guardrails/hooks/run-guards.test.sh @@ -56,6 +56,11 @@ stub miss.sh 'INPUT=$(hook::buffer_stdin) || exit 0 hook::jq_fields "$INPUT" ".session_id" ".tool_name" || exit 0 printf "%s\n" "${HOOK_JQ_FIELDS[@]}" >>"'"$SEEN"'"' stub lib.sh 'printf "ps=%s\n" "${_GUARDRAILS_PS_COMMAND_LOADED:-unset}" >>"'"$SEEN"'"' +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\"}}"' PAYLOAD=$(jq -n '{session_id:"s-1",tool_name:"Bash",cwd:"/x",tool_input:{command:"git status --short"}}') @@ -124,6 +129,59 @@ assert_eq "cache miss serves the right values" $'s-1\nBash' "$(cat "$SEEN")" run "$PAYLOAD" --lib lib/powershell/ps-command.sh "$TEST_TMPDIR/lib.sh" assert_eq "--lib library is loaded before the guards run" "ps=1" "$(cat "$SEEN")" +# --- a dispatched guard sees the real dirname, not a dispatcher shadow ------- +run "$PAYLOAD" "$TEST_TMPDIR/dirname.sh" +assert_eq "dirname inside a dispatched guard is the external command" "file /" "$(cat "$SEEN")" + +# --- no jq: several emitters yield ONE document, never a concatenation ------- +# Build a PATH with no jq on it. A directory that carries jq is replaced by a +# shim directory re-exporting its other executables, so every other tool the +# dispatcher and the stubs need stays reachable. +NOJQ_PATH="" +IFS=: read -r -a path_dirs <<<"$PATH" +for d in "${path_dirs[@]}"; do + [[ -n "$d" ]] || continue + if [[ -x "$d/jq" || -x "$d/jq.exe" ]]; then + shim="$TEST_TMPDIR/nojq-$(printf '%s' "$d" | tr -c 'A-Za-z0-9' _)" + mkdir -p "$shim" + for f in "$d"/*; do + base="${f##*/}" + [[ "$base" == jq || "$base" == jq.exe ]] && continue + [[ -x "$f" ]] || continue + ln -s "$f" "$shim/$base" 2>/dev/null || true + done + NOJQ_PATH+="${NOJQ_PATH:+:}$shim" + else + NOJQ_PATH+="${NOJQ_PATH:+:}$d" + fi +done +if PATH="$NOJQ_PATH" command -v jq >/dev/null 2>&1 || ! PATH="$NOJQ_PATH" command -v sed >/dev/null 2>&1; then + bad "could not build a PATH without jq that still carries sed" +else + run_nojq() { # run_nojq ... -> OUT, ERR, RC as run does + local input="$1" + shift + : >"$SEEN" + RC=0 + OUT=$(PATH="$NOJQ_PATH" "$BASH" "$DISPATCH" "$@" <<<"$input" 2>"$TEST_TMPDIR/err") || RC=$? + ERR=$(cat "$TEST_TMPDIR/err") + } + run_nojq "$PAYLOAD" "$TEST_TMPDIR/ctx1.sh" "$TEST_TMPDIR/ask.sh" "$TEST_TMPDIR/deny.sh" "$TEST_TMPDIR/ctx2.sh" + assert_exit "no jq: run exits 0" 0 "$RC" + assert_eq "no jq: exactly one JSON document on stdout" "1" "$(grep -c '^{' <<<"$OUT")" + assert_eq "no jq: stdout is a single line" "1" "$(wc -l <<<"$OUT" | tr -d ' ')" + assert_contains "no jq: the blocking document is the one emitted" "$OUT" '"permissionDecision":"deny"' + assert_contains "no jq: dropped documents are named on stderr" "$ERR" "run-guards: dropped without jq:" + assert_contains "no jq: the dropped context document is on stderr" "$ERR" "ctx one" + assert_contains "no jq: the dropped ask document is on stderr" "$ERR" '"ask"' + assert_absent "no jq: the emitted document is not also dropped" "$ERR" "stub deny" + run_nojq "$PAYLOAD" "$TEST_TMPDIR/ctx1.sh" "$TEST_TMPDIR/ask.sh" + assert_contains "no jq: ask outranks a plain context document" "$OUT" '"ask"' + run_nojq "$PAYLOAD" "$TEST_TMPDIR/ctx1.sh" "$TEST_TMPDIR/ctx2.sh" + assert_eq "no jq: with no blocking document the first one is emitted" "$standalone" "$OUT" + assert_contains "no jq: the second context document is dropped to stderr" "$ERR" "ctx two" +fi + # --- an unknown guard is reported, the rest still run ------------------------ run "$PAYLOAD" "$TEST_TMPDIR/nope.sh" "$TEST_TMPDIR/allow.sh" assert_exit "missing guard surfaces as rc 1" 1 "$RC" diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index c102870b04..61b1bd6f0e 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -8,11 +8,19 @@ All notable changes to the `source-control` plugin are documented here. Format f ### Changed - **The Bash gates carry `if` filters, so they spawn only for the commands they judge.** - `pr-body-linkage-gate` runs under `Bash(gh *)`, and `worktree-add-containment-gate` + `pr-body-linkage-gate` runs under `Bash(*gh *)`, and `worktree-add-containment-gate` and `worktree-add-claim-gate` under `Bash(*worktree*)`; each filter is a superset of the gate's own first check (`gh` on the command line; `worktree` in the command), so no command the gate would have judged is skipped. A plain `git status` no longer costs three hook processes. +- **The linkage gate's filter is `Bash(*gh *)`, not `Bash(gh *)`.** The `if` field + matches the command name, so the narrower form never launched the gate for a wrapped + call (`env GH_TOKEN=x gh pr create`, `sudo gh pr create`, + `bash -c "cd x && gh pr create"`). The leading wildcard covers those, at the cost of + one bash start on a non-`gh` line that happens to contain `gh `, which the gate's own + regex pre-filter dismisses. A `gh` produced by a substitution is still judged only + because Claude Code runs the hook regardless when it cannot tell what a command + expands to. ## [0.55.40] diff --git a/plugins/source-control/README.md b/plugins/source-control/README.md index 0b48048ba6..5eeaa8ea71 100644 --- a/plugins/source-control/README.md +++ b/plugins/source-control/README.md @@ -160,15 +160,22 @@ invocation, or a call following a `cd`/`pushd` on the same command line, which moves the directory the gate file and any relative `--body-file` resolved against. Set `pr_body_linkage_gate_enabled` to `false` to turn it off. -The registration carries an `if` filter, `Bash(gh *)`, so the hook process is spawned -only for a command that runs `gh` (Claude Code checks each subcommand of a compound -command, and runs the hook regardless when it cannot tell what a command expands to). -That is a superset of the hook's own first check, so nothing it would have judged is -skipped; a plain `git status` no longer pays for it. The filter is best-effort on the -Bash side: a pattern beyond a bare command name still spawns the process on any command -containing `$()`, a backtick or `$VAR`, because Claude Code cannot tell what such a -command expands to. The dotfiles fan-out harness reports those spawns as -`RAN(best-effort)` on its `$()` sample. +The registration carries an `if` filter, `Bash(*gh *)`, the same shape as the +`Bash(*worktree*)` filter on the worktree gates, so the hook process is spawned only +for a command line that carries `gh ` somewhere in its text (Claude Code checks each +subcommand of a compound command, and runs the hook regardless when it cannot tell what +a command expands to). The leading wildcard is deliberate: the `if` field matches the +command name, so the narrower `Bash(gh *)` never launched the gate for a wrapped call +such as `env GH_TOKEN=x gh pr create`, `sudo gh pr create` or +`bash -c "cd x && gh pr create"`, whose first word is not `gh`. The wider filter is a +superset of the hook's own first check (a `gh` word anywhere on the line), so nothing +it would have judged is skipped; a plain `git status` still does not pay for it, and a +non-`gh` line that happens to contain `gh ` (`echo high tide`) pays one bash start +before the hook's own jq-free regex pre-filter dismisses it. What the filter still +cannot see is a `gh` that only appears after a `$()`, a backtick or a `$VAR` expands; +Claude Code spawns the hook regardless for such a command, so the gate still judges +it, and the dotfiles fan-out harness reports those spawns as `RAN(best-effort)` on its +`$()` sample. #### Telemetry (opt-in) diff --git a/plugins/source-control/hooks/hooks.json b/plugins/source-control/hooks/hooks.json index f0fd832e82..9558e9c6c2 100644 --- a/plugins/source-control/hooks/hooks.json +++ b/plugins/source-control/hooks/hooks.json @@ -7,7 +7,7 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/pr-body-linkage-gate.sh", - "if": "Bash(gh *)", + "if": "Bash(*gh *)", "timeout": 15, "statusMessage": "Checking PR body against the repo's pr-issue-linkage gate..." }, From 27e37309e95c2357b5d1c34fe8331709c38f2a01 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:57:53 -0400 Subject: [PATCH 7/8] test(disk-hygiene): pin the engine gate's per-tool registration shape The guard tests that read hooks.json asserted exactly one PreToolUse registration of the engine gate; there are now two, one per tool, with the same argv and timeout. A new test locks that shape: the Bash entry carries the if filter, the PowerShell entry carries none. The README's Stop-detector sentence no longer counts it as the "second" hook entry. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011boRpMmkKhritwmR4ZPMvP --- plugins/disk-hygiene/README.md | 2 +- .../skills/clean/scripts/test_hygiene.py | 37 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index 144f64763d..d47d462121 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -114,7 +114,7 @@ that issue. PreToolUse hooks also fire inside subagents, so fanned-out workers r guards. **A silent engine-gate launch or runtime failure is surfaced.** A `Stop`-event detector -(`skills/clean/scripts/guard_launch_monitor.py`, a second hook entry in `hooks/hooks.json`, +(`skills/clean/scripts/guard_launch_monitor.py`, a separate hook entry in `hooks/hooks.json`, independent of the engine-gate guard itself) scans the session transcript for `hook_non_blocking_error` records naming the engine gate's own command string and warns once per session with the failure count and the most recent failure's exit code, duration, and stderr, so a diff --git a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py index 52c87f319b..a618fc5a58 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py @@ -6352,8 +6352,37 @@ def _engine_gate_hook_args(cls) -> list[str]: for hook in entry.get("hooks", []) if any("destructive_guard.py" in token for token in cls._hook_argv(hook)) ] - assert len(commands) == 1, commands - return cls._guard_argv_from_hook(commands[0], "destructive_guard.py") + # One registration per tool (``Bash`` with an ``if`` filter, ``PowerShell`` + # without one), both carrying the same guard argv. + assert len(commands) == 2, commands + argvs = { + tuple(cls._guard_argv_from_hook(hook, "destructive_guard.py")) + for hook in commands + } + assert len(argvs) == 1, argvs + return list(argvs.pop()) + + def test_engine_gate_is_registered_once_per_tool(self) -> None: + """Lock the per-tool registration shape of the plugin-level engine gate. + + An ``if`` filter is scoped to the tool it names: under a single + ``Bash|PowerShell`` matcher, ``Bash(...)`` filtered every PowerShell call + out of this kill-switch guard. The ``Bash`` entry keeps the filter; the + ``PowerShell`` entry carries none, because a PowerShell filter must match + every subcommand of a compound command and would skip the guard silently + on a mixed line. + """ + hooks_path = SCRIPT_DIR.parents[2] / "hooks" / "hooks.json" + config = json.loads(hooks_path.read_text(encoding="utf-8")) + by_matcher = { + entry.get("matcher"): hook + for entry in config["hooks"]["PreToolUse"] + for hook in entry.get("hooks", []) + if any("destructive_guard.py" in token for token in self._hook_argv(hook)) + } + self.assertEqual({"Bash", "PowerShell"}, set(by_matcher)) + self.assertTrue(by_matcher["Bash"].get("if", "").startswith("Bash(")) + self.assertNotIn("if", by_matcher["PowerShell"]) def test_engine_gate_hook_resolves_kill_switch_from_plugin_root_not_user_config( self, @@ -7149,7 +7178,9 @@ def test_declared_hook_timeouts_match_the_watchdog_ceiling(self) -> None: for hook in entry.get("hooks", []) if any("destructive_guard.py" in token for token in self._hook_argv(hook)) ] - self.assertEqual([guard._DECLARED_HOOK_TIMEOUT_SECONDS], declared) + # One registration per tool, both declaring the same timeout. + self.assertEqual(2, len(declared), declared) + self.assertEqual({guard._DECLARED_HOOK_TIMEOUT_SECONDS}, set(declared)) skill_text = (SCRIPT_DIR.parent / "SKILL.md").read_text(encoding="utf-8") timeout_lines = [ From f4eb65b19267c08aea279d845482e5fb4d079ac1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:14:17 -0400 Subject: [PATCH 8/8] docs(source-control): drop the trailing space inside the gh code spans (MD038) Co-Authored-By: Claude Fable 5.1 --- plugins/source-control/CHANGELOG.md | 2 +- plugins/source-control/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index 61b1bd6f0e..88e48b07cf 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -17,7 +17,7 @@ All notable changes to the `source-control` plugin are documented here. Format f matches the command name, so the narrower form never launched the gate for a wrapped call (`env GH_TOKEN=x gh pr create`, `sudo gh pr create`, `bash -c "cd x && gh pr create"`). The leading wildcard covers those, at the cost of - one bash start on a non-`gh` line that happens to contain `gh `, which the gate's own + one bash start on a non-`gh` line that happens to contain the text `gh` followed by a space, which the gate's own regex pre-filter dismisses. A `gh` produced by a substitution is still judged only because Claude Code runs the hook regardless when it cannot tell what a command expands to. diff --git a/plugins/source-control/README.md b/plugins/source-control/README.md index 5eeaa8ea71..ef2b2665f5 100644 --- a/plugins/source-control/README.md +++ b/plugins/source-control/README.md @@ -162,7 +162,7 @@ against. Set `pr_body_linkage_gate_enabled` to `false` to turn it off. The registration carries an `if` filter, `Bash(*gh *)`, the same shape as the `Bash(*worktree*)` filter on the worktree gates, so the hook process is spawned only -for a command line that carries `gh ` somewhere in its text (Claude Code checks each +for a command line that carries `gh` followed by a space somewhere in its text (Claude Code checks each subcommand of a compound command, and runs the hook regardless when it cannot tell what a command expands to). The leading wildcard is deliberate: the `if` field matches the command name, so the narrower `Bash(gh *)` never launched the gate for a wrapped call @@ -170,7 +170,7 @@ such as `env GH_TOKEN=x gh pr create`, `sudo gh pr create` or `bash -c "cd x && gh pr create"`, whose first word is not `gh`. The wider filter is a superset of the hook's own first check (a `gh` word anywhere on the line), so nothing it would have judged is skipped; a plain `git status` still does not pay for it, and a -non-`gh` line that happens to contain `gh ` (`echo high tide`) pays one bash start +non-`gh` line that happens to contain `gh` followed by a space (`echo high tide`) pays one bash start before the hook's own jq-free regex pre-filter dismisses it. What the filter still cannot see is a `gh` that only appears after a `$()`, a backtick or a `$VAR` expands; Claude Code spawns the hook regardless for such a command, so the gate still judges