A concise reference for writing safe Claude Code hooks. Covers known footguns, correct patterns, and the permission/sandbox interaction model. Contributions welcome — see the end of this document.
| Value | Effect | When to use |
|---|---|---|
"deny" |
Block the tool call. Show permissionDecisionReason to the model. |
Deny-list rules that should hard-block. |
"allow" |
Auto-approve. Bypasses the entire permission system — no prompt, no permissions.allow check, no defaultMode evaluation. |
Only when you are implementing a positive allowlist and you are certain the command is safe. |
"passthrough" |
No opinion. Falls through to normal permission evaluation (deny → ask → allow → defaultMode). |
Default fallthrough for deny-list hooks. |
# BAD — silently auto-approves everything not explicitly denied
for rule in deny_rules:
if matches(rule, command):
return {"permissionDecision": "deny", ...}
return {"permissionDecision": "allow"} # <-- THIS IS THE BUGImpact: Every Bash command not matching a deny rule executes without user
consent, regardless of permissions.allow or defaultMode: "default".
Commands like kill, rm -rf, curl ... | bash, chmod 777, etc. all
silently auto-approve.
Real-world discovery: A hook denying only 3 patterns (pip install, bare
ImageMagick import, global npm install) inadvertently auto-approved kill -15
on an unrelated process. The 327ms tool execution time confirmed no human could
have responded to a permission prompt — it was the hook.
# GOOD — deny what you know is bad, defer everything else
for rule in deny_rules:
if matches(rule, command):
return {"permissionDecision": "deny", ...}
return {"permissionDecision": "passthrough"} # <-- normal evaluation continues1. PreToolUse hooks → "deny"/"allow" short-circuit; "passthrough" continues
2. permissions.deny rules → absolute block
3. permissions.ask rules → force prompt
4. permissions.allow rules → auto-approve
5. defaultMode fallback → "default" prompts, "dontAsk" denies, "bypassPermissions" allows
A hook returning "allow" jumps to step 1's short-circuit — steps 2-5 are
never evaluated.
Unlike CLAUDE.md (which walks ancestor directories), settings files load from
exactly three fixed locations:
| Priority | File | Scope |
|---|---|---|
| Highest | .claude/settings.local.json (project root) |
Local, gitignored |
| Middle | .claude/settings.json (project root) |
Shared, committed |
| Lowest | ~/.claude/settings.json |
User global |
Plus managed/enterprise policy above all three.
permissions.allow arrays are merged (union) across scopes per the
specification, though bugs exist where project-level can replace user-level
(GitHub issues #17017, #18160).
Feature request for parent directory traversal: GitHub issue #12962 (open).
| Approval type | Persistence | Storage |
|---|---|---|
| "Yes" (one-time) | Current session only | In-memory |
| "Yes, don't ask again" | Permanent per project | .claude/settings.local.json |
Bash approvals via "don't ask again" persist across restarts. File edit approvals are session-scoped only.
| Dimension | Permissions | Sandbox |
|---|---|---|
| Scope | All tools (Bash, Read, Edit, MCP, etc.) | Bash only (+ child processes) |
| Enforcement | Claude Code logic layer | OS primitives (Seatbelt/macOS, bubblewrap/Linux) |
| What it controls | Which tools Claude can invoke | Filesystem/network access of subprocesses |
| Bypass via shell | Read(.env) deny does NOT block cat .env in Bash |
Sandbox deny paths block ALL processes |
| Setting A | Setting B | Result |
|---|---|---|
bypassPermissions |
allowUnsandboxedCommands: true (default) |
Arbitrary unsandboxed commands, zero prompts |
"allow": ["Bash"] |
allowUnsandboxedCommands: true |
dangerouslyDisableSandbox silently bypasses sandbox |
PreToolUse hook returns "allow" |
Any defaultMode |
Hook overrides all permission evaluation |
allowUnsandboxedCommands(default:true) — Controls thedangerouslyDisableSandboxescape hatch. Commands retried outside sandbox still go through permission prompts unless another bypass is active.skipDangerousModePermissionPrompt— Narrow: only suppresses the startup "WARNING: Running in Bypass Permissions mode" dialog. Does NOT affect tool-level prompts.sandbox.excludedCommands— Commands that always run outside sandbox. Must also be inpermissions.allowto auto-approve (two separate systems).
Before deploying a PreToolUse hook:
- Fallthrough returns
"passthrough", not"allow"— unless you are building an explicit allowlist and understand the implications - Deny rules use anchored patterns — avoid partial matches that miss
command variations (e.g.,
pip3,python3 -m pip, env var prefixes) - Test with commands outside your deny list — verify they still prompt
the user when
defaultModeis"default" - Consider compound commands —
safe-cmd && dangerous-cmdmay bypass a hook that only checks the first token - Handle malformed input gracefully —
sys.stdinmay contain unexpected JSON; don't crash (a crashing hook may be treated as passthrough or may block all commands depending on configuration) - Don't assume hook response is logged — hook decisions
(
permissionDecision) are not currently recorded in conversation history, making debugging difficult without reading the hook source - Test with
--dangerously-skip-permissionsOFF — bypass mode skips hooks too, so bugs only surface in normal mode
When a command runs without an expected prompt:
- Check PreToolUse hooks first — a hook returning
"allow"is the most common silent bypass - Check
settings.local.json— "don't ask again" approvals persist here - Check all settings scopes —
~/.claude/settings.json, project-level, managed policy - Measure execution time — if tool_call → tool_result < 500ms, no human approved it interactively
- Check conversation history —
claude-history-query show --jsonl -t <uuid>and search for the tool call (note: hook decisions are NOT in history)
- Claude Code Hooks documentation —
permissionDecisionvalues specification - Claude Code Permissions documentation — evaluation order, modes, pattern syntax
- Claude Code Sandboxing documentation —
sandbox architecture,
allowUnsandboxedCommands - Claude Code Settings documentation — scopes, precedence, array merge behavior
- GitHub issues: #12962 (settings parent traversal), #17017 / #18160
(array merge bugs), #25503 (
skipDangerousModePermissionPrompt), #32466 (phantom permission prompts), #14268 (dangerouslyDisableSandboxbypass)
Contributions welcome. If you know of other security/reliability pitfalls in Claude Code hooks, settings, or permission evaluation — please comment with details and we will add them. The goal is a living reference that helps hook authors avoid silent permission bypasses and other non-obvious footguns.