From bce7cf8cb4a8d657d1575f6e740913a4592fab9e Mon Sep 17 00:00:00 2001 From: yarlinghe Date: Sat, 11 Apr 2026 13:48:05 -0700 Subject: [PATCH] fix(hooks): correct pre-tool-check.sh hook protocol bugs (#72) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- 06-hooks/pre-tool-check.sh | 56 ++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/06-hooks/pre-tool-check.sh b/06-hooks/pre-tool-check.sh index 932395e..f2dbdc0 100644 --- a/06-hooks/pre-tool-check.sh +++ b/06-hooks/pre-tool-check.sh @@ -29,7 +29,18 @@ # Input: JSON via stdin with the shape: # { "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 INPUT=$(cat) @@ -42,24 +53,41 @@ if [ -z "$COMMAND" ]; then COMMAND="$INPUT" 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 ────────────────────────────────────────────────────────── # These commands are blocked unconditionally because they are almost always # destructive and rarely intentional in an automated context. 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 \*" "dd if=/dev/zero" "dd if=/dev/random" - ":(){:|:&};:" # Fork bomb + ":\(\)\{:\|:&\};:" # Fork bomb (regex metachars escaped) "mkfs\." # Filesystem format "format c:" # Windows disk format ) for pattern in "${BLOCKED_PATTERNS[@]}"; do if echo "$COMMAND" | grep -qE "$pattern"; then - echo "❌ Blocked: Potentially destructive command detected: $pattern" - echo " Command: $COMMAND" + log_decision "BLOCK:$pattern" + # 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 fi done @@ -79,17 +107,23 @@ WARNING_PATTERNS=( "truncate" ) -WARNINGS=0 +MATCHED_WARNINGS="" for pattern in "${WARNING_PATTERNS[@]}"; do if echo "$COMMAND" | grep -qi "$pattern"; then - echo "⚠️ Warning: High-risk operation detected: $pattern" - WARNINGS=$((WARNINGS + 1)) + MATCHED_WARNINGS="${MATCHED_WARNINGS:+$MATCHED_WARNINGS,}$pattern" + # 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 done -if [ "$WARNINGS" -gt 0 ]; then - echo " Command: $COMMAND" - echo " Proceeding — review the above warnings before continuing." +if [ -n "$MATCHED_WARNINGS" ]; then + log_decision "WARN:$MATCHED_WARNINGS" + echo " Command: $COMMAND" >&2 + echo " Proceeding — review the above warnings before continuing." >&2 +else + log_decision "ALLOW" fi # ── Allow ─────────────────────────────────────────────────────────────────────