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
@@ -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",
Expand Down
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.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
Expand Down
44 changes: 25 additions & 19 deletions plugins/guardrails/hooks/block-dangerous-git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,16 @@ if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

# Read inherited fd0 directly (bare cat) — NEVER `</dev/stdin`: on Windows Git
# Bash, CC spawns hooks with stdin = a Win32 pipe that `/dev/stdin` cannot
# resolve (ENOENT → silent no-op).
INPUT=$(cat)
# hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read. rc 1
# (empty stdin) skips like the empty-COMMAND guard below; rc 2 (read timed out
# before a complete payload) FAILS CLOSED — the guard cannot evaluate the tool
# call, and a silent skip would pass exactly the traffic this guard exists to
# stop. buffer_stdin already printed the BLOCKED reason to stderr.
INPUT=$(hook::buffer_stdin) || {
rc=$?
((rc == 2)) && exit 2
exit 0
}
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r')
[[ -n "$COMMAND" ]] || exit 0

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
;;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)."
Expand Down Expand Up @@ -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)."
Expand Down Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions plugins/guardrails/hooks/block-hook-bypass.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,16 @@ if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

# Read inherited fd0 directly (bare cat) — NEVER `</dev/stdin`: on Windows Git
# Bash, CC spawns hooks with stdin = a Win32 pipe that `/dev/stdin` cannot
# resolve (ENOENT → silent no-op).
INPUT=$(cat)
# hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read. rc 1
# (empty stdin) skips like the empty-COMMAND guard below; rc 2 (read timed out
# before a complete payload) FAILS CLOSED — the guard cannot evaluate the tool
# call, and a silent skip would pass exactly the traffic this guard exists to
# stop. buffer_stdin already printed the BLOCKED reason to stderr.
INPUT=$(hook::buffer_stdin) || {
rc=$?
((rc == 2)) && exit 2
exit 0
}
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r')
[[ -n "$COMMAND" ]] || exit 0

Expand Down
14 changes: 10 additions & 4 deletions plugins/guardrails/hooks/block-no-verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,16 @@ if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

# Read inherited fd0 directly (bare cat) — NEVER `</dev/stdin`: on Windows Git
# Bash, CC spawns hooks with stdin = a Win32 pipe that `/dev/stdin` cannot
# resolve (ENOENT → silent no-op).
INPUT=$(cat)
# hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read. rc 1
# (empty stdin) skips like the empty-COMMAND guard below; rc 2 (read timed out
# before a complete payload) FAILS CLOSED — the guard cannot evaluate the tool
# call, and a silent skip would pass exactly the traffic this guard exists to
# stop. buffer_stdin already printed the BLOCKED reason to stderr.
INPUT=$(hook::buffer_stdin) || {
rc=$?
((rc == 2)) && exit 2
exit 0
}
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r')
[[ -n "$COMMAND" ]] || exit 0

Expand Down
7 changes: 3 additions & 4 deletions plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,9 @@ if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

# Read inherited fd0 directly (bare cat) — NEVER `</dev/stdin`: on Windows Git
# Bash, CC spawns hooks with stdin = a Win32 pipe that `/dev/stdin` cannot
# resolve (ENOENT → silent no-op).
INPUT=$(cat)
# hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read; empty
# or timed-out stdin skips this advisory hook.
INPUT=$(hook::buffer_stdin) || exit 0
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null | tr -d '\r')
[[ -n "$COMMAND" ]] || exit 0

Expand Down
14 changes: 11 additions & 3 deletions plugins/guardrails/hooks/hardcoded-path-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,17 @@ if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

# Read inherited fd0 directly (bare cat) — NEVER `</dev/stdin` (Windows Git Bash
# Win32-pipe ENOENT → silent no-op). Buffer once; parse each field from it.
INPUT=$(cat)
# hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read; buffer
# once, parse each field from it. rc 1 (empty stdin) skips like the empty-field
# guards below; rc 2 (read timed out before a complete payload) FAILS CLOSED —
# the guard cannot evaluate the tool call, and a silent skip would pass exactly
# the traffic this guard exists to stop. buffer_stdin already printed the
# BLOCKED reason to stderr.
INPUT=$(hook::buffer_stdin) || {
rc=$?
((rc == 2)) && exit 2
exit 0
}
TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null | tr -d '\r')

case "$TOOL" in
Expand Down
14 changes: 11 additions & 3 deletions plugins/guardrails/hooks/secret-pattern-detection.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,17 @@ if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

# Read inherited fd0 directly (bare cat) — NEVER `</dev/stdin` (Windows Git Bash
# Win32-pipe ENOENT → silent no-op). Buffer once; parse each field from it.
INPUT=$(cat)
# hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read; buffer
# once, parse each field from it. rc 1 (empty stdin) skips like the empty-field
# guards below; rc 2 (read timed out before a complete payload) FAILS CLOSED —
# the guard cannot evaluate the tool call, and a silent skip would pass exactly
# the traffic this guard exists to stop. buffer_stdin already printed the
# BLOCKED reason to stderr.
INPUT=$(hook::buffer_stdin) || {
rc=$?
((rc == 2)) && exit 2
exit 0
}
TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null | tr -d '\r')

case "$TOOL" in
Expand Down
7 changes: 3 additions & 4 deletions plugins/guardrails/hooks/workflow-resilience-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@ if ! command -v jq >/dev/null 2>&1; then
exit 0
fi

# Read inherited fd0 directly (bare cat) — NEVER `</dev/stdin`: on Windows Git
# Bash, CC spawns hooks with stdin = a Win32 pipe that `/dev/stdin` cannot
# resolve (ENOENT → silent no-op).
INPUT=$(cat)
# hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read; empty
# or timed-out stdin skips this advisory hook.
INPUT=$(hook::buffer_stdin) || exit 0
SCRIPT=$(printf '%s' "$INPUT" | jq -r '.tool_input.script // empty' 2>/dev/null | tr -d '\r')
SCRIPT_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.scriptPath // empty' 2>/dev/null | tr -d '\r')

Expand Down
Loading