mirror of
https://github.com/luongnv89/claude-howto.git
synced 2026-06-01 10:31:33 +02:00
fix(hooks): correct pre-tool-check.sh hook protocol bugs (#72)
The pre-tool-check.sh example had three bugs rooted in the same
misunderstanding of the Claude Code PreToolUse hook protocol
(stdin/stdout/stderr + exit code contract):
1. Substring matching on `rm -rf /`
The pattern was unanchored, so grep treated it as a substring and
falsely blocked any command containing `rm -rf /` — including benign
calls like `rm -rf /tmp/build` or `rm -rf /var/cache/foo`. Fixed by
anchoring the slash to a whitespace-or-end-of-line boundary.
2. WARN tier was dead code
The warning layer printed to stderr and then `exit 0`. Claude Code
silently discards stderr on exit 0, so the warnings were never seen
by Claude, the user, or any log. Fixed by adding an audit log file
at `$CLAUDE_PROJECT_DIR/.claude/hooks/audit.log` that records every
invocation with its decision (BLOCK/WARN/ALLOW). The audit log is
now the reliable observability mechanism for the WARN tier.
3. BLOCK reasons printed to stdout instead of stderr
On `exit 2`, Claude Code reads stderr to surface the block reason
to Claude. The echoes before `exit 2` defaulted to stdout, so
Claude Code reported `"No stderr output"` and Claude had to read
the hook source file to infer why a command was blocked. Fixed by
explicitly redirecting the block-reason echoes to stderr with `>&2`.
Also escaped the regex metacharacters in the fork-bomb pattern
`:(){:|:&};:` so it matches literally under `grep -E`, and updated the
header docstring to document the stdout/stderr/exit-code convention so
future readers don't make the same mistakes.
Verified with 6 smoke tests covering: benign command (ALLOW), warn-tier
relative path, substring edge case (`rm -rf /tmp/...` no longer falsely
blocked), exact root match (`rm -rf /`, `rm -rf / ; echo` still blocked),
fork-bomb literal, and `git push --force` (WARN only). stdout is empty
in all cases; all reasons correctly routed to stderr or the audit log.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+45
-11
@@ -29,7 +29,18 @@
|
|||||||
# Input: JSON via stdin with the shape:
|
# Input: JSON via stdin with the shape:
|
||||||
# { "tool_name": "Bash", "tool_input": { "command": "..." } }
|
# { "tool_name": "Bash", "tool_input": { "command": "..." } }
|
||||||
#
|
#
|
||||||
# Output: Exit 0 to allow, exit 2 to block, or print JSON to modify behavior.
|
# Output convention (per Claude Code hook protocol):
|
||||||
|
# - exit 0 → allow. stdout may contain JSON (hookSpecificOutput); stderr
|
||||||
|
# is silently discarded, so warnings printed to stderr are NOT visible.
|
||||||
|
# For observability on allowed commands, write to an audit log file.
|
||||||
|
# - exit 2 → block. stderr is surfaced back to Claude as the block reason.
|
||||||
|
# Any echo explaining *why* a command was blocked MUST be redirected to
|
||||||
|
# stderr with `>&2`, otherwise Claude Code reports "No stderr output".
|
||||||
|
#
|
||||||
|
# Audit log: every invocation is recorded to
|
||||||
|
# $CLAUDE_PROJECT_DIR/.claude/hooks/audit.log
|
||||||
|
# with the decision (BLOCK/WARN/ALLOW), so you can observe WARN-tier
|
||||||
|
# matches even though their stderr output is dropped by Claude Code.
|
||||||
|
|
||||||
# Read the full JSON input from stdin
|
# Read the full JSON input from stdin
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
@@ -42,24 +53,41 @@ if [ -z "$COMMAND" ]; then
|
|||||||
COMMAND="$INPUT"
|
COMMAND="$INPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Audit log ─────────────────────────────────────────────────────────────────
|
||||||
|
# Records every invocation with the final decision. This is the only reliable
|
||||||
|
# way to observe the WARN tier, because Claude Code silently drops stderr on
|
||||||
|
# exit 0. Falls back to $(pwd) when the hook is invoked outside Claude Code
|
||||||
|
# (e.g. for local testing).
|
||||||
|
LOG_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}/.claude/hooks"
|
||||||
|
LOG_FILE="$LOG_DIR/audit.log"
|
||||||
|
mkdir -p "$LOG_DIR" 2>/dev/null
|
||||||
|
log_decision() {
|
||||||
|
echo "$(date -u +%FT%TZ) [$1] $COMMAND" >> "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Blocked patterns ──────────────────────────────────────────────────────────
|
# ── Blocked patterns ──────────────────────────────────────────────────────────
|
||||||
# These commands are blocked unconditionally because they are almost always
|
# These commands are blocked unconditionally because they are almost always
|
||||||
# destructive and rarely intentional in an automated context.
|
# destructive and rarely intentional in an automated context.
|
||||||
|
|
||||||
BLOCKED_PATTERNS=(
|
BLOCKED_PATTERNS=(
|
||||||
"rm -rf /"
|
# Anchor `rm -rf /` so `/` must be followed by whitespace or end of line,
|
||||||
|
# otherwise substring matching would falsely flag e.g. `rm -rf /tmp/foo`.
|
||||||
|
"rm -rf /([[:space:]]|$)"
|
||||||
"rm -rf \*"
|
"rm -rf \*"
|
||||||
"dd if=/dev/zero"
|
"dd if=/dev/zero"
|
||||||
"dd if=/dev/random"
|
"dd if=/dev/random"
|
||||||
":(){:|:&};:" # Fork bomb
|
":\(\)\{:\|:&\};:" # Fork bomb (regex metachars escaped)
|
||||||
"mkfs\." # Filesystem format
|
"mkfs\." # Filesystem format
|
||||||
"format c:" # Windows disk format
|
"format c:" # Windows disk format
|
||||||
)
|
)
|
||||||
|
|
||||||
for pattern in "${BLOCKED_PATTERNS[@]}"; do
|
for pattern in "${BLOCKED_PATTERNS[@]}"; do
|
||||||
if echo "$COMMAND" | grep -qE "$pattern"; then
|
if echo "$COMMAND" | grep -qE "$pattern"; then
|
||||||
echo "❌ Blocked: Potentially destructive command detected: $pattern"
|
log_decision "BLOCK:$pattern"
|
||||||
echo " Command: $COMMAND"
|
# These echoes MUST go to stderr — Claude Code surfaces stderr as the
|
||||||
|
# block reason on exit 2. Writing to stdout would show "No stderr output".
|
||||||
|
echo "❌ Blocked: Potentially destructive command detected: $pattern" >&2
|
||||||
|
echo " Command: $COMMAND" >&2
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -79,17 +107,23 @@ WARNING_PATTERNS=(
|
|||||||
"truncate"
|
"truncate"
|
||||||
)
|
)
|
||||||
|
|
||||||
WARNINGS=0
|
MATCHED_WARNINGS=""
|
||||||
for pattern in "${WARNING_PATTERNS[@]}"; do
|
for pattern in "${WARNING_PATTERNS[@]}"; do
|
||||||
if echo "$COMMAND" | grep -qi "$pattern"; then
|
if echo "$COMMAND" | grep -qi "$pattern"; then
|
||||||
echo "⚠️ Warning: High-risk operation detected: $pattern"
|
MATCHED_WARNINGS="${MATCHED_WARNINGS:+$MATCHED_WARNINGS,}$pattern"
|
||||||
WARNINGS=$((WARNINGS + 1))
|
# Mirror the warning on stderr for humans running the hook manually.
|
||||||
|
# Claude Code drops this on exit 0 — the audit log is the reliable
|
||||||
|
# record (see WARN entries).
|
||||||
|
echo "⚠️ Warning: High-risk operation detected: $pattern" >&2
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$WARNINGS" -gt 0 ]; then
|
if [ -n "$MATCHED_WARNINGS" ]; then
|
||||||
echo " Command: $COMMAND"
|
log_decision "WARN:$MATCHED_WARNINGS"
|
||||||
echo " Proceeding — review the above warnings before continuing."
|
echo " Command: $COMMAND" >&2
|
||||||
|
echo " Proceeding — review the above warnings before continuing." >&2
|
||||||
|
else
|
||||||
|
log_decision "ALLOW"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Allow ─────────────────────────────────────────────────────────────────────
|
# ── Allow ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user