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 (5)

Collapse
 
skillselion profile image
Skillselion •

The allow-broadly-plus-hook pattern is the right recommendation, and the argument for it is stronger than the post makes it. A deny rule is matched against a command pattern, so once the same command shows up inside a pipeline or after &&, whether it still matches depends entirely on how someone wrote the glob. Bash(git push *) against git push origin main | tee push.log is the pair that settles this for most people. A PreToolUse hook receives the whole tool_input.command string and can grep it, so the compound case stops being a pattern authoring puzzle and becomes ordinary string handling.

Two things worth adding from running this setup daily. First, a hook is code you now maintain, and by your own description any non-zero exit that is not 2 is treated as non-blocking. That means a hook that dies for a reason unrelated to policy, jq not on PATH in the environment the hook actually runs in, drops your rule silently and nothing in the transcript says so. If you want it to fail closed, catch your own errors and exit 2 on purpose rather than letting the script fall over.

Second, the protected-paths gate deserves reading twice before anyone designs around it. If .claude cannot be unlocked by an allow rule, then any workflow where an agent edits its own skills, hooks or settings needs a different plan, not a cleverer allow list. That is a real constraint on self-modifying agent setups and it is better to hit it on a whiteboard than at 2am.

Collapse
 
hamid_ahmadian_3570449f72 profile image
Hamid Ahmadian •

This matches what we hit running a fleet of unattended nightly agents. The silent-drop case you describe is real -- we had a hook that shelled out to a small validator script, and one night the script's own dependency wasn't on PATH in the cron environment (different PATH than an interactive shell), so it errored non-zero-but-not-2, the harness treated it as non-blocking, and a command we meant to gate just ran. Nothing in the transcript flagged it either -- we only found it days later while reviewing logs. Now every hook we write starts with a set -euo pipefail and an explicit trap that exits 2 on any unexpected failure, specifically so "my hook broke" and "the policy said no" can never look the same from the agent's side.

On the protected-paths point: we run into that constraint constantly, because our agents' own instructions live under .claude/. Our workaround wasn't to fight the gate -- it's to keep a separate, explicitly-writable staging path for anything the agent needs to propose changes to (hooks, skills, settings), and a human/scribe step merges into .claude/ afterward. Slower, but it means the protected-paths gate stays a hard boundary instead of something we quietly route around.

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.

Collapse
 
hamid_ahmadian_3570449f72 profile image
Hamid Ahmadian •

Agreed, and the "truth table, not a blocked production task" framing is exactly right -- that's how we found ours too, the hard way. We run a handful of unattended nightly agents each with their own scope of allow/deny rules layered on top of a shared project-level policy. The first time two of those scopes actually overlapped (an agent-specific allow next to a project-wide deny on the same command shape), we assumed the more specific rule would win like it does in most linting/config systems. It didn't -- deny won, which was correct, but only obvious after we'd already built a mental model that turned out wrong.

What actually fixed it for us wasn't reading the precedence order more carefully, it was writing out every scope pair as literal test cases (project-deny + agent-allow, agent-deny + project-allow, both-deny, etc.) and running each one against the real CLI before deploying any new agent. Cheap to write, and it converts "I think this resolves the way I expect" into something you can rerun after every policy change instead of re-learning by surprise.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.