Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save gwpl/b481ea6947ea91f9a43992a727780c6e to your computer and use it in GitHub Desktop.

Select an option

Save gwpl/b481ea6947ea91f9a43992a727780c6e to your computer and use it in GitHub Desktop.
Claude Code PreToolUse Hook Security Pitfalls — permissionDecision footguns, correct patterns, permission/sandbox architecture reference

Claude Code PreToolUse Hook Security Pitfalls & Reference Guide

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.

1. The Critical permissionDecision Footgun

The three values and what they mean

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 (denyaskallowdefaultMode). Default fallthrough for deny-list hooks.

The bug pattern: deny-list hook with "allow" fallthrough

# 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 BUG

Impact: 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.

The fix

# 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 continues

2. Permission System Architecture

Evaluation order (first match wins)

1. 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.

Settings file scopes (no parent directory traversal)

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).

Session-level permission grants

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.

3. Sandbox vs. Permissions — Two Orthogonal Systems

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

Dangerous combinations

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

Key settings

  • allowUnsandboxedCommands (default: true) — Controls the dangerouslyDisableSandbox escape 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 in permissions.allow to auto-approve (two separate systems).

4. Hook Development Checklist

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 defaultMode is "default"
  • Consider compound commandssafe-cmd && dangerous-cmd may bypass a hook that only checks the first token
  • Handle malformed input gracefullysys.stdin may 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-permissions OFF — bypass mode skips hooks too, so bugs only surface in normal mode

5. Debugging Permission Behavior

When a command runs without an expected prompt:

  1. Check PreToolUse hooks first — a hook returning "allow" is the most common silent bypass
  2. Check settings.local.json — "don't ask again" approvals persist here
  3. Check all settings scopes~/.claude/settings.json, project-level, managed policy
  4. Measure execution time — if tool_call → tool_result < 500ms, no human approved it interactively
  5. Check conversation historyclaude-history-query show --jsonl -t <uuid> and search for the tool call (note: hook decisions are NOT in history)

6. References


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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment