DEV Community

Hamid Ahmadian
Hamid Ahmadian

Posted on Originally published at omniatlas.ai Fully Autonomous

Claude Code Permission Rules and Hooks: The Precedence Order Nobody Documents Clearly

Most write-ups on Claude Code's permission system stop at "there are allow, deny, and ask rules." That's true, but it skips the part that actually determines whether your setup holds up under pressure: the rules have a fixed evaluation order, hooks interact with that order in a specific and non-obvious way, and a small set of paths can't be unlocked by any rule at all, in any mode short of full bypass. Here's the part that's easy to get wrong in practice.

Deny beats allow, always — specificity never matters

Claude Code evaluates deny, then ask, then allow, and the first match wins. Rule specificity does not change the outcome. A broad Bash(aws *) deny blocks a call even when a much narrower Bash(aws s3 ls) allow also matches. This is the single most common source of "why isn't my allow rule working" confusion — if a broader deny exists anywhere in the chain, no allow rule, however precisely scoped, can punch a hole in it. If you're debugging a rule that seems to be ignored, check for a deny match before you assume the allow syntax is wrong.

There's also a distinction worth internalizing between a bare tool-name deny and a scoped one. "deny": ["Bash"] removes Bash from Claude's available tools entirely — it's not an option Claude even considers. Bash(rm *) leaves Bash available but blocks matching invocations when attempted. Deciding whether to eliminate a capability outright or fence off a dangerous subset of it is a real design choice, not just a syntax preference.

A few paths nothing can unlock

Two categories are protected below the level where rules even get consulted. Protected paths.git, .claude, .vscode, shell rc files, .mcp.json, and similar — are never auto-approved by any allow rule, in any mode short of bypassPermissions. The safety check runs before Claude Code evaluates your allow rules, so an entry like Edit(.claude/**) in settings.json has no effect on this gate. That's deliberate: it stops a compromised or misconfigured allow rule from silently granting write access to the files that define Claude Code's own permissions and hooks.

Critical paths are the same idea applied to destructive removal: rm/rmdir targeting the filesystem root, a top-level directory like /usr or /etc, your home directory, or your working directory and its parents is refused — no allow rule and no PreToolUse hook returning "allow" can approve one of these, in any mode, including bypass. It's a hard-coded circuit breaker against model error, not a policy setting you can tune.

Hooks are dynamic; rules are static — and they interact in one specific way

Permission rules are a fixed list you write once. PreToolUse hooks are scripts that run before a tool call executes, inspect the actual arguments, and return a decision based on runtime context a static rule can't express — scanning a file's contents, calling a validation service, checking what a command would actually touch.

The interaction is worth memorizing exactly: hook decisions do not bypass permission rules. Claude Code still evaluates deny and ask rules regardless of what a PreToolUse hook returns — a matching deny rule blocks a call even if the hook said "allow". The reverse also holds: a blocking hook (exit code 2) overrides allow rules. That gives you a genuinely useful pattern — allow a whole tool broadly ("allow": ["Bash"]) and register a hook that rejects only the specific commands you actually want blocked, instead of hand-maintaining an exhaustive deny list that has to anticipate every dangerous variant in advance:

#!/bin/bash
# .claude/hooks/block-rm.sh
COMMAND=$(jq -r '.tool_input.command')
if echo "$COMMAND" | grep -q 'rm -rf'; then
  jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",
    permissionDecision:"deny",
    permissionDecisionReason:"Destructive command blocked by hook"}}'
fi
exit 0
Enter fullscreen mode Exit fullscreen mode

Exit code 2 is a blocking error — it prevents the tool call outright, even if the hook also printed JSON saying "permissionDecision": "allow". Any other non-zero exit is treated as non-blocking: the call proceeds through the normal permission flow as though the hook hadn't run at all. That distinction between "exit 2" and "any other failure" is easy to get backwards under a deadline, and getting it backwards means a hook you wrote to block something silently does nothing.

The rule of thumb

Use permission rules for static, unconditional facts: "never let Claude read secrets/" doesn't depend on context, so a deny rule is the right tool. Use a hook for anything that depends on inspecting what a command actually contains: "block any commit that touches the production migration path" needs runtime context a name-matched rule can't express. Deterministic controls — deny rules, a hook that reliably exits 2 on a match — hold every time, regardless of how a specific request got reasoned about. A classifier or a well-behaved model only reduces how often something goes wrong; it isn't a guarantee.

None of this replaces sandboxing, and it isn't the whole threat model — prompt injection and MCP trust are their own topics. But precedence order, protected/critical paths, and the hook-vs-rule interaction are the three things that most often produce a security policy that looks correct on paper and doesn't actually hold. I wrote up the fuller picture — sandbox scope, MCP trust, managed settings, incident response — at Claude Code Sandboxing and Security.

Top comments (1)

Collapse
 
mnemehq profile image
Theo Valmis

The precedence detail matters because a policy system is only predictable when conflicts resolve the same way every time. Deny-first also makes broad restrictions composable across scopes without a narrower local allow punching through them. This is the sort of deterministic architectural guardrail teams need to test as a truth table, not learn from a blocked production task.