Copy-paste hook configurations from production for pre-commit validation, PII scanning, deploy safeguards, and session logging

Six months ago I kept a sticky note on my monitor. It said: "Did you lint? Did you check the branch? Did you look at what it deleted?"

Every Claude Code session, the same manual checks. Every time Claude edited a file, I would switch to another terminal and run the formatter. Every time it ran a Bash command, I would squint at the output wondering if anything touched production. Every time it tried to commit, I would check I was on the right branch first.

The sticky note was not the problem. The problem was that I was doing a computer's job. Repetitive pattern matching on every action, hundreds of times a day. That is what scripts are for.

Then I found out Claude Code fires events. Every single action. Tool calls, file edits, Bash commands, session starts, session ends. All of them. And you can intercept any of them with a shell script.

The sticky note is gone. Here is what replaced it.

The Event System Nobody Talks About

Claude Code is not just a chat interface that writes code. Under the surface, it runs a lifecycle loop. User sends a prompt. Claude decides which tool to use. The tool executes. Claude reads the result. Repeat until done.

At every transition in that loop, Claude Code fires an event. And you can attach a handler to any of them.

The two events that matter most:

Handlers are registered in .claude/settings.json. They can be shell commands, HTTP endpoints, or LLM prompts. I use shell commands because they are fast, debuggable, and do not require infrastructure.

The entire interface is: Claude Code pipes JSON to your script's stdin. Your script optionally writes JSON to stdout. That is it. If your script exits without output, Claude Code proceeds normally.

Script 1: The Disaster Prevention Script

I wrote this one after Claude tried to rm -rf a build directory that happened to have a symlink into my home folder. Nothing bad happened because I caught it in the permission prompt. But I should not have to catch it. A machine should catch it.

#!/bin/bash
# .claude/hooks/guard.sh
# Blocks destructive commands before they execute

CMD=$(jq -r '.tool_input.command // empty')
[ -z "$CMD" ] && exit 0

# Patterns I never want to run unreviewed
BLOCKED_PATTERNS=(
  'rm -rf'
  'rm -r /'
  'DROP TABLE'
  'DROP DATABASE'
  'truncate '
  '> /dev/sd'
  'mkfs\.'
  'dd if='
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
  if echo "$CMD" | grep -qi "$pattern"; then
    jq -n --arg reason "Blocked: command matches dangerous pattern '$pattern'" '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: $reason
      }
    }'
    exit 0
  fi
done

Eight patterns. Could be eighty. The point is not to enumerate every dangerous command. The point is to catch the ones you have personally almost run by accident. My list reflects my scars. Yours will reflect yours.

The registration in .claude/settings.json:

"PreToolUse": [
  {
    "matcher": "Bash",
    "hooks": [{ "type": "command", "command": ".claude/hooks/guard.sh" }]
  }
]

The matcher is a regex on the tool name. "Bash" means this hook only fires for Bash commands, not file edits or reads. Keeps it fast.

Script 2: The Formatter That Never Forgets

Before this hook, my workflow was: Claude edits file, I notice the formatting is wrong, I ask Claude to fix it, Claude runs the formatter, I review the formatted version. Four steps for something that should be zero steps.

#!/bin/bash
# .claude/hooks/fmt.sh
# Formats files immediately after Claude edits them

TOOL=$(jq -r '.tool_name // empty')
[ "$TOOL" != "Edit" ] && [ "$TOOL" != "Write" ] && exit 0

FP=$(jq -r '.tool_input.file_path // .tool_input.path // empty')
[ -z "$FP" ] || [ ! -f "$FP" ] && exit 0

case "${FP##*.}" in
  js|ts|jsx|tsx|mjs)
    if [ -f node_modules/.bin/prettier ]; then
      node_modules/.bin/prettier --write "$FP" 2>/dev/null
    elif command -v npx &>/dev/null; then
      npx prettier --write "$FP" 2>/dev/null
    fi
    ;;
  rs)
    rustfmt "$FP" 2>/dev/null
    ;;
  py)
    command -v ruff &>/dev/null && ruff format "$FP" 2>/dev/null
    command -v ruff &>/dev/null && ruff check --fix "$FP" 2>/dev/null
    ;;
  go)
    gofmt -w "$FP" 2>/dev/null
    ;;
  css|scss)
    command -v stylelint &>/dev/null && stylelint --fix "$FP" 2>/dev/null
    ;;
