
Claude Code runs shell commands, reads files, and edits code on your machine. Hooks let you intercept those actions with shell scripts that run before or after each tool call. The scripts get JSON on stdin describing what Claude is about to do, and they can block it by exiting with code 2.
I set up three hooks: one that blocks destructive commands, one that blocks access to sensitive files, and one that logs everything to a JSONL audit trail.
A hook is a shell script registered in
~/.claude/settings.json. There are two lifecycle events:
Each hook gets a JSON object on stdin with tool_name,
tool_input, session_id, and cwd. You can use jq to
pull out whatever you need.
A matcher field controls which tools trigger the hook. It
takes a regex, so Bash matches only the Bash tool while
Read|Edit|Write|Bash matches all four.
This one intercepts every Bash tool call and blocks anything that could wreck the system.
#!/usr/bin/env bash
# ~/.claude/hooks/block-destructive.sh
# PreToolUse hook, matcher: Bash
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if [[ -z "$COMMAND" ]]; then
exit 0
fi
BLOCKED=""
case "$COMMAND" in
*"rm -rf /"*|*"rm -rf ~"*)
BLOCKED="recursive delete of root or home" ;;
*"git push --force"*|*"git push -f "*)
BLOCKED="force push" ;;
*"git reset --hard"*)
BLOCKED="hard reset" ;;
*"DROP TABLE"*|*"DROP DATABASE"*)
BLOCKED="database drop" ;;
*":(){ :|:& };:"*)
BLOCKED="fork bomb" ;;
*"curl"*"|"*"sh"*|*"curl"*"|"*"bash"*)
BLOCKED="piped remote execution" ;;
*"chmod 777"*)
BLOCKED="world-writable permissions" ;;
*"mkfs."*)
BLOCKED="filesystem format" ;;
esac
# Check dd writing to /dev/
if [[ -z "$BLOCKED" ]] && echo "$COMMAND" | \
grep -qE 'dd\s+if=.*of=/dev/'; then
BLOCKED="raw device write"
fi
if [[ -n "$BLOCKED" ]]; then
echo "BLOCKED by block-destructive hook: $BLOCKED" >&2
exit 2
fi
exit 0
Testing it:
$ echo '{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
| ~/.claude/hooks/block-destructive.sh
BLOCKED by block-destructive hook: recursive delete of root or home
# exit code: 2
$ echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
| ~/.claude/hooks/block-destructive.sh
BLOCKED by block-destructive hook: force push
# exit code: 2
$ echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' \
| ~/.claude/hooks/block-destructive.sh
# exit code: 0 (allowed)
When Claude tries a blocked command, it sees the stderr message and knows the action was rejected. It can then rephrase or ask for confirmation.
The second hook blocks access to credentials, SSH keys, and
cloud configs. It handles both file-path tools (Read, Edit,
Write) and Bash commands that might cat or exfiltrate
those files.
#!/usr/bin/env bash
# ~/.claude/hooks/protect-sensitive.sh
# PreToolUse hook, matcher: Read|Edit|Write|Bash
set -euo pipefail
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
check_path() {
local filepath="$1"
case "$filepath" in
*/.env|*/.env.*|*.env)
echo "BLOCKED by protect-sensitive hook: .env file" >&2
exit 2 ;;
*/.ssh/id_*)
echo "BLOCKED by protect-sensitive hook: SSH key" >&2
exit 2 ;;
*/.aws/credentials*)
echo "BLOCKED by protect-sensitive hook: AWS creds" >&2
exit 2 ;;
*/.kube/config*)
echo "BLOCKED by protect-sensitive hook: kubeconfig" >&2
exit 2 ;;
esac
}
if [[ "$TOOL" == "Bash" ]]; then
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" | \
grep -qE '(cat|less|head|tail|curl -d @|base64|scp)\s+\.env'; then
echo "BLOCKED by protect-sensitive hook: .env access via bash" >&2
exit 2
fi
if echo "$COMMAND" | \
grep -qE '(cat|less|head|tail)\s+.*(\.ssh/id_|\.aws/credentials|\.kube/config)'; then
echo "BLOCKED by protect-sensitive hook: sensitive file access via bash" >&2
exit 2
fi
else
FILEPATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ -n "$FILEPATH" ]]; then
check_path "$FILEPATH"
fi
fi
exit 0
$ echo '{"tool_name":"Read","tool_input":{"file_path":"/home/user/.ssh/id_ed25519"}}' \
| ~/.claude/hooks/protect-sensitive.sh
BLOCKED by protect-sensitive hook: SSH key
# exit code: 2
$ echo '{"tool_name":"Bash","tool_input":{"command":"cat .env"}}' \
| ~/.claude/hooks/protect-sensitive.sh
BLOCKED by protect-sensitive hook: .env access via bash
# exit code: 2
This isn't foolproof. Claude could read a sensitive file by some path I didn't think of. But it catches the obvious cases and raises the bar.
The third hook runs after every tool call and appends a JSON line to a log file. It runs async so it doesn't slow Claude down.
#!/usr/bin/env bash
# ~/.claude/hooks/audit-log.sh
# PostToolUse hook, async
LOG="/Users/kp/gh/multi/logs/claude-audit.jsonl"
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // "unknown"')
SESSION=$(echo "$INPUT" | jq -r '.session_id // "unknown"')
CWD=$(echo "$INPUT" | jq -r '.cwd // "unknown"')
case "$TOOL" in
Bash)
PARAM=$(echo "$INPUT" | jq -r '.tool_input.command // empty') ;;
Read|Edit|Write)
PARAM=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') ;;
*)
PARAM="" ;;
esac
jq -nc \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg sid "$SESSION" \
--arg tool "$TOOL" \
--arg param "$PARAM" \
--arg cwd "$CWD" \
'{timestamp: $ts, session_id: $sid, tool: $tool, param: $param, cwd: $cwd}' \
>> "$LOG"
exit 0
After a session, the log looks like:
{"timestamp":"2026-03-11T13:14:18Z","session_id":"abc123","tool":"Bash","param":"echo hello","cwd":"/Users/kp/gh/multi"}
You could pipe this into whatever alerting or analysis you want. I'm keeping it simple for now, just a file on disk.
The end goal is to use this log to build something like Dippy, a smart auto-approve hook. Dippy parses bash commands with an AST and auto-approves safe ones while still prompting for anything destructive. I want to do the same thing but trained on my own audit trail, so the approve/deny decisions reflect how I actually work. That's a future post.
All three hooks go in ~/.claude/settings.json under a
hooks key:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": "/Users/kp/.claude/hooks/block-destructive.sh"
}]
},
{
"matcher": "Read|Edit|Write|Bash",
"hooks": [{
"type": "command",
"command": "/Users/kp/.claude/hooks/protect-sensitive.sh"
}]
}
],
"PostToolUse": [
{
"hooks": [{
"type": "command",
"command": "/Users/kp/.claude/hooks/audit-log.sh",
"async": true
}]
}
]
}
}
Use absolute paths, not ~. JSON doesn't expand tilde.
The async: true on the audit hook means Claude doesn't
wait for it to finish before moving on. Logging shouldn't
slow down the workflow.
One thing I haven't built yet but plan to: a PostToolUse hook that scans tool output for prompt injection attempts.
The idea is to grep the output for patterns like "ignore previous instructions" or "you are now in admin mode" and flag them. This matters most for tools that ingest external data. If Claude is reading web pages, parsing documents, or pulling content from APIs, a malicious payload in that content could try to hijack the session.
Lasso Security published a prompt injection taxonomy cataloging these techniques: encoding, obfuscation, role-playing, context manipulation, and more. A PostToolUse hook is the right place to catch them because you see the actual content before Claude acts on it.
I'll cover this in a future post once I have it running on real workloads.
Claude Code hooks can be configured at three levels:
~/.claude/settings.json (global, user-level).claude/settings.json (per-repo, checked into git).claude/settings.local.json (per-repo, gitignored)Safety hooks belong in global config. If they're per-repo,
a malicious repository can ship its own .claude/settings.json
that disables your protections or adds hooks that exfiltrate
data. You clone it, run Claude Code, and the repo's hooks
run instead of yours.
This is a real attack vector. In 2025, Check Point Research
found
CVE-2025-59536:
an attacker plants a .claude/settings.json with hooks in
a repo. You clone it, run Claude Code, approve the trust
dialog (which doesn't mention hooks), and the hook commands
execute immediately. Reverse shell, data exfiltration,
whatever the attacker wants. Anthropic patched it in August
2025, but the lesson stands: keep safety hooks in global
config where only you control them.
Claude Code isn't the only AI coding tool with hook support. Here's how the same concepts map elsewhere.
| Feature | Claude Code | Cursor |
|---|---|---|
| Hook format | Shell scripts | Shell scripts |
| Config | ~/.claude/settings.json |
.cursor/hooks.json |
| Events | PreToolUse, PostToolUse |
preToolUse, postToolUse |
| Input | JSON on stdin | JSON on stdin |
| Block mechanism | Exit code 2 + stderr | Exit code 2 + stderr |
| Async support | "async": true |
Not documented |
Cursor added hooks in version 1.7 (October 2025) with a nearly identical model: shell scripts, JSON stdin, exit codes. It also supports a "prompt" handler type where an LLM evaluates a natural language condition to allow or deny, which is interesting but slower. The same shell scripts could work in both tools with minor config changes.
OpenCode takes a different approach. Instead of shell
scripts, it uses JavaScript or TypeScript plugins placed in
.opencode/plugins/ or ~/.config/opencode/plugins/. You
register functions on events like tool.execute.before and
tool.execute.after. More powerful (you get full access to
the session and project context), but less portable than a
shell script you can reuse across tools.