← Back to Payloads
Tutorial2026-07-06

Claude Code Hooks That Block Secret Leaks and Auto-Format in 10 Minutes

Three Claude Code hooks that pay for themselves in a week: stop .env and API keys from ever hitting disk, auto-format every file Claude writes, and silently approve noisy read-only commands. Copy-paste setup.
Quick Access
Install command
$ mrt install tutorial
Browse related skills
Claude Code Hooks That Block Secret Leaks and Auto-Format in 10 Minutes

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.

What You Need

  • Claude Code v1.0.30+ (the hook schema stabilized around this build)
  • A project you work in — hooks live in .claude/settings.json (project) or ~/.claude/settings.json (global)
bash
claude --version        # want 1.0.30 or newer
mkdir -p .claude/hooks

No daemon, no SDK, no extra services.

Hook #1: Block Secret Leaks Before They Hit Disk

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:

bash
#!/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 0
bash
chmod +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.

Hook #2: Auto-Format Every File Claude Writes

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:

bash
#!/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 0

Notice the command -v guards. If prettier isn't installed in this repo, the hook silently no-ops instead of spamming stderr.

Hook #3: Auto-Approve the Noisy Read-Only Stuff

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:

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.

Gotchas (Stuff I Hit So You Don't)

  • **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.
  • Stdin is the full event JSON. Tools do not get separate env vars in v1.0.30. Pipe to jq or python3 -c for field extraction. Don't try to eval it.
  • Exit code 2 ≠ exit code 1. Code 2 blocks the tool and feeds stderr to Claude. Code 1 logs the error to you but lets the tool run. Mixing these up is the most common reason "my hook didn't fire."

Going Further

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

Related Dispatches