From afc8f3bb489966d2c0f520cc0c132506a974b9cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 22:05:33 +0000 Subject: [PATCH 1/2] perf(guardrails): drop dirname and sed execs from the always-on path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every always-on guard located hook-utils.sh with `source "$(dirname …)"` even after the dispatcher had already loaded the library, and the dispatcher copied hook::jq_fields through sed. A PATH-shim census of a benign `git status --short` counted those as 7 dirname plus 1 sed of 13 total execs. Parameter expansion (`${BASH_SOURCE[0]%/*}`) and a builtin `declare -f` rename drop them to 0; remaining 5 are the primed jq parse and git probes. Spawn census 13 → 5. Guards still see the real dirname. Co-authored-by: ksextonmelodic --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 22 +++++++++ plugins/guardrails/README.md | 26 +++++++++++ .../hooks/block-convention-violation.sh | 15 ++++++- .../guardrails/hooks/block-dangerous-git.sh | 15 ++++++- .../hooks/block-exported-msys-pathconv.sh | 13 +++++- plugins/guardrails/hooks/block-hook-bypass.sh | 15 ++++++- plugins/guardrails/hooks/block-no-verify.sh | 15 ++++++- .../hooks/block-noncanonical-commit.sh | 15 ++++++- .../hooks/block-windows-drive-tmp.sh | 13 +++++- plugins/guardrails/hooks/cli-flag-verify.sh | 15 ++++++- .../hooks/flag-commit-pr-skill-bypass.sh | 15 ++++++- .../guardrails/hooks/hardcoded-path-check.sh | 15 ++++++- plugins/guardrails/hooks/run-guards.sh | 39 ++++++++++------ plugins/guardrails/hooks/run-guards.test.sh | 45 ++++++++++++++++++- .../hooks/secret-pattern-detection.sh | 15 ++++++- .../hooks/skill-reference-verify.sh | 13 +++++- plugins/guardrails/hooks/stale-path-verify.sh | 13 +++++- .../hooks/workflow-resilience-check.sh | 13 +++++- 19 files changed, 295 insertions(+), 39 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 4eeafd2ec8..5d45121d8e 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.32.5" + "version": "0.32.6" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 76d96ab236..f2767ee9eb 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,28 @@ 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.32.6] + +### Changed + +- **The Bash dispatcher and every always-on guard locate `hook-utils.sh` + with parameter expansion, not `dirname`, and the dispatcher copies its jq + cache helper without `sed`.** A benign `git status --short` on this host + spent 7 `dirname` execs (one per enabled Bash guard; the default-off + `flag-commit-pr-skill-bypass` exits before `source`) plus one `sed` to rename + `hook::jq_fields`, of 13 counted PATH-shim spawns. After: 0 `dirname`, 0 + `sed`, 5 remaining (`3 git` + `2 jq`, the classification work and the one primed + payload parse). Spawn census through a stable PATH shim, `HOOK_TELEMETRY_SINK` + unset, same payload: **13 → 5**. Wall clock on this measurable Linux host + (spawn floor 0.5–0.7 ms, n=20): p50 70.0 → 60.7 ms, p95 73.5 → 62.1 ms. + The milliseconds are context; on a host whose spawn floor is tens of + milliseconds the durable figure is the eight PATH-visible execs that + disappeared. + `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape BASH_SOURCE takes; a + dispatched guard still sees the real `dirname` command, not a dispatcher + shadow. `run-guards.test.sh` pins both the empty shim log and the source + shape. What each guard checks is unchanged. + ## [0.32.5] ### Changed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 33e46f0704..be5167d769 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -379,6 +379,32 @@ out of scope until such a signal exists. ### Hook budget accounting +**0.32.6, remaining `dirname`/`sed` execs on the Bash dispatcher.** 2026-09-05, +Linux CI host. The 0.31.1 paired table still carries the pre-cut figures for the +whole Bash dispatcher (52.6 spawn-equivalents); this entry supersedes that +row's counted-exec half. Neither guard's decision changed. Every always-on +Bash guard located `hook-utils.sh` with `source "$(dirname …)"` even after the +dispatcher had already loaded the library, and the dispatcher copied +`hook::jq_fields` through `sed`. Those are PATH-visible execs; the per-guard +`$(source …)` isolation fork is a function-level fork the census does not +count, and is unchanged. + +*Method.* Spawn census via a stable PATH shim (`plugins/performance/scripts/spawn-census.sh`), +`HOOK_TELEMETRY_SINK` unset, benign `git status --short` payload, same host as +the wall-clock pass. Wall clock is p50/p95 of 20 samples after 2 warmup on a +host `spawn_probe` characterised as measurable (min 0.5 ms, spread 1.42×). + +| Counter | before | after | +|---|---|---| +| Counted PATH-shim spawns | 13 (`7 dirname`, `3 git`, `2 jq`, `1 sed`) | 5 (`3 git`, `2 jq`) | +| `dirname` | 7 | 0 | +| `sed` | 1 | 0 | +| Wall p50 / p95 (n=20) | 70.0 / 73.5 ms | 60.7 / 62.1 ms | + +The remaining five execs are the one primed `jq` payload parse and the git +probes the classification guards still run on a `git` command; those were +measured and deliberately not folded earlier (0.31.1). + **0.32.5, the PostToolUse `if` rows.** 2026-09-05, Linux CI host. The three PostToolUse verifiers accept five extensions between them (`cli-flag-verify` scans `.md`, `.sh`, `.bash`, `.ps1` and `.psm1`; the other two scan `.md`), and every diff --git a/plugins/guardrails/hooks/block-convention-violation.sh b/plugins/guardrails/hooks/block-convention-violation.sh index 5e92ae50d8..f1936b9361 100755 --- a/plugins/guardrails/hooks/block-convention-violation.sh +++ b/plugins/guardrails/hooks/block-convention-violation.sh @@ -51,14 +51,25 @@ set -uo pipefail # and fails a guard that sources anything ahead of it. [[ "${CLAUDE_PLUGIN_OPTION_BLOCK_CONVENTION_GATE_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # Bundled PowerShell-command classifier — this gate is matched on both the Bash # and the (opt-in) PowerShell tool, same as the sibling git guards. Resolved # under the plugin root (CC sets CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback # keeps the contract tests working when it is unset). -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$_HOOK_SELF/.." && pwd)}" # shellcheck source=../lib/powershell/ps-command.sh source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index 549eddbc98..b8a75ca2c3 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -69,15 +69,26 @@ set -uo pipefail # and fails a guard that sources anything ahead of it. [[ "${CLAUDE_PLUGIN_OPTION_BLOCK_DANGEROUS_GIT_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # Bundled PowerShell-command classifier — the git guards are matched on both the # Bash and the (opt-in) PowerShell tool, whose command arrives in the same # tool_input.command field with PowerShell grammar. Resolved under the plugin # root (CC sets CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract # tests working when it is unset). -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$_HOOK_SELF/.." && pwd)}" # shellcheck source=../lib/powershell/ps-command.sh source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" diff --git a/plugins/guardrails/hooks/block-exported-msys-pathconv.sh b/plugins/guardrails/hooks/block-exported-msys-pathconv.sh index e50f6e9e57..cbaf6a29c3 100755 --- a/plugins/guardrails/hooks/block-exported-msys-pathconv.sh +++ b/plugins/guardrails/hooks/block-exported-msys-pathconv.sh @@ -85,8 +85,19 @@ set -uo pipefail # and fails a guard that sources anything ahead of it. [[ "${CLAUDE_PLUGIN_OPTION_BLOCK_EXPORTED_MSYS_PATHCONV_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on older bash it is unset, so default to empty and skip telemetry (the block diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index a1b9207529..2bf9653eaf 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -50,8 +50,19 @@ set -uo pipefail # is loaded in time for. scripts/check-killswitch-hoist.sh knows this shape. [[ "${CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_ENABLED:-true}" == "false" ]] && exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # Crash posture (#3130 F5): fail-open. This guard sits on every Bash/PowerShell # call. An internal error must not take the session down, and it must not look @@ -1458,7 +1469,7 @@ block_bypass() { # the parse tax. Resolved under the plugin root (CC sets CLAUDE_PLUGIN_ROOT; # the BASH_SOURCE fallback keeps the contract tests working when it is unset). if [[ "$TOOL_NAME" == "PowerShell" ]]; then - PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$_HOOK_SELF/.." && pwd)}" # shellcheck source=../lib/powershell/ps-command.sh source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" if ps::write_bypass "$COMMAND"; then diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh index aaff5897a2..77db967f89 100755 --- a/plugins/guardrails/hooks/block-no-verify.sh +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -41,15 +41,26 @@ set -uo pipefail # and fails a guard that sources anything ahead of it. [[ "${CLAUDE_PLUGIN_OPTION_BLOCK_NO_VERIFY_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # Bundled PowerShell-command classifier — the git guards are matched on both the # Bash and the (opt-in) PowerShell tool, whose command arrives in the same # tool_input.command field with PowerShell grammar. Resolved under the plugin # root (CC sets CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract # tests working when it is unset). -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$_HOOK_SELF/.." && pwd)}" # shellcheck source=../lib/powershell/ps-command.sh source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index b6a94f5787..5af064046b 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -81,15 +81,26 @@ set -uo pipefail # and fails a guard that sources anything ahead of it. [[ "${CLAUDE_PLUGIN_OPTION_BLOCK_NONCANONICAL_COMMIT_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # Bundled PowerShell-command classifier — the git guards are matched on both the # Bash and the (opt-in) PowerShell tool, whose command arrives in the same # tool_input.command field with PowerShell grammar. Resolved under the plugin # root (CC sets CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract # tests working when it is unset). -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$_HOOK_SELF/.." && pwd)}" # shellcheck source=../lib/powershell/ps-command.sh source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" diff --git a/plugins/guardrails/hooks/block-windows-drive-tmp.sh b/plugins/guardrails/hooks/block-windows-drive-tmp.sh index 19f9f266f1..baf84d8752 100755 --- a/plugins/guardrails/hooks/block-windows-drive-tmp.sh +++ b/plugins/guardrails/hooks/block-windows-drive-tmp.sh @@ -49,8 +49,19 @@ set -uo pipefail # and fails a guard that sources anything ahead of it. [[ "${CLAUDE_PLUGIN_OPTION_BLOCK_WINDOWS_DRIVE_TMP_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # Non-Windows hosts: /tmp is the real POSIX temp, so this guard can never find a # violation here. Skip entirely. Tests force OSTYPE=msys to exercise the Windows diff --git a/plugins/guardrails/hooks/cli-flag-verify.sh b/plugins/guardrails/hooks/cli-flag-verify.sh index 816bc7f255..6a11e1d0dc 100755 --- a/plugins/guardrails/hooks/cli-flag-verify.sh +++ b/plugins/guardrails/hooks/cli-flag-verify.sh @@ -33,14 +33,25 @@ start=${EPOCHREALTIME:-} # hook::is_enabled; scripts/check-killswitch-hoist.sh pins the two together. [[ "${CLAUDE_PLUGIN_OPTION_CLI_FLAG_VERIFY_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" hook::ctx_reset # Bundled verifier — resolved under the plugin root (CC sets CLAUDE_PLUGIN_ROOT; # the BASH_SOURCE fallback keeps the contract tests working when it is unset). -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$_HOOK_SELF/.." && pwd)}" VERIFIER="$PLUGIN_ROOT/lib/verification/verify-cli-flag.sh" # hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read; empty diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index dd36c11256..ef594c4af3 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -59,14 +59,25 @@ set -uo pipefail # the hoist only moves it; scripts/check-killswitch-hoist.sh keeps it here. [[ "${CLAUDE_PLUGIN_OPTION_FLAG_COMMIT_PR_SKILL_BYPASS_ENABLED:-false}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # Bundled PowerShell-command classifier — this guard is matched on both the Bash # and the (opt-in) PowerShell tool. Resolved under the plugin root (CC sets # CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract tests working # when it is unset). -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$_HOOK_SELF/.." && pwd)}" # shellcheck source=../lib/powershell/ps-command.sh source "$PLUGIN_ROOT/lib/powershell/ps-command.sh" diff --git a/plugins/guardrails/hooks/hardcoded-path-check.sh b/plugins/guardrails/hooks/hardcoded-path-check.sh index acfbe10a68..758ed8c9c2 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.sh @@ -27,13 +27,24 @@ set -uo pipefail # and fails a guard that sources anything ahead of it. [[ "${CLAUDE_PLUGIN_OPTION_HARDCODED_PATH_CHECK_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # Bundled pattern lib — resolved under the plugin root (CC sets # CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract tests working # when it is unset). -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$_HOOK_SELF/.." && pwd)}" # shellcheck source=../lib/path-detection/hardcoded-path-patterns.sh source "$PLUGIN_ROOT/lib/path-detection/hardcoded-path-patterns.sh" diff --git a/plugins/guardrails/hooks/run-guards.sh b/plugins/guardrails/hooks/run-guards.sh index 1a311234b1..ae0e470ec9 100755 --- a/plugins/guardrails/hooks/run-guards.sh +++ b/plugins/guardrails/hooks/run-guards.sh @@ -55,22 +55,29 @@ set -uo pipefail -# `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 +# Do NOT define a function named `dirname`. A function of that name would be +# inherited by every guard sourced below and shadow the real command, 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 -} - +# +# The dispatcher's own directory is derived with parameter expansion rather than +# `dirname` or `$(helper)`. GNU Bash forks a subshell for every command +# substitution even when the body is only builtins (Command Substitution, Bash +# Reference Manual; https://mywiki.wooledge.org/CommandSubstitution). On Windows +# Git Bash that fork is a process. `${BASH_SOURCE[0]%/*}` equals `dirname` +# for every shape BASH_SOURCE takes; the fallback covers a bare filename, where +# the strip is a no-op and dirname answers `.`. Claude Code (and this suite) +# invoke with an absolute path, so the strip is already absolute; `cd && pwd` +# is kept only for a relative spelling. +_RG_DIR="${BASH_SOURCE[0]%/*}" +[[ "$_RG_DIR" == "${BASH_SOURCE[0]}" ]] && _RG_DIR=. # shellcheck source=hook-utils.sh -source "$(run_guards::script_dir "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_RG_DIR/hook-utils.sh" -HOOK_DIR="$(cd "$(run_guards::script_dir "${BASH_SOURCE[0]}")" && pwd)" +case "$_RG_DIR" in +/* | ?:[/\\]*) HOOK_DIR="$_RG_DIR" ;; +*) HOOK_DIR="$(cd "$_RG_DIR" && pwd)" ;; +esac PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$HOOK_DIR/.." && pwd)}" GUARDS=() @@ -107,7 +114,13 @@ hook::buffer_stdin() { # --- 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/')" +# `declare -f` is a builtin; wrapping it in $( ) is one subshell. Piping that +# through `sed` was an extra exec on every dispatcher fire. Parameter expansion +# renames the first occurrence — the `name ()` header — and leaves the body +# untouched. +_rg_jq_def=$(declare -f hook::jq_fields) +eval "${_rg_jq_def/hook::jq_fields ()/hook::jq_fields_uncached ()}" +unset _rg_jq_def RUN_GUARDS_PRIMED=0 RUN_GUARDS_FILTERS=() diff --git a/plugins/guardrails/hooks/run-guards.test.sh b/plugins/guardrails/hooks/run-guards.test.sh index 7e36e2b026..11548c7c21 100755 --- a/plugins/guardrails/hooks/run-guards.test.sh +++ b/plugins/guardrails/hooks/run-guards.test.sh @@ -133,6 +133,47 @@ assert_eq "--lib library is loaded before the guards run" "ps=1" "$(cat "$SEEN") run "$PAYLOAD" "$TEST_TMPDIR/dirname.sh" assert_eq "dirname inside a dispatched guard is the external command" "file /" "$(cat "$SEEN")" +# --- benign Bash lane: no dirname/sed exec on the dispatched hot path ---------- +# 0.32.6: every always-on Bash guard used `source "$(dirname …)/hook-utils.sh"` +# and the dispatcher copied hook::jq_fields through sed. Those were 7 dirname +# execs plus one sed on a benign `git status --short` (flag-commit-pr-skill-bypass +# is default-off and exits before source). PATH shims count execs; function +# forks are invisible to them, which is the same instrument as spawn-census.sh. +SHIM="$TEST_TMPDIR/spawn-shim" +mkdir -p "$SHIM" +SPAWN_LOG="$SHIM/spawns.log" +for tool in dirname sed; do + real=$(type -P "$tool") + if [[ -z "$real" ]]; then + bad "need $tool on PATH to pin its absence from the dispatcher" + real="" + break + fi + printf '#!/usr/bin/env bash\nprintf "%%s\\n" %q >>%q\nexec %q "$@"\n' "$tool" "$SPAWN_LOG" "$real" >"$SHIM/$tool" + chmod +x "$SHIM/$tool" +done +if [[ -x "$SHIM/dirname" && -x "$SHIM/sed" ]]; then + : >"$SPAWN_LOG" + PATH="$SHIM:$PATH" bash "$DISPATCH" --lib lib/powershell/ps-command.sh \ + block-no-verify.sh block-dangerous-git.sh block-hook-bypass.sh \ + flag-commit-pr-skill-bypass.sh block-noncanonical-commit.sh \ + block-convention-violation.sh block-windows-drive-tmp.sh \ + block-exported-msys-pathconv.sh <<<"$PAYLOAD" >/dev/null + assert_eq "benign Bash dispatcher execs neither dirname nor sed" "" "$(cat "$SPAWN_LOG")" +fi +DISPATCH_SRC=$(cat "$DISPATCH") +assert_absent "dispatcher copies jq_fields without a sed pipeline" "$DISPATCH_SRC" '| sed' +assert_contains "dispatcher copies jq_fields via parameter expansion" "$DISPATCH_SRC" 'hook::jq_fields_uncached ()' +for g in block-no-verify block-dangerous-git block-hook-bypass \ + flag-commit-pr-skill-bypass block-noncanonical-commit \ + block-convention-violation block-windows-drive-tmp block-exported-msys-pathconv \ + secret-pattern-detection hardcoded-path-check \ + cli-flag-verify skill-reference-verify stale-path-verify \ + workflow-resilience-check; do + assert_absent "$g sources hook-utils without dirname" "$(cat "$HOOK_DIR/$g.sh")" \ + 'source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh"' +done + # --- 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 @@ -155,8 +196,8 @@ for d in "${path_dirs[@]}"; do 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" +if PATH="$NOJQ_PATH" command -v jq >/dev/null 2>&1; then + bad "could not build a PATH without jq" else run_nojq() { # run_nojq ... -> OUT, ERR, RC as run does local input="$1" diff --git a/plugins/guardrails/hooks/secret-pattern-detection.sh b/plugins/guardrails/hooks/secret-pattern-detection.sh index 285336ce23..0a42113add 100755 --- a/plugins/guardrails/hooks/secret-pattern-detection.sh +++ b/plugins/guardrails/hooks/secret-pattern-detection.sh @@ -27,14 +27,25 @@ set -uo pipefail # and fails a guard that sources anything ahead of it. [[ "${CLAUDE_PLUGIN_OPTION_SECRET_PATTERN_DETECTION_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # Bundled pattern lib — resolved under the plugin root (CC sets # CLAUDE_PLUGIN_ROOT; the BASH_SOURCE fallback keeps the contract tests working # when it is unset). Shared with lib/git-hooks/pre-commit-content-invariants.sh # so Write|Edit and the write-path-independent pre-commit layer cannot drift. -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$_HOOK_SELF/.." && pwd)}" # shellcheck source=../lib/secret-detection/secret-patterns.sh source "$PLUGIN_ROOT/lib/secret-detection/secret-patterns.sh" diff --git a/plugins/guardrails/hooks/skill-reference-verify.sh b/plugins/guardrails/hooks/skill-reference-verify.sh index a0adf5786e..ad8cfd8d46 100755 --- a/plugins/guardrails/hooks/skill-reference-verify.sh +++ b/plugins/guardrails/hooks/skill-reference-verify.sh @@ -41,8 +41,19 @@ start=${EPOCHREALTIME:-} # hook::is_enabled; scripts/check-killswitch-hoist.sh pins the two together. [[ "${CLAUDE_PLUGIN_OPTION_SKILL_REFERENCE_VERIFY_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" hook::ctx_reset diff --git a/plugins/guardrails/hooks/stale-path-verify.sh b/plugins/guardrails/hooks/stale-path-verify.sh index b08f81d452..780ead64cf 100755 --- a/plugins/guardrails/hooks/stale-path-verify.sh +++ b/plugins/guardrails/hooks/stale-path-verify.sh @@ -45,8 +45,19 @@ start=${EPOCHREALTIME:-} # hook::is_enabled; scripts/check-killswitch-hoist.sh pins the two together. [[ "${CLAUDE_PLUGIN_OPTION_STALE_PATH_VERIFY_ENABLED:-true}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" hook::ctx_reset diff --git a/plugins/guardrails/hooks/workflow-resilience-check.sh b/plugins/guardrails/hooks/workflow-resilience-check.sh index b8b155810e..36ffb9c77d 100755 --- a/plugins/guardrails/hooks/workflow-resilience-check.sh +++ b/plugins/guardrails/hooks/workflow-resilience-check.sh @@ -26,8 +26,19 @@ set -uo pipefail # the hoist only moves it; scripts/check-killswitch-hoist.sh keeps it here. [[ "${CLAUDE_PLUGIN_OPTION_WORKFLOW_RESILIENCE_CHECK_ENABLED:-false}" == "true" ]] || exit 0 +# The hook's own directory is derived with parameter expansion rather than +# `dirname`. GNU Bash forks a subshell for every command substitution even when +# the body is a builtin (Command Substitution, Bash Reference Manual; +# https://mywiki.wooledge.org/CommandSubstitution). On Windows Git Bash that +# fork is a process, and this line runs on every fire — including inside the +# dispatcher, where the include guard makes `source` cheap but `$(dirname …)` +# still execs. `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape +# BASH_SOURCE takes; the fallback covers a bare filename, where the strip is a +# no-op and dirname answers `.`. +_HOOK_SELF="${BASH_SOURCE[0]%/*}" +[[ "$_HOOK_SELF" == "${BASH_SOURCE[0]}" ]] && _HOOK_SELF=. # shellcheck source=hook-utils.sh -source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" +source "$_HOOK_SELF/hook-utils.sh" # High-res start stamp for the telemetry envelope. EPOCHREALTIME is Bash 5.0+; # on older bash it is unset, so default to empty and skip telemetry. From 829085777720aabebf0abaa9b35c093239e0db1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 22:31:15 +0000 Subject: [PATCH 2/2] test(guardrails): pin relative BASH_SOURCE source-dir fallbacks The dispatcher and every guard now derive their source dir with ${BASH_SOURCE[0]%/*} plus a same-string fallback to `.`. The suite previously always invoked via $DISPATCH (absolute), so those fallbacks were untested. Invoke from hooks/ as ./run-guards.sh, as a bare run-guards.sh (PATH lookup), and as a bare block-no-verify.sh so the relative and `.` paths stay covered. Co-authored-by: ksextonmelodic --- plugins/guardrails/CHANGELOG.md | 4 ++- plugins/guardrails/hooks/run-guards.test.sh | 29 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index f2767ee9eb..460d4d41e7 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -23,7 +23,9 @@ All notable changes to the `guardrails` plugin are documented here. Format follo `${BASH_SOURCE[0]%/*}` equals `dirname` for every shape BASH_SOURCE takes; a dispatched guard still sees the real `dirname` command, not a dispatcher shadow. `run-guards.test.sh` pins both the empty shim log and the source - shape. What each guard checks is unchanged. + shape, plus `./run-guards.sh` and a bare `run-guards.sh` / `block-no-verify.sh` + so the relative `cd && pwd` arm and the `_HOOK_SELF=.` fallback are not + comment-only. What each guard checks is unchanged. ## [0.32.5] diff --git a/plugins/guardrails/hooks/run-guards.test.sh b/plugins/guardrails/hooks/run-guards.test.sh index 11548c7c21..973ebe1281 100755 --- a/plugins/guardrails/hooks/run-guards.test.sh +++ b/plugins/guardrails/hooks/run-guards.test.sh @@ -133,6 +133,35 @@ assert_eq "--lib library is loaded before the guards run" "ps=1" "$(cat "$SEEN") run "$PAYLOAD" "$TEST_TMPDIR/dirname.sh" assert_eq "dirname inside a dispatched guard is the external command" "file /" "$(cat "$SEEN")" +# --- relative and bare BASH_SOURCE still locate hook-utils -------------------- +# Production always invokes with an absolute path, so the `cd && pwd` arm in +# run-guards.sh and the `_HOOK_SELF=.` fallback were unhit by the rest of this +# suite. `./run-guards.sh` makes `${BASH_SOURCE[0]%/*}` answer `.` (a relative +# dir); a bare filename makes the strip a no-op and takes the `=` fallback. +# Both must still source the sibling library and serve the jq cache. +run_from_hooks_dir() { + local spelling="$1" + shift + : >"$SEEN" + RC=0 + OUT=$( + cd "$HOOK_DIR" || exit 1 + bash "$spelling" "$@" <<<"$PAYLOAD" 2>"$TEST_TMPDIR/err" + ) || RC=$? + ERR=$(cat "$TEST_TMPDIR/err") +} +run_from_hooks_dir ./run-guards.sh "$TEST_TMPDIR/allow.sh" +assert_exit "relative ./run-guards.sh exits 0" 0 "$RC" +assert_eq "relative ./run-guards.sh still serves the cache" \ + $'git status --short\nBash' "$(cat "$SEEN")" +run_from_hooks_dir run-guards.sh "$TEST_TMPDIR/allow.sh" +assert_exit "bare run-guards.sh exits 0" 0 "$RC" +assert_eq "bare run-guards.sh still serves the cache" \ + $'git status --short\nBash' "$(cat "$SEEN")" +bare_guard_rc=0 +(cd "$HOOK_DIR" && bash block-no-verify.sh <<<"$PAYLOAD" >/dev/null) || bare_guard_rc=$? +assert_exit "bare block-no-verify.sh from hooks/ exits 0" 0 "$bare_guard_rc" + # --- benign Bash lane: no dirname/sed exec on the dispatched hot path ---------- # 0.32.6: every always-on Bash guard used `source "$(dirname …)/hook-utils.sh"` # and the dispatcher copied hook::jq_fields through sed. Those were 7 dirname