esac

exit 0

This version is more thorough than the minimal example you find in documentation. It checks whether the formatter actually exists before calling it. It handles Prettier via local install or npx. It runs both ruff format and ruff check --fix for Python because formatting and linting are different things.

Register it as PostToolUse with an "Edit|Write" matcher. It fires after every file modification. The file is formatted before Claude reads it back or moves to the next edit.

The compound effect is significant. Over a week I measured it. Without the hook, I was spending an average of 4 minutes per session asking Claude to fix formatting issues. With the hook, zero. Across 30 sessions a week, that is two hours reclaimed.

Script 3: The Secret Detector

This one exists because of a near-miss. Claude was debugging an API integration, read a config file containing a test API key, and included the key in its response. The key was for a staging environment and was rotated the next day. But the principle bothered me. Tool outputs flow through the conversation, get cached, potentially get logged. I do not want credentials in that flow.

#!/bin/bash
# .claude/hooks/secrets.sh
# Scans tool output for credential-shaped strings

OUTPUT=$(jq -r '.tool_output // empty')
[ -z "$OUTPUT" ] && exit 0

ALERTS=""

# AWS keys (AKIA...)
echo "$OUTPUT" | grep -qE 'AKIA[0-9A-Z]{16}' && ALERTS="$ALERTS aws_key"

# Generic API key patterns (long hex/base64 after common key names)
echo "$OUTPUT" | grep -qiE '(api_key|apikey|secret_key|access_token|auth_token)["\x27: =]+[A-Za-z0-9+/]{20,}' && ALERTS="$ALERTS api_credential"

# Private key headers
echo "$OUTPUT" | grep -q 'BEGIN.*PRIVATE KEY' && ALERTS="$ALERTS private_key"

# JWT tokens
echo "$OUTPUT" | grep -qE 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.' && ALERTS="$ALERTS jwt_token"

# Connection strings with passwords
echo "$OUTPUT" | grep -qiE '(postgresql|mysql|mongodb|redis)://[^:]+:[^@]+@' && ALERTS="$ALERTS connection_string"

if [ -n "$ALERTS" ]; then
  TS=$(date '+%H:%M:%S')
  TOOL=$(jq -r '.tool_name // unknown')
  echo "[$TS] SECRET_ALERT in $TOOL:$ALERTS" >> .claude/secret-alerts.log
  echo "WARNING: potential credential in $TOOL output ($ALERTS)" >&2
fi

exit 0

This is not a replacement for proper secrets management. It is a tripwire. It catches the obvious patterns: AWS access keys, things that look like API credentials, private key blocks, JWTs, database connection strings with embedded passwords.

When it fires, it logs to .claude/secret-alerts.log and prints a warning to stderr. It does not block anything. Blocking on a false positive would interrupt your flow. Logging on a true positive gives you something to investigate at the end of the session.

I review secret-alerts.log once a day. Most entries are false positives (test fixtures, documentation examples). About once a week, one is real enough to warrant rotating a credential or updating a .gitignore.

Script 4: The Branch Cop

Short, sharp, and born from a genuine mistake. I was on main, asked Claude to push a quick fix, and it ran git push origin main before I registered what was happening. The push went through. CI was green. No damage done. But the commit was not supposed to land on main without a PR.

#!/bin/bash
# .claude/hooks/no-push-main.sh
# Prevents git push to main/master/production branches

CMD=$(jq -r '.tool_input.command // empty')
echo "$CMD" | grep -q 'git push' || exit 0

BRANCH=$(git branch --show-current 2>/dev/null)
PROTECTED="main master production release"

for b in $PROTECTED; do
  if [ "$BRANCH" = "$b" ]; then
    jq -n --arg branch "$BRANCH" '{
      hookSpecificOutput: {
        hookEventName: "PreToolUse",
        permissionDecision: "deny",
        permissionDecisionReason: ("Push to " + $branch + " blocked. Create a feature branch first.")
      }
    }'
    exit 0
  fi
done

# Also block force push to any branch
if echo "$CMD" | grep -qE 'push.*(-f|--force)'; then
  jq -n '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "deny",
      permissionDecisionReason: "Force push blocked. Use --force-with-lease if you must."
    }
  }'
fi

Two guards in one script. No pushing to protected branches. No force pushing to any branch. The second rule is optional but I keep it because force pushes are the kind of thing that should require you to type the command yourself, with full awareness of what you are doing.

