From bfe03ae4aebcb5e74fb5f1be30b9e29d04b7d7f6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:54:50 +0000 Subject: [PATCH 1/2] fix(guardrails): isolated-session remedy, crash fail-open, strict option parse (0.29.10) Name scratch_roots and session --settings ahead of the global switch. Keep fail-open on crash with a visible notice. Accept only exact true/false for block_hook_bypass_enabled. Closes #3130 Co-authored-by: Kyle Sexton --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 15 +++++++ plugins/guardrails/README.md | 22 ++++++++++ plugins/guardrails/hooks/block-hook-bypass.sh | 44 +++++++++++++++++-- .../hooks/block-hook-bypass.test.sh | 20 ++++++++- 5 files changed, 97 insertions(+), 6 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index be87c2982a..545d50e9cd 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.29.9" + "version": "0.29.10" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index bf4cc1f655..306f4ac5b9 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,21 @@ 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.29.10] + +### Changed + +- **block-hook-bypass: isolated-session remedy, crash fail-open, strict option + parse (#3130).** The block message no longer dead-ends when Write/Edit is + refused in an isolated session — it names `block_hook_bypass_scratch_roots` + and session-scoped `--settings` ahead of the user-global switch. The + operator-only sentence moves to `systemMessage`. An internal crash still + fails open (availability on the session's hottest path) but now emits a + dual-channel "guard did not run" notice. `block_hook_bypass_enabled` accepts + only exact `true`/`false` (unset → true); any other value keeps the guard on + and says so. README records the 60s `hooks.json` timeout fail-open and + MCP-provided write tools as known residuals. + ## [0.29.9] ### Changed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 3de020d758..f58f6f389f 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -132,6 +132,28 @@ out of scope until such a signal exists. (`/guardrails:setup apply install-pre-commit-content`) or an equivalent CI check. The block message carries a lane-specific scope note so a reader does not credit the guard with coverage it never claimed. +- **`block-hook-bypass` isolated-session remedy.** Write or Edit may be refused + for paths in the main checkout when the agent is in an isolated session or + worktree. That is not a dead end: write under a configured + `block_hook_bypass_scratch_roots` directory, or ask the operator for a + session-scoped disable via `claude --settings`. The user-global + `block_hook_bypass_enabled` switch is last resort — it persists across every + repository where guardrails is enabled. +- **`block-hook-bypass` fails open on its own crash.** An internal script error + exits 0 so a defect on this hottest-path hook cannot freeze the session, and + emits a dual-channel "guard did not run" notice so the allow is not silent. + Stdin timeout and a NUL payload still fail closed. The 60s `hooks.json` + `timeout` on this handler is a harness-level fail-open the plugin does not + override: if the process is killed at that bound, the tool call proceeds. +- **`block-hook-bypass` option parse is strict.** Only the exact strings `true` + and `false` are accepted (`unset` defaults to enabled). Any other value keeps + the guard enabled and names the bad value — a typo must not silently disable + a blocking safety control. +- **`block-hook-bypass` does not see MCP-provided shell or file-write tools.** + The matcher is `Bash|PowerShell`. A write issued through an MCP tool is an + accepted residual, same class as the unmonitored Bash forms above. There is + also no default scratch exemption: ordinary temp writes block until an + operator sets `block_hook_bypass_scratch_roots`. - **`block-hook-bypass` has one target-scoped exemption beyond `/dev/null`, and it is off unless an operator turns it on.** `block_hook_bypass_scratch_roots` takes a comma-separated list of absolute directories whose contents are diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index b5e3654f0c..978ec060a5 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -37,7 +37,42 @@ set -uo pipefail # shellcheck source=hook-utils.sh source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh" -hook::check_enabled "BLOCK_HOOK_BYPASS" +# 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 +# like a clean allow. EXIT converts unexpected statuses to 0 after a dual-channel +# "guard did not run" notice. Block (2) and allow (0) pass through. +block_hook_bypass_on_exit() { + local rc=$? + if ((rc == 0 || rc == 2)); then + return 0 + fi + trap - EXIT + local msg="guardrails block-hook-bypass: guard did not run (internal error, rc=${rc}); fail-open — this Bash/PowerShell call was not evaluated. The hook stays fail-open on crash so a defect here cannot freeze the session's hottest path." + echo "$msg" >&2 + hook::emit_channels PreToolUse "$msg" "$msg" 2>/dev/null || true + exit 0 +} +trap block_hook_bypass_on_exit EXIT + +# Test injection: trip the crash path without depending on a real script error. +if [[ "${BLOCK_HOOK_BYPASS_TEST_CRASH:-}" == "1" ]]; then + exit 99 +fi + +# Strict-and-loud enable (#3130 F7). hook::check_enabled treats any value other +# than exact "true" as off, so a typo would silently disable a blocking safety +# control. Only true/false (unset → true) are accepted; anything else keeps the +# guard on and says so. +_bbh_enabled="${CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_ENABLED:-true}" +case "$_bbh_enabled" in +true) ;; +false) exit 0 ;; +*) + _bbh_bad="guardrails block-hook-bypass: block_hook_bypass_enabled=${_bbh_enabled} is not exactly true or false; treating as enabled (a safety switch does not silently disable)" + echo "$_bbh_bad" >&2 + hook::emit_channels PreToolUse "$_bbh_bad" "$_bbh_bad" + ;; +esac # 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 @@ -1160,13 +1195,16 @@ block_bypass() { local form="$1" reason="$2" echo "BLOCKED: $reason" >&2 echo "Use the Write or Edit tool instead of a shell file-write workaround." >&2 - echo "In an isolated session, Write or Edit may be refused for paths in the main checkout — that remedy is then unavailable." >&2 - echo "An operator can set the guardrails block_hook_bypass_enabled option to false (/plugin configure) to bypass; that option is user-scoped and persists in every repository where guardrails is enabled — re-enable it when the bypass is no longer needed. The switch is not actionable by the blocked agent." >&2 + echo "If Write or Edit is refused for a path in the main checkout (isolated session / worktree), write under a directory listed in block_hook_bypass_scratch_roots, or ask the operator for a session-scoped disable via claude --settings. The user-global block_hook_bypass_enabled switch is last resort — it persists across every repository." >&2 if [[ "$TOOL_NAME" == "PowerShell" ]]; then echo "$_BYPASS_SCOPE_NOTE_PWSH" >&2 else echo "$_BYPASS_SCOPE_NOTE_BASH" >&2 fi + # Operator-facing levers go on systemMessage (human channel). The blocked + # agent cannot toggle this guard (#3130 F2/F3). + hook::emit_channels PreToolUse "" \ + "guardrails block-hook-bypass blocked a shell file-write. The blocked agent cannot toggle this guard (the switch is not actionable by the blocked agent). Narrower levers, in order: (1) block_hook_bypass_scratch_roots for a target-scoped scratch exemption; (2) session-scoped claude --settings; (3) user-global block_hook_bypass_enabled via /plugin configure — that option is user-scoped and persists in every repository where guardrails is enabled. Re-enable it when the bypass is no longer needed." emit_tel "blocked" "$form" exit 2 } diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 95fed43be6..f12a300f52 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -511,6 +511,18 @@ run "here-string alone (allowed)" "grep foo <<< \"haystack\"" 0 # --- Kill switch — disabled path is a clean no-op even on a bypass ---------- run "kill switch off → no-op despite cat > file" "cat > foo.txt" 0 \ CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_ENABLED=false +# #3130 F7: a typo must not silently disable. Only exact true/false. +run "kill switch typo YES stays enabled (blocked)" "cat > foo.txt" 2 \ + CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_ENABLED=YES +typo_out=$(env CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_ENABLED=YES \ + bash "$HOOK" <<<"$(command_json "cat > foo.txt")" 2>&1) +assert_contains "typo enable value is named" "$typo_out" "not exactly true or false" +# #3130 F5: crash fail-open with a visible notice. +crash_out=$(env BLOCK_HOOK_BYPASS_TEST_CRASH=1 \ + bash "$HOOK" <<<"$(command_json "cat > foo.txt")" 2>&1) +crash_rc=$? +assert_exit "crash path fails open" 0 "$crash_rc" +assert_contains "crash path names guard did not run" "$crash_out" "guard did not run" # --- Telemetry: block emits a `blocked` envelope ---------------------------- TEL="$(mktemp "$TEST_TMPDIR/tmp.XXXXXXXXXX")" @@ -1189,10 +1201,12 @@ assert_contains "PS write block names Write/Edit" "$psout" "Write or Edit tool" assert_absent "PS write block message is shell-agnostic" "$psout" "Bash file-write" assert_contains "PS write block names kill switch" "$psout" "block_hook_bypass_enabled" assert_contains "PS write block names isolated Write/Edit refusal" "$psout" "main checkout" +assert_contains "PS write block names scratch_roots remedy" "$psout" "block_hook_bypass_scratch_roots" +assert_contains "PS write block names session --settings" "$psout" "--settings" assert_contains "PS write block marks kill switch operator-only" "$psout" "not actionable by the blocked agent" assert_contains "PS write block warns kill switch is user-scoped" "$psout" "user-scoped" assert_contains "PS write block warns kill switch persists across repositories" "$psout" "every repository" -assert_contains "PS write block tells operator to re-enable kill switch" "$psout" "re-enable it" +assert_contains "PS write block tells operator to re-enable kill switch" "$psout" "Re-enable it" # --- Enforcement-scope disclosure ------------------------------------------- # The message asserted "use Write or Edit instead" with no scope, so it read as @@ -1202,12 +1216,14 @@ assert_contains "PS write block tells operator to re-enable kill switch" "$psout scopeout=$(bash "$HOOK" <<<"$(command_json "printf 'x' > out.log")" 2>&1) assert_contains "bash block names kill switch" "$scopeout" "block_hook_bypass_enabled" assert_contains "bash block names isolated Write/Edit refusal" "$scopeout" "main checkout" +assert_contains "bash block names scratch_roots remedy" "$scopeout" "block_hook_bypass_scratch_roots" +assert_contains "bash block names session --settings" "$scopeout" "--settings" assert_contains "bash block marks kill switch operator-only" "$scopeout" \ "not actionable by the blocked agent" assert_contains "bash block warns kill switch is user-scoped" "$scopeout" "user-scoped" assert_contains "bash block warns kill switch persists across repositories" "$scopeout" \ "every repository" -assert_contains "bash block tells operator to re-enable kill switch" "$scopeout" "re-enable it" +assert_contains "bash block tells operator to re-enable kill switch" "$scopeout" "Re-enable it" assert_contains "bash block states its scope" "$scopeout" \ "only this command string is inspected" assert_contains "bash block names the invoked-script gap" "$scopeout" \ From 560a516aebdead234fdf193bcaac94e1c69590b1 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:15:12 +0000 Subject: [PATCH 2/2] fix(guardrails): crash test without production kill switch; stderr remedies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove BLOCK_HOOK_BYPASS_TEST_CRASH from the shipped hook. Exercise the fail-open path via a hook copy. Put isolated-session operator levers on stderr — systemMessage is an exit-0 field and is discarded on exit 2. Closes #3130 Co-authored-by: Kyle Sexton --- plugins/guardrails/CHANGELOG.md | 5 ++-- plugins/guardrails/README.md | 4 ++- plugins/guardrails/hooks/block-hook-bypass.sh | 15 ++++------ .../hooks/block-hook-bypass.test.sh | 28 +++++++++++++++++-- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 306f4ac5b9..6f4c700afb 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -11,8 +11,9 @@ All notable changes to the `guardrails` plugin are documented here. Format follo parse (#3130).** The block message no longer dead-ends when Write/Edit is refused in an isolated session — it names `block_hook_bypass_scratch_roots` and session-scoped `--settings` ahead of the user-global switch. The - operator-only sentence moves to `systemMessage`. An internal crash still - fails open (availability on the session's hottest path) but now emits a + operator-only sentence is on stderr (exit 2 discards `systemMessage`) and + is also emitted on `systemMessage` for hosts that parse it. An internal crash + still fails open (availability on the session's hottest path) but now emits a dual-channel "guard did not run" notice. `block_hook_bypass_enabled` accepts only exact `true`/`false` (unset → true); any other value keeps the guard on and says so. README records the 60s `hooks.json` timeout fail-open and diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index f58f6f389f..eb3a46c080 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -138,7 +138,9 @@ out of scope until such a signal exists. `block_hook_bypass_scratch_roots` directory, or ask the operator for a session-scoped disable via `claude --settings`. The user-global `block_hook_bypass_enabled` switch is last resort — it persists across every - repository where guardrails is enabled. + repository where guardrails is enabled. Those levers are printed on stderr + (Claude Code surfaces an exit-2 stderr reason; `systemMessage` is an exit-0 + field and is discarded on a block). - **`block-hook-bypass` fails open on its own crash.** An internal script error exits 0 so a defect on this hottest-path hook cannot freeze the session, and emits a dual-channel "guard did not run" notice so the allow is not silent. diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 978ec060a5..3fc02cf43b 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -54,11 +54,6 @@ block_hook_bypass_on_exit() { } trap block_hook_bypass_on_exit EXIT -# Test injection: trip the crash path without depending on a real script error. -if [[ "${BLOCK_HOOK_BYPASS_TEST_CRASH:-}" == "1" ]]; then - exit 99 -fi - # Strict-and-loud enable (#3130 F7). hook::check_enabled treats any value other # than exact "true" as off, so a typo would silently disable a blocking safety # control. Only true/false (unset → true) are accepted; anything else keeps the @@ -1193,18 +1188,20 @@ seen." block_bypass() { local form="$1" reason="$2" + # Operator levers live on stderr. systemMessage is an exit-0 JSON field + # (docs/conventions/hook-observability); Claude Code discards it on exit 2. + # Keep the same text on systemMessage for any host that does parse it. + local operator_msg="guardrails block-hook-bypass blocked a shell file-write. The blocked agent cannot toggle this guard (the switch is not actionable by the blocked agent). Narrower levers, in order: (1) block_hook_bypass_scratch_roots for a target-scoped scratch exemption; (2) session-scoped claude --settings; (3) user-global block_hook_bypass_enabled via /plugin configure — that option is user-scoped and persists in every repository where guardrails is enabled. Re-enable it when the bypass is no longer needed." echo "BLOCKED: $reason" >&2 echo "Use the Write or Edit tool instead of a shell file-write workaround." >&2 echo "If Write or Edit is refused for a path in the main checkout (isolated session / worktree), write under a directory listed in block_hook_bypass_scratch_roots, or ask the operator for a session-scoped disable via claude --settings. The user-global block_hook_bypass_enabled switch is last resort — it persists across every repository." >&2 + echo "$operator_msg" >&2 if [[ "$TOOL_NAME" == "PowerShell" ]]; then echo "$_BYPASS_SCOPE_NOTE_PWSH" >&2 else echo "$_BYPASS_SCOPE_NOTE_BASH" >&2 fi - # Operator-facing levers go on systemMessage (human channel). The blocked - # agent cannot toggle this guard (#3130 F2/F3). - hook::emit_channels PreToolUse "" \ - "guardrails block-hook-bypass blocked a shell file-write. The blocked agent cannot toggle this guard (the switch is not actionable by the blocked agent). Narrower levers, in order: (1) block_hook_bypass_scratch_roots for a target-scoped scratch exemption; (2) session-scoped claude --settings; (3) user-global block_hook_bypass_enabled via /plugin configure — that option is user-scoped and persists in every repository where guardrails is enabled. Re-enable it when the bypass is no longer needed." + hook::emit_channels PreToolUse "" "$operator_msg" emit_tel "blocked" "$form" exit 2 } diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index f12a300f52..e0493971c0 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -517,9 +517,20 @@ run "kill switch typo YES stays enabled (blocked)" "cat > foo.txt" 2 \ typo_out=$(env CLAUDE_PLUGIN_OPTION_BLOCK_HOOK_BYPASS_ENABLED=YES \ bash "$HOOK" <<<"$(command_json "cat > foo.txt")" 2>&1) assert_contains "typo enable value is named" "$typo_out" "not exactly true or false" -# #3130 F5: crash fail-open with a visible notice. -crash_out=$(env BLOCK_HOOK_BYPASS_TEST_CRASH=1 \ - bash "$HOOK" <<<"$(command_json "cat > foo.txt")" 2>&1) +# #3130 F5: crash fail-open with a visible notice. Trip it on a copy of the +# shipped hook — a production env-var kill switch would fail the guard open +# for any session that happened to carry that name. +if grep -Fqe 'BLOCK_HOOK_BYPASS_TEST_CRASH' "$HOOK"; then + bad "shipped hook must not honor BLOCK_HOOK_BYPASS_TEST_CRASH" +fi +CRASH_DIR="$TEST_TMPDIR/crash-hook" +mkdir -p "$CRASH_DIR" +cp "$HOOK_DIR/hook-utils.sh" "$CRASH_DIR/hook-utils.sh" +awk ' + { print } + $0 == "trap block_hook_bypass_on_exit EXIT" { print "exit 99" } +' "$HOOK" >"$CRASH_DIR/block-hook-bypass.sh" +crash_out=$(bash "$CRASH_DIR/block-hook-bypass.sh" <<<"$(command_json "cat > foo.txt")" 2>&1) crash_rc=$? assert_exit "crash path fails open" 0 "$crash_rc" assert_contains "crash path names guard did not run" "$crash_out" "guard did not run" @@ -1224,6 +1235,17 @@ assert_contains "bash block warns kill switch is user-scoped" "$scopeout" "user- assert_contains "bash block warns kill switch persists across repositories" "$scopeout" \ "every repository" assert_contains "bash block tells operator to re-enable kill switch" "$scopeout" "Re-enable it" +# Exit-2 hosts discard systemMessage; the operator levers must survive on stderr. +scope_err="$TEST_TMPDIR/block-stderr.txt" +bash "$HOOK" <<<"$(command_json "printf 'x' > out.log")" >/dev/null 2>"$scope_err" || true +scope_err_txt=$(cat "$scope_err") +assert_contains "stderr carries isolated Write/Edit refusal" "$scope_err_txt" "main checkout" +assert_contains "stderr carries scratch_roots remedy" "$scope_err_txt" "block_hook_bypass_scratch_roots" +assert_contains "stderr carries session --settings" "$scope_err_txt" "--settings" +assert_contains "stderr marks kill switch operator-only" "$scope_err_txt" \ + "not actionable by the blocked agent" +assert_contains "stderr warns kill switch is user-scoped" "$scope_err_txt" "user-scoped" +assert_contains "stderr tells operator to re-enable kill switch" "$scope_err_txt" "Re-enable it" assert_contains "bash block states its scope" "$scopeout" \ "only this command string is inspected" assert_contains "bash block names the invoked-script gap" "$scopeout" \