Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,5 +147,5 @@
"min": 1
}
},
"version": "0.29.9"
"version": "0.29.10"
}
16 changes: 16 additions & 0 deletions plugins/guardrails/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
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 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
MCP-provided write tools as known residuals.

## [0.29.9]

### Changed
Expand Down
24 changes: 24 additions & 0 deletions plugins/guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,30 @@ 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. 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.
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
Expand Down
41 changes: 38 additions & 3 deletions plugins/guardrails/hooks/block-hook-bypass.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,37 @@ 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

# 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
Expand Down Expand Up @@ -1158,15 +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 "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
echo "$operator_msg" >&2
if [[ "$TOOL_NAME" == "PowerShell" ]]; then
echo "$_BYPASS_SCOPE_NOTE_PWSH" >&2
else
echo "$_BYPASS_SCOPE_NOTE_BASH" >&2
fi
hook::emit_channels PreToolUse "" "$operator_msg"
emit_tel "blocked" "$form"
exit 2
}
Expand Down
42 changes: 40 additions & 2 deletions plugins/guardrails/hooks/block-hook-bypass.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,29 @@ 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. 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"

# --- Telemetry: block emits a `blocked` envelope ----------------------------
TEL="$(mktemp "$TEST_TMPDIR/tmp.XXXXXXXXXX")"
Expand Down Expand Up @@ -1189,10 +1212,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
Expand All @@ -1202,12 +1227,25 @@ 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"
# 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" \
Expand Down