Now in early access, book a 30-minute demo →
← Back to blog Research

How Claude Code Permissions Actually Work: Allow Rules, Deny Rules, and the PreToolUse Hook

Claude Code's permission system is the boundary between a coding agent that suggests changes and one that runs shell commands, edits files, and reaches the network on your behalf. Most teams configure a few allow rules, maybe a deny rule for .env, and move on without knowing the actual evaluation order or what a PreToolUse hook can and cannot do. That gap matters, because the rules are enforced by Claude Code, not by the model: instructions in your prompt or CLAUDE.md shape what the agent tries to do, but only the permission layer decides what it is allowed to do.

This post is a mechanism explainer. We walk through the order rules are evaluated, how PreToolUse hooks fit in (and where they can't reach), the four path anchors that decide which files a rule covers, and the permission modes that change the defaults. Every behavior here is taken from Claude Code's official permissions and hooks documentation. The same mechanics underpin real-world failures we have written up, including the Claude Code project-file RCE and token exfiltration in CVE-2025-59536, where the question of what a local config is permitted to do is exactly the question that decides impact.

Deny, then ask, then allow

Claude Code splits permission rules into three lists: allow lets a tool call proceed without a prompt, ask forces a confirmation prompt, and deny blocks the call outright. When the agent attempts a tool call, the rules are evaluated in a fixed order: deny first, then ask, then allow. The first list that matches determines the outcome, and specificity does not change the order. A matching ask rule prompts you even when a more specific allow rule also matches the same call. This is the single most important fact about the system, and it is the opposite of the intuition many engineers carry over from firewall or IAM allowlists, where the most specific rule tends to win.

The practical consequence: deny rules are absolute within Claude Code's own enforcement. If a tool is denied at any settings level, no other level can allow it. A user-level deny blocks a project-level allow, and a managed-settings deny cannot be overridden by --allowedTools on the command line. How a deny rule is written also matters. A bare tool name like Bash removes the tool from the model's context entirely, so Claude never sees it; a scoped rule like Bash(rm *) leaves the tool available and blocks only matching calls. The two forms have very different blast radii.

What the PreToolUse hook adds, and what it can't override

A PreToolUse hook is a shell command Claude Code runs before the permission prompt, on every matching tool call. It receives the tool input on stdin and can return a decision in hookSpecificOutput: a permissionDecision of allow, deny, or ask, with a permissionDecisionReason. This is the runtime check our own runtime governance at the hook is built around, and it is genuinely powerful. A hook sees the resolved command, can inspect arguments that a static glob can't reliably constrain, and can block calls an allow rule would otherwise wave through. A hook that exits with code 2 stops the call before permission rules are even evaluated.

What a hook cannot do is loosen the rules. Hook decisions do not bypass permission rules: deny and ask rules are evaluated regardless of what the hook returns. A matching deny rule blocks the call even if the hook returned allow, and a matching ask rule still prompts even if the hook returned allow or ask. The hook also cannot override a managed-policy deny. So the precedence is layered: a blocking hook beats an allow rule, but a deny or ask rule beats a hook. If you are reasoning about why a particular tool call was stopped, check the deny list first, then ask, then any code-2 hook, and only then the allow list.

One caveat worth flagging for anyone hardening a fleet: hook behavior around the ask decision has had reported edge cases in the field, and a hook is only as trustworthy as the script it runs and the settings file that registers it. A PreToolUse hook is part of the local configuration, which means it is itself something that should be inventoried and verified rather than assumed. We have seen related trust-boundary failures in agent tooling, including the GitHub Action bot-actor bypass in Claude Code, where the gap between configured intent and actual enforcement is where the risk lives.

The four path anchors decide which files a rule covers

Read and Edit rules follow gitignore-style matching, but the leading characters of a pattern anchor it to one of four roots. Getting the anchor wrong is the difference between a deny rule that protects every secret on disk and one that protects nothing. The most dangerous mistake is assuming a leading slash means an absolute path: in Claude Code it does not. /path is relative to the project root. An absolute filesystem path needs a double slash.

PatternAnchorExample matches
//pathAbsolute, from filesystem rootRead(//Users/alice/secrets/**)
~/pathHome directoryRead(~/.ssh/**)
/pathProject root (NOT filesystem root)Edit(/src/**/*.ts)
path or ./pathCurrent working directoryRead(*.env)

A bare filename follows gitignore semantics and matches at any depth, so Read(.env) is equivalent to Read(/.env) and blocks any .env at or under the current directory, but not one in a parent directory or another project. To block a file everywhere on the machine you need the absolute anchor: Read(///.env). Deny rules also apply if either a symlink's path or its resolved target matches, so a symlink pointing at a denied file is itself denied. This is where per-machine governance gets brittle: a rule an engineer wrote to protect ~/.ssh on their laptop says nothing about the same path on anyone else's. We covered the broader pattern in what we found scanning AI configs and in securing AI coding agents and CLIs.

Permission modes change the defaults underneath all of this

Modes set the baseline behavior that the rules then refine. The default mode prompts on first use of each tool. acceptEdits auto-accepts file edits and common filesystem commands for paths in the working directory. plan mode lets the agent read and run read-only commands to explore but not edit source. bypassPermissions skips prompts entirely, except those forced by an explicit ask rule and a small circuit-breaker set such as rm -rf / and rm -rf ~. You set the baseline with defaultMode in a settings file.

{
  "permissions": {
    "defaultMode": "default",
    "deny": ["Read(.env)", "Bash(git push *)"],
    "ask": ["Bash(rm *)"],
    "allow": ["Bash(npm run *)", "Bash(git commit *)"]
  }
}

Two things make modes a governance concern. First, bypassPermissions removes nearly every prompt, and while administrators can block it by setting disableBypassPermissionsMode to disable in managed settings, that control only exists if someone deployed managed settings to the machine in the first place. Second, even careful Bash rules are fragile against argument tricks: a rule meant to restrict curl to one domain does not survive -X GET, a protocol switch, a redirect, or a variable holding the URL. The documentation's own recommendation is to deny network tools and use a PreToolUse hook for real enforcement. The MCP layer compounds this, since stdio servers run as local subprocesses by design, a property we examined in the Anthropic MCP stdio-by-design RCE.

The governance gap: every one of these controls is per-machine

Step back and the structure is clear. Allow, ask, and deny rules; PreToolUse hooks; path anchors; permission modes; managed settings: each is a real, well-documented control. What none of them provide is a view across more than one machine. The rules live in ~/.claude/settings.json and a project's .claude/settings.json, the hooks live next to them, and the mode is whatever the local user or a managed policy set. There is no built-in answer to fleet-level questions: which laptops are running in bypassPermissions, which projects ship a PreToolUse hook and whether it actually denies anything, which deny rules use a project-relative anchor where an engineer meant an absolute one, and which machines never received managed settings at all. The same per-machine blind spot is what let a single poisoned configuration matter in the comment-and-control multi-agent prompt injection and credential theft case.

This is the layer Anomity makes visible. The endpoint daemon inventories Claude Code alongside the other AI artifacts on each managed machine, surfaces the permission rules, hooks, and mode that are actually in effect, and where Claude Code exposes the PreToolUse hook, returns an allow, deny, or log decision on each tool call before it runs, with every decision written to a queryable 90-day audit trail and routed to your SIEM, Slack, or Jira. It does not replace Claude Code's own enforcement; it gives security a fleet-wide picture of it, so the deny rule you wrote once becomes a policy you can verify everywhere. If you are trying to govern this layer rather than guess at it, request early access.

Ask AI about Anomity
ChatGPT Claude Perplexity Google AI Grok