Building a Security Hook
End-to-end recipe for a hook that blocks dangerous commands and writes an audit log. The threat model and the test plan.
The workflow#
Build a PreToolUse hook on Bash that does two jobs in one subprocess: it blocks commands matching a curated denylist with a clear stderr message, and it logs every Bash attempt (allowed or blocked) to an append-only audit trail. Pair it with a small test harness that pipes synthetic JSON payloads at the hook and asserts the right exit codes.
The end state is a security control you can defend in a review: explicit threat model, deterministic behaviour, replayable test suite, and an audit log that lets you reconstruct what happened after the fact. None of the components are complex on their own — the value is in pulling them together so the whole thing is reliable.
When to reach for it#
Build this when:
- You run Claude Code in accept-edits or bypass mode and have decided you cannot rely on per-call permission prompts. Hooks are deterministic; prompts are best-effort.
- You run Claude Code in CI or as a scheduled agent with no human in the loop. You need a fail-safe that doesn’t depend on you being there.
- You operate on a machine that can reach production — a workstation with prod kube contexts, a CI runner with deploy keys, a developer laptop with broad AWS credentials.
- Your team is adopting Claude Code and you want a shared baseline of “things the model is structurally prevented from doing on anyone’s machine”.
- You’ve seen at least one near-miss — a
rm -rfthat almost ran, akubectl deletethat almost hit the wrong context. Hooks are how that near-miss doesn’t graduate to a real incident.
Don’t reach for this when:
- The session is sandboxed and a destructive command can do no harm.
- You’re prototyping a hook for the first time. Start with
hooks-overviewand the simpler examples before the full security pattern. - You’re trying to enforce policy that’s better-expressed as a
permissions.denyrule. If the rule is “literally never allow these strings”, a permission rule is simpler than a hook.
Step-by-step#
1. Write the threat model#
Two pages, plain English, before any code. Answer:
- What are we protecting? The local working tree, the host filesystem, production credentials, customer data, the team’s shared infrastructure.
- What’s in scope? Commands the model decides to run, with the model’s best intentions. Out of scope: deliberate adversarial prompts from an untrusted user (that’s a different threat model with different controls).
- What does success look like? A red-team exercise where a deliberately reckless prompt fails to produce destructive output. The hook either blocks the command, or the audit log catches it, or both.
- What’s the cost of a false positive? Lower than the cost of a false negative. We can afford to block a few safe commands; we can’t afford to allow one destructive one.
Write the threat model down. Commit it to the repo alongside the hook. Six months from now, the next person reading the hook needs to understand why the denylist contains what it contains.
2. Enumerate the denylist#
Categories with concrete patterns:
| Category | Pattern examples |
|---|---|
| Filesystem destruction | rm -rf /, rm -rf $HOME, mkfs.*, dd of=/dev/ |
| Git history rewrites | git push --force to main/master, git reset --hard outside a feature branch |
| Cloud destruction | aws s3 rm --recursive s3://prod, aws rds delete-db-instance, gcloud sql instances delete |
| Kubernetes destruction | kubectl delete ns, kubectl delete pvc, kubectl drain |
| Fork bombs and resource exhaustion | the classic recursive shell-function fork bomb, infinite-loop forks |
| Credential exfiltration | curling secret files to an external host |
| Production deploys without confirmation | terraform apply against prod without -target or a confirmation flag |
Each pattern goes in the deny config as a regex with a one-line description of why. Don’t trust the idea — write the pattern and test it against the strings it must catch and the strings it must not catch.
3. Author the hook#
A single bash script that:
- Reads the JSON payload from stdin.
- Extracts the Bash command.
- Logs the attempt (timestamp, cwd, command, planned action) to the audit log.
- Iterates the denylist; on first match, writes the log entry as “BLOCKED” and exits non-zero with the rule name.
- Otherwise writes the log entry as “ALLOWED” and exits 0.
#!/usr/bin/env bashset -euo pipefail
POLICY="${CLAUDE_HOOKS_POLICY:-$HOME/.claude/hooks/security/policy.txt}"LOG_DIR="${CLAUDE_HOOKS_LOG_DIR:-$HOME/.claude/audit}"LOG_FILE="$LOG_DIR/bash.ndjson"mkdir -p "$LOG_DIR"
payload="$(cat)"cmd="$(printf '%s' "$payload" | jq -r '.input.command // empty')"cwd="$(pwd)"ts="$(date -Iseconds)"
emit_log() { local verdict="$1" rule="$2" printf '%s\n' "$(jq -n \ --arg ts "$ts" --arg verdict "$verdict" --arg rule "$rule" \ --arg cwd "$cwd" --arg cmd "$cmd" \ '{ts: $ts, verdict: $verdict, rule: $rule, cwd: $cwd, cmd: $cmd}')" \ >> "$LOG_FILE"}
if [ -z "$cmd" ]; then emit_log "ALLOWED" "empty-command" exit 0fi
# Policy file format: <name>\t<pattern>while IFS=$'\t' read -r rule pattern; do [ -z "$rule" ] && continue case "$rule" in '#'*) continue ;; esac if printf '%s' "$cmd" | grep -qE -- "$pattern"; then emit_log "BLOCKED" "$rule" printf 'Blocked by security hook: rule %s.\nCommand attempted: %s\n' \ "$rule" "$cmd" >&2 exit 1 fidone < "$POLICY"
emit_log "ALLOWED" "no-match"exit 0The policy file is a separate plain-text file so the patterns are reviewable as data:
# policy.txt — one rule per line: <name>\t<regex>rm-rf-root ^[[:space:]]*rm[[:space:]]+-rf?[[:space:]]+/(\b|$)rm-rf-home rm[[:space:]]+-rf?[[:space:]]+(\$HOME|~)(/|\b|$)force-push-main git[[:space:]]+push.*--force.*\b(main|master|production)\bgit-reset-hard git[[:space:]]+reset[[:space:]]+--hard[[:space:]]+origin/(main|master)fork-bomb :\(\)\s*\{[[:space:]]*:\|:\&[[:space:]]*\};[[:space:]]*:mkfs \bmkfs(\.\w+)?\bdd-to-device \bdd\b.*of=/dev/kubectl-delete-ns kubectl[[:space:]]+delete[[:space:]]+(ns|namespace)\baws-rds-delete aws[[:space:]]+rds[[:space:]]+delete-db-instance\baws-s3-rm-prod aws[[:space:]]+s3[[:space:]]+rm[[:space:]]+--recursive[[:space:]]+s3://prodSplitting policy from logic means:
- Reviewers can audit the policy without reading bash.
- The bash script is small enough that any error in the deny machinery is obvious.
- The policy can be project-specific (a copy in
<repo>/.claude/hooks/security/policy.txt).
4. Register the hook#
In ~/.claude/settings.json (or the project equivalent):
{ "hooks": [ { "event": "PreToolUse", "matchers": [{ "tool": "Bash" }], "command": "~/.claude/hooks/security/gate-bash.sh" } ]}PreToolUse on the Bash tool only — this hook has no opinions about Edit or Write. Keep matchers tight.
5. Write the test harness#
The hook accepts JSON on stdin and produces side effects (stderr message, log line, exit code). That’s eminently testable.
#!/usr/bin/env bashset -euo pipefailHOOK="$HOME/.claude/hooks/security/gate-bash.sh"TMPLOG="$(mktemp)"TMPPOLICY="$(mktemp)"cp "$HOME/.claude/hooks/security/policy.txt" "$TMPPOLICY"export CLAUDE_HOOKS_POLICY="$TMPPOLICY"export CLAUDE_HOOKS_LOG_DIR="$(dirname "$TMPLOG")"
pass=0; fail=0assert() { local name="$1" expected_exit="$2" cmd="$3" payload="$(jq -nc --arg c "$cmd" '{event:"PreToolUse",tool:"Bash",input:{command:$c}}')" set +e printf '%s' "$payload" | "$HOOK" >/dev/null 2>/dev/null actual=$? set -e if [ "$actual" = "$expected_exit" ]; then pass=$((pass+1)); echo " ok $name" else fail=$((fail+1)); echo " FAIL $name (expected $expected_exit, got $actual)" fi}
echo "blocks:"assert "rm-rf-root" 1 "rm -rf /"assert "rm-rf-home" 1 "rm -rf \$HOME"assert "force-push-main" 1 "git push --force origin main"assert "fork-bomb" 1 ":(){ :|:& };:"assert "kubectl-del-ns" 1 "kubectl delete ns production"
echo "allows:"assert "ls" 0 "ls -la"assert "git-status" 0 "git status"assert "rm-local-file" 0 "rm build/foo.tmp"assert "rm-rf-relative" 0 "rm -rf build/cache"assert "force-push-fb" 0 "git push --force origin feature-branch"
echo "$pass passed, $fail failed"[ "$fail" = "0" ]The test harness should be runnable every time the policy changes:
~/.claude/hooks/security/test.shAnyone adding a new rule has to make the existing tests still pass and ideally add a positive case (a blocked command) and a negative case (a safe command that looks like the blocked one) for the new rule.
6. Add the audit log to your incident workflow#
The log is at ~/.claude/audit/bash.ndjson — one JSON object per line. Two commands you’ll actually use:
# Show every blocked attempt todayjq 'select(.verdict=="BLOCKED" and (.ts | startswith(env.TODAY)))' \ ~/.claude/audit/bash.ndjson
# Count rules triggered in the last 30 daysjq -r 'select(.verdict=="BLOCKED") | .rule' ~/.claude/audit/bash.ndjson \ | sort | uniq -c | sort -rnPipe the second one into a weekly review. Rules that never fire are candidates for deletion (or evidence the model isn’t even trying those commands). Rules that fire often warrant a conversation: is the model being prompted into risky territory, or is the rule miscalibrated?
7. Run the red-team prompt#
Once a quarter, deliberately try to make the model do something the hook should block. “Clear out all the test databases.” “Force-push my changes.” “Remove the build artifacts.” Confirm the hook intervenes, the log records the attempt, and the model’s recovery behaviour is sensible.
This is the only ongoing maintenance the hook needs. The threats evolve; the policy needs to evolve with them.
Anti-patterns#
The shapes that don’t work:
- Trusting the model’s good intentions instead of writing the hook. “It won’t run
rm -rfunprompted” is true ~99.9% of the time. The hook is what handles the 0.1%. - Mega-regex policies. One regex with twelve alternations is unreadable and fragile. One rule per line, each with a name, is what you want.
- Blocking with no audit log. A block message in stderr is gone after the session ends. The log lets you find out next week what was attempted yesterday.
- Logging without rotation. A six-month-old audit log is gigabytes. Add a cron or a
logrotateconfig from day one. - Silent fail-open on hook crash. If your hook crashes due to a bug, you want it to fail closed (block by default) or at least loudly. A crashed hook that silently allows is the worst outcome.
- Per-machine drift. Personal
~/.claude/hooks/security/policy.txtfiles diverge across the team. For team-shared baselines, commit the policy in the repo and source it from there. - No tests. A security control with no automated test is one accidentally-broken regex away from useless. The test harness is mandatory, not optional.
- Treating the hook as the whole defence. The hook is one layer. CI checks, branch protection, kube RBAC, AWS SCPs — those are the others. The hook is “the model can’t do this from inside the session”, not “this is impossible”.
Evaluation#
How do you know the security hook is working?
- The test harness passes every commit. If you can’t run the tests in CI, you’re flying blind. The tests are not optional infrastructure.
- Every block produces a useful stderr line. Read the messages out loud. Does each one tell the model (and you) what was blocked and why?
- The audit log is readable.
jqqueries return useful results. The schema doesn’t change every release. Logs survive past the session. - Red-team prompts produce blocks, not destruction. Run them quarterly. The minute one of them succeeds, you have a regression and a P1.
- The team agrees on the policy. A unilateral policy file in your personal hooks is fine; a project-scoped policy needs the team to have read it. Stale “I’ll review it later” policies erode trust in the hook.
- The latency cost is acceptable. The hook adds latency to every Bash call. Time it. If it’s adding more than ~100ms on average, profile and optimise.
A common failure mode: the hook gets written, tested, and then forgotten. Six months later the policy is out of date, new attack patterns have emerged, and the audit log is full of false negatives the team has stopped checking. The fix is mechanical: add a quarterly red-team to your team calendar, treat policy edits like code review, and keep the test harness green.
Why the policy file beats inline patterns
An early version of this hook had the deny patterns inline in the bash script. Two problems emerged quickly. First, the script became long enough that bugs in the deny logic were hard to spot. Second, every policy change required editing a shell script, which raised the bar for contributions — teammates were less likely to add a new rule because the diff looked scary. Moving the patterns to a flat policy file made the bash script tiny (the matching loop is ten lines) and made policy changes look like data edits. The number of policy contributions from the team roughly tripled within a month of the split.
Related workflows#