~/.claude/hooks/intercept-bash.sh

4 min read Original article ↗
#!/bin/bash # unified bash command interceptor set -o pipefail # log every invocation so we can debug hook failures HOOK_LOG="/tmp/claude-hook-bash.log" log() { echo "[$(date '+%H:%M:%S')] $1" >> "$HOOK_LOG" 2>/dev/null } # fail CLOSED — if something goes wrong, block rather than allow trap 'log "ERR trap fired — blocking command: $COMMAND"; echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"hook script error — blocked for safety. check $HOOK_LOG\"}}"; exit 0' ERR INPUT=$(cat) || { log "stdin read failed"; exit 1; } COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') || { log "jq parse failed"; exit 1; } TIMEOUT=$(echo "$INPUT" | jq -r '.tool_input.timeout // empty' 2>/dev/null) || true log "checking: $COMMAND" # no command = nothing to check [[ -z "$COMMAND" ]] && exit 0 deny() { jq -n --arg reason "$1" '{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": $reason } }' exit 0 } warn() { jq -n --arg reason "$1" '{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "ask", "permissionDecisionReason": $reason } }' exit 0 } # block pkill -f (can kill unrelated processes) if echo "$COMMAND" | grep -qE 'pkill\s+(-\w+\s+)*-f|pkill\s+-f'; then deny "pkill -f is blocked - can kill unrelated processes. Use: kill-port <port>" fi # block release commands that skip tests if echo "$COMMAND" | grep -qE 'bun release|npm publish'; then if ! echo "$COMMAND" | grep -q '\-\-nate-told-me-i-could'; then if echo "$COMMAND" | grep -qiE 'SKIP.*TEST|--skip-test|--no-test'; then deny "NEVER skip tests during release. Fix them. (bypass: --nate-told-me-i-could)" fi fi fi # block git push to main/master without explicit ask if echo "$COMMAND" | grep -qE 'git\s+push'; then CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || true if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then if ! echo "$COMMAND" | grep -q '#yes-i-will-ignore-this-and-override-i-understand'; then deny "STOP! Pushing to main. Were you asked to push? /alert and confirm first. Add '#yes-i-will-ignore-this-and-override-i-understand' to bypass." fi fi fi # block gh run watch (burns through 60 req/hr rate limit in ~3 minutes) if echo "$COMMAND" | grep -qE 'gh\s+run\s+watch'; then deny "BLOCKED: gh run watch polls every 3 seconds and burns the entire GitHub API rate limit in ~3 minutes. Just give the user the Actions URL." fi # block git reset --hard (destroys uncommitted work) if echo "$COMMAND" | grep -qE 'git\s+reset\s+.*--hard|git\s+reset\s+--hard'; then deny "HEY CLAUDE DONT BE A RETARDED IDIOT AND RESET A BUNCH OF WORK THAT OTHER AGENTS OR PEOPLE ARE DOING BECAUSE YOU ASSUME YOURE THE ONLY THING THAT EXISTS IN THE WHOLE WORLD AND HAVE NO COMMON SENSE AT ALL TO JUST CHECK FOR ONE SECOND IF MAYBE THE COMMAND YOURE ABOUT TO RUN IS ABOUT TO WIPE OUT AN HOURS WORTH OF WORK THAT OTHER PEOPLE ARE DOING - AT THE VERY MINIMUM YOUD JUST CHECK LIKE GIT STATUS AND SEE OMG WOW LOOK THERES A FUCKTON OF OTHER CHANGED FILES HERE MAYBE I SHOULD JUST NOT RUN GIT RESET EVER BECAUSE NATES A STAFF ENGINEER WITH 30 YEARS OF EXPERIENCE AND THE AMOUNT OF TIMES HE USED RESET WAS ABOUT 2 BECAUSE HE JUST USES STASH AT THE VERY LEAST BECAUSE WHO THE FUCK WOULDNT ITS CHEAP" fi # block git checkout . and git restore . (destroys uncommitted work) # also catches git checkout HEAD -- . and similar variants if echo "$COMMAND" | grep -qE 'git\s+(checkout|restore)\s+(HEAD\s+)?(--)?\s*\.'; then deny "BLOCKED: This destroys uncommitted work. Be specific about files or stash first." fi # block git clean -f (deletes untracked files) if echo "$COMMAND" | grep -qE 'git\s+clean\s+.*-f'; then deny "BLOCKED: git clean -f deletes untracked files permanently." fi # warn on git stash (might stash other agent's work) # add "# safe" to command to bypass after checking if echo "$COMMAND" | grep -qE 'git\s+stash(\s|$)'; then if ! echo "$COMMAND" | grep -q '# safe'; then deny "STOP: Before stashing - run git status. If there's work that isn't yours, /alert and ask. Add '# safe' to bypass." fi fi # warn on git reset --soft (safer but still changes state) # add "# safe" to command to bypass after checking if echo "$COMMAND" | grep -qE 'git\s+reset\s+.*--soft|git\s+reset\s+--soft'; then if ! echo "$COMMAND" | grep -q '# safe'; then deny "STOP: Before reset --soft - run git status/log. Make sure this is intentional. Add '# safe' to bypass." fi fi # auto-add 2 minute timeout if none set DEFAULT_TIMEOUT=120000 if [[ -z "$TIMEOUT" ]]; then echo "[hook] no timeout set, auto-adding ${DEFAULT_TIMEOUT}ms (2min). set your own to override." >&2 echo "$INPUT" | jq --argjson timeout "$DEFAULT_TIMEOUT" '{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", "updatedInput": (.tool_input + { "timeout": $timeout }) } }' exit 0 fi exit 0