Claude handles the denial gracefully. It sees the reason, suggests creating a feature branch, and proceeds on the right track. The hook does not interrupt the session. It redirects it.

Script 5: The Black Box Recorder

Every tool call. Every input. Every output. Timestamped and appended to a log file. This is the hook I most frequently recommend to teams because it answers the question you always ask after something goes wrong: "What exactly did Claude do?"

#!/bin/bash
# .claude/hooks/record.sh
# Logs every tool call to a structured audit file

TS=$(date '+%Y-%m-%d %H:%M:%S')
TOOL=$(jq -r '.tool_name // "unknown"')
SESSION=$(jq -r '.session_id // "unknown"')

# Compact input (one line, no whitespace bloat)
INPUT=$(jq -c '.tool_input // {}')

# Truncate output to prevent log bloat (first 500 chars)
OUTPUT=$(jq -r '.tool_output // empty' | head -c 500)

# Tab-separated for easy parsing with awk/cut
printf '%s\t%s\t%s\t%s\t%s\n' \
  "$TS" "$SESSION" "$TOOL" "$INPUT" "$OUTPUT" \
  >> .claude/session-log.tsv

exit 0

Tab-separated, not JSON. Deliberate choice. TSV files are greppable, sortable, and cuttable with standard Unix tools. Need to see every Bash command from today?

grep "$(date +%Y-%m-%d)" .claude/session-log.tsv | grep "Bash" | cut -f4

Need to count how many file edits Claude made this week?

grep -c "Edit\|Write" .claude/session-log.tsv

The output field is truncated to 500 characters to prevent multi-megabyte log files when Claude reads large files. If you need full output, switch to per-session log files: replace the filename with .claude/logs/session-${SESSION}.tsv.

For teams that need centralised logging, replace the file append with an HTTP hook pointing at your logging infrastructure. The JSON payload Claude Code sends contains everything this script captures, plus additional context like the working directory and tool configuration.

The Combined Settings File

All five scripts, wired together:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/guard.sh" },
          { "type": "command", "command": ".claude/hooks/no-push-main.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/fmt.sh" }
        ]
      },
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/secrets.sh" },
          { "type": "command", "command": ".claude/hooks/record.sh" }
        ]
      }
    ]
  }
}

Copy the JSON into .claude/settings.json. Create the five scripts in .claude/hooks/. Run chmod +x .claude/hooks/*.sh. Done.

PreToolUse hooks run in order. Both guard.sh and no-push-main.sh evaluate every Bash command. If either returns a deny, the command is blocked. PostToolUse hooks also run in order but they cannot block anything; the tool already executed.

The empty matcher on the second PostToolUse entry means secrets.sh and record.sh fire on every tool call, not just file edits. That is intentional. I want to scan Bash output for credentials and I want to log everything, regardless of tool type.

What I Learned Running These for Six Months

The formatter hook pays for itself on day one. Everything else is insurance. The formatter is an immediate, measurable time saving. Install that one first even if you ignore the rest.

False positives in the secret scanner are fine. I would rather review ten false alarms a week than miss one real credential. The log file review takes 30 seconds. Rotating a leaked credential takes an hour.

The guard script needs to evolve. Every few weeks I add a pattern after a near-miss. It started with rm -rf. Now it has eight patterns. In six months it will have twenty. That is the point. It accumulates your team's institutional knowledge about what should never run unreviewed.

The audit log is most valuable after incidents. You rarely read it proactively. But when something goes wrong, having a complete record of every action Claude took turns a two-hour investigation into a five-minute grep.

Keep hooks fast. Every PreToolUse hook adds latency to every tool call. My guard and branch-cop scripts run in under 5ms each. The formatter is the slowest at 50-200ms depending on file size, but it runs PostToolUse so it does not block the pipeline. If your hook calls an external API, use PostToolUse or accept the latency.

The Sticky Note Is Gone

The monitoring that used to live in my head now lives in five shell scripts totalling 150 lines. They run on every tool call without my involvement. They catch things I would miss when I am tired at the end of a long session. They never forget to check the branch.

AI coding assistants are powerful. But power without guardrails is just risk you have not measured yet. Five scripts, ten minutes of setup, and the risk drops to nearly zero.

The sticky note has been replaced by something better. Something that does not rely on me remembering to look at it.