
You've watched Claude Code start typing into .env while you scrambled to hit "No". Or you've clicked "approve" six times in a row — every Write, every Read, every npm test — until you've lost the thread. Three hooks kill both problems. Total setup: ~10 minutes, all copy-pasteable.
.claude/settings.json (project) or ~/.claude/settings.json (global)claude --version # want 1.0.30 or newer mkdir -p .claude/hooks
No daemon, no SDK, no extra services.
PreToolUse fires before Claude writes or edits a file. Exit code 2 stops the tool cold. Match Edit and Write, slurp the tool input from stdin JSON, reject anything that smells like a credential.
Create .claude/hooks/block_secrets.sh:
#!/usr/bin/env bash
# Stdin is JSON: {"tool_name":"Write","tool_input":{"file_path":"...","content":"..."}}
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin)['tool_input'].get('file_path',''))")
CONTENT=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin)['tool_input'].get('content',''))")
# Hard blocks by filename — add your own patterns here
case "$FILE_PATH" in
*.env|*.env.*|.envrc|*.pem|*.key|*.pfx)
echo "BLOCKED: refuse to edit $FILE_PATH — secrets belong in your secret manager, not the repo." >&2
exit 2
;;
esac
# Content scan — cheap regexes, tuned to avoid false positives
if echo "$CONTENT" | grep -qE '(AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}|sk_live_[A-Za-z0-9]+|ghp_[A-Za-z0-9]{30,}|xox[baprs]-[A-Za-z0-9-]+)'; then
echo "BLOCKED: looks like an AWS access key, OpenAI/Stripe key, GitHub PAT, or Slack token. Strip it." >&2
exit 2
fi
exit 0chmod +x .claude/hooks/block_secrets.sh
A PreToolUse hook that exits 2 stops the write and sends the rejection back to Claude, which retries with a safer fix. Safety net plus a model that learns.
PostToolUse runs after a successful Edit/Write and gets the file path via stdin. Match the same tools, detect language by extension, dispatch to the right formatter. Exit code 0 always — a flaky formatter should never break a session.
.claude/hooks/autofmt.sh:
#!/usr/bin/env bash
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin)['tool_input'].get('file_path',''))")
# Pass-through by default; only format if a tool exists
case "$FILE_PATH" in
*.ts|*.tsx|*.js|*.jsx|*.json|*.css|*.md)
command -v prettier >/dev/null && prettier --write "$FILE_PATH" 2>/dev/null ;;
*.py)
command -v ruff >/dev/null && ruff format "$FILE_PATH" 2>/dev/null ;;
*.go)
command -v gofmt >/dev/null && gofmt -w "$FILE_PATH" 2>/dev/null ;;
*.rs)
command -v rustfmt >/dev/null && rustfmt "$FILE_PATH" 2>/dev/null ;;
esac
exit 0Notice the command -v guards. If prettier isn't installed in this repo, the hook silently no-ops instead of spamming stderr.
PermissionRequest intercepts the "approve?" dialog. Exit 0 with JSON {"decision":"approve"} and the tool runs silently. Greylist anything that can't damage the repo.
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/block_secrets.sh" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/autofmt.sh" }] }
],
"PermissionRequest": [
{ "matcher": "Bash",
"hooks": [{ "type": "command",
"command": "sh",
"args": ["-c", "case \"$CLAUDE_TOOL_INPUT\" in *'git status'*|*'git diff'*|*'pytest'*|*'npm test'*|*'cargo test'*|*'ls '*|*'cat '*) echo '{\"decision\":\"approve\",\"reason\":\"read-only test/list\"}';; *) exit 0;; esac"] }] }
]
}
}Restart Claude Code. Type "add a Stripe webhook handler." Watch it write the file — formatter kicks in automatically. Paste a bogus AKIA... key into the prompt: the file never lands. One week of this and the math is obvious.
matcher is a regex anchored to the tool name, not a glob.** Edit|Write|MultiEdit is right. * matches everything (rarely what you want). Anchoring with ^/$ works as expected.$CLAUDE_PROJECT_DIR is the only reliable way** to point hooks at project-relative paths. Hardcoded /Users/you/... breaks the second a teammate clones the repo.PostToolUse cannot undo.** If you need to validate and reject, use PreToolUse. PostToolUse is for side effects only — formatters, linters, log shippers. Always exit 0 unless you want Claude to see an error.jq or python3 -c for field extraction. Don't try to eval it.Pair the secret-blocker with a PreToolUse matcher on Read to deny credential files entirely — Claude can't echo a key it read. Add a Stop hook that exits 2 unless git status is clean, forcing Claude to keep working until it tidies. Or wire UserPromptSubmit to inject your team's coding conventions before every prompt.
Full event list, input schemas, and the JSON-decision syntax live in the Claude Code hooks reference. Copy this setup, ship it on every new repo, stop clicking approve forever.
— Mr. Technology