From 5c52fffad5142a382a9a7d926772c57749422912 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:50:09 -0400 Subject: [PATCH] refactor(guardrails): read stdin via hook::buffer_stdin; blocking guards fail closed on timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the bare `INPUT=$(cat)` fd0 read in all seven guardrails entry hooks with the shared `hook::buffer_stdin` helper (bounded read, default 2s), so the Windows Win32-pipe late-EOF stall can no longer hang a hook — and with it every tool call — indefinitely. Timeout semantics are decided per hook class rather than copying the audit-hook `|| exit 0` collapse: - The five blocking guards (block-dangerous-git, block-hook-bypass, block-no-verify, secret-pattern-detection, hardcoded-path-check) FAIL CLOSED on rc 2: a stdin read that timed out before a complete payload means the guard could not evaluate the tool call, and skipping would pass exactly the traffic the guard exists to stop. They exit 2 with buffer_stdin's BLOCKED reason already on stderr. Empty stdin (rc 1) still skips, matching the previous empty-payload behavior. - The two advisory hooks (flag-commit-pr-skill-bypass, workflow-resilience-check) skip on any read failure, as before. guardrails 0.8.0. Epic #313 deferred-backlog item 3 (guardrails half; the 8 advisory plugins shipped in the sibling PR). Co-Authored-By: Claude Fable 5 --- plugins/guardrails/.claude-plugin/plugin.json | 2 +- plugins/guardrails/CHANGELOG.md | 16 +++++++ .../guardrails/hooks/block-dangerous-git.sh | 44 +++++++++++-------- plugins/guardrails/hooks/block-hook-bypass.sh | 14 ++++-- plugins/guardrails/hooks/block-no-verify.sh | 14 ++++-- .../hooks/flag-commit-pr-skill-bypass.sh | 7 ++- .../guardrails/hooks/hardcoded-path-check.sh | 14 ++++-- .../hooks/secret-pattern-detection.sh | 14 ++++-- .../hooks/workflow-resilience-check.sh | 7 ++- 9 files changed, 90 insertions(+), 42 deletions(-) diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index c9deb5e21..bc1e605e1 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "guardrails", - "version": "0.7.1", + "version": "0.8.0", "description": "Eight safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, (advisory) hallucinated CLI flags, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.", "author": { "name": "Melodic Software", diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 6424c16f8..c745861c5 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -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.8.0] + +### Changed + +- All seven hook entry scripts read stdin via the shared `hook::buffer_stdin` helper + (bounded `read -t`, default 2s) instead of a bare `cat`, so a Windows Win32-pipe + late-EOF stall can no longer hang a hook — and with it every tool call — indefinitely. +- **Blocking guards now fail closed on a stdin read timeout.** When `hook::buffer_stdin` + returns 2 (the read timed out before a complete JSON payload arrived), the five + blocking guards (`block-dangerous-git`, `block-hook-bypass`, `block-no-verify`, + `secret-pattern-detection`, `hardcoded-path-check`) exit 2 with the BLOCKED reason on + stderr instead of skipping: a guard that could not evaluate the tool call must not + wave it through. Empty stdin still skips, matching the previous empty-payload + behavior. The two advisory hooks (`flag-commit-pr-skill-bypass`, + `workflow-resilience-check`) skip on any read failure, as before. + ## [0.7.1] ### Fixed diff --git a/plugins/guardrails/hooks/block-dangerous-git.sh b/plugins/guardrails/hooks/block-dangerous-git.sh index cd9796b2d..215bebc29 100755 --- a/plugins/guardrails/hooks/block-dangerous-git.sh +++ b/plugins/guardrails/hooks/block-dangerous-git.sh @@ -49,10 +49,16 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -# Read inherited fd0 directly (bare cat) — NEVER `/dev/null | tr -d '\r') [[ -n "$COMMAND" ]] || exit 0 @@ -119,8 +125,8 @@ abbrev_match() { is_push_value_opt() { local x="$1" [[ "$x" == *=* ]] && return 1 - abbrev_match "push-option" "$x" 2 || abbrev_match "repo" "$x" 3 \ - || abbrev_match "receive-pack" "$x" 4 || abbrev_match "exec" "$x" 1 + abbrev_match "push-option" "$x" 2 || abbrev_match "repo" "$x" 3 || + abbrev_match "receive-pack" "$x" 4 || abbrev_match "exec" "$x" 1 } # Is an operand a worktree-wide pathspec? `.` from the repo root, the @@ -281,8 +287,8 @@ check_segment() { fi ;;& *) - if [[ "$x" == "-n" ]] || abbrev_match "dry-run" "$x" 2 \ - || [[ "$x" =~ ^-[A-Za-z]+$ && "$x" == *n* ]]; then + if [[ "$x" == "-n" ]] || abbrev_match "dry-run" "$x" 2 || + [[ "$x" =~ ^-[A-Za-z]+$ && "$x" == *n* ]]; then dry=1 fi ;; @@ -417,8 +423,8 @@ check_segment() { [[ "$rest" == *n* ]] && dry=1 continue fi - if [[ "$x" == "-n" ]] || abbrev_match "dry-run" "$x" 1 \ - || [[ "$x" =~ ^-[A-Za-z]+$ && "$x" == *n* ]]; then + if [[ "$x" == "-n" ]] || abbrev_match "dry-run" "$x" 1 || + [[ "$x" =~ ^-[A-Za-z]+$ && "$x" == *n* ]]; then dry=1 fi done @@ -521,13 +527,13 @@ check_segment() { # Value-taking options in accepted abbreviated form (--c for # --conflict, --or for --orphan — verified unique floors) consume # the next word, which must not count as an operand. - if [[ "$x" != *=* ]] \ - && { abbrev_match "conflict" "$x" 1 || abbrev_match "orphan" "$x" 2; }; then + if [[ "$x" != *=* ]] && + { abbrev_match "conflict" "$x" 1 || abbrev_match "orphan" "$x" 2; }; then ((k += 2)) continue fi - if [[ "$x" == "-f" ]] || abbrev_match "force" "$x" 1 \ - || [[ "$x" =~ ^-[A-Za-z]+$ && "$x" == *f* ]]; then + if [[ "$x" == "-f" ]] || abbrev_match "force" "$x" 1 || + [[ "$x" =~ ^-[A-Za-z]+$ && "$x" == *f* ]]; then block "checkout-force" \ "BLOCKED: git checkout -f/--force throws away local modifications." \ "Commit or stash first, or allow via the block_dangerous_git_allow option (add checkout-force)." @@ -576,9 +582,9 @@ check_segment() { continue ;; *) - if [[ "$x" == "-f" ]] || abbrev_match "force" "$x" 1 \ - || abbrev_match "discard-changes" "$x" 2 \ - || [[ "$x" =~ ^-[A-Za-z]+$ && "$x" == *f* ]]; then + if [[ "$x" == "-f" ]] || abbrev_match "force" "$x" 1 || + abbrev_match "discard-changes" "$x" 2 || + [[ "$x" =~ ^-[A-Za-z]+$ && "$x" == *f* ]]; then block "checkout-force" \ "BLOCKED: git switch -f/--discard-changes throws away local modifications." \ "Commit or stash first, or allow via the block_dangerous_git_allow option (add checkout-force)." @@ -668,8 +674,8 @@ check_segment() { # Value-taking options in accepted abbreviated form (--so for # --source, --c for --conflict — verified unique floors) consume # the next word, which must not count as a positive pathspec. - if [[ "$x" != *=* ]] \ - && { abbrev_match "source" "$x" 2 || abbrev_match "conflict" "$x" 1; }; then + if [[ "$x" != *=* ]] && + { abbrev_match "source" "$x" 2 || abbrev_match "conflict" "$x" 1; }; then ((k += 2)) continue fi diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index db2e2ddc9..8d33be4a1 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -41,10 +41,16 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -# Read inherited fd0 directly (bare cat) — NEVER `/dev/null | tr -d '\r') [[ -n "$COMMAND" ]] || exit 0 diff --git a/plugins/guardrails/hooks/block-no-verify.sh b/plugins/guardrails/hooks/block-no-verify.sh index 273f5905c..e60e508cd 100755 --- a/plugins/guardrails/hooks/block-no-verify.sh +++ b/plugins/guardrails/hooks/block-no-verify.sh @@ -47,10 +47,16 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -# Read inherited fd0 directly (bare cat) — NEVER `/dev/null | tr -d '\r') [[ -n "$COMMAND" ]] || exit 0 diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index a980b1367..9d665877f 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -59,10 +59,9 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -# Read inherited fd0 directly (bare cat) — NEVER `/dev/null | tr -d '\r') [[ -n "$COMMAND" ]] || exit 0 diff --git a/plugins/guardrails/hooks/hardcoded-path-check.sh b/plugins/guardrails/hooks/hardcoded-path-check.sh index 6133b6ab1..c387c0886 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.sh @@ -42,9 +42,17 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -# Read inherited fd0 directly (bare cat) — NEVER `/dev/null | tr -d '\r') case "$TOOL" in diff --git a/plugins/guardrails/hooks/secret-pattern-detection.sh b/plugins/guardrails/hooks/secret-pattern-detection.sh index 35e80fb93..3f34f4dd4 100755 --- a/plugins/guardrails/hooks/secret-pattern-detection.sh +++ b/plugins/guardrails/hooks/secret-pattern-detection.sh @@ -35,9 +35,17 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -# Read inherited fd0 directly (bare cat) — NEVER `/dev/null | tr -d '\r') case "$TOOL" in diff --git a/plugins/guardrails/hooks/workflow-resilience-check.sh b/plugins/guardrails/hooks/workflow-resilience-check.sh index c98857d38..f741bd5bc 100755 --- a/plugins/guardrails/hooks/workflow-resilience-check.sh +++ b/plugins/guardrails/hooks/workflow-resilience-check.sh @@ -26,10 +26,9 @@ if ! command -v jq >/dev/null 2>&1; then exit 0 fi -# Read inherited fd0 directly (bare cat) — NEVER `/dev/null | tr -d '\r') SCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.scriptPath // empty' 2>/dev/null | tr -d '\r')