Rule Syntax, Precedence, and Why Deny Wins
By the end you can
- Write allow, ask and deny rules that match the calls you intend
- State the evaluation order and predict the outcome for a conflicting pair
- Explain why deny rules hold in every mode including bypassPermissions
- Place wildcards so a Bash rule does not widen past the subcommand
- Predict how a rule reads a compound command, a wrapper and a runner
- Say how rules from different settings files combine
Lesson 10.1 left you holding a mode, and a mode is one setting applied to every
tool call in the session. Real work is not uniform: you want npm test to run
unattended and git push never to run at all, in the same hour, on the same
repo. That is what
rules are for. You are about to write a block of JSON with three arrays in it,
and the two ways it goes wrong are a rule that never fires and a rule that fires
much wider than you meant.
Tool and Tool(specifier)
Permission rules follow the format
ToolorTool(specifier). — Configure permissions
Parentheses inside the specifier are literal, so a path or command containing them needs no escaping. Three arrays, three jobs:
| Array | What it does to a matching call |
|---|---|
permissions.allow | Runs it without manual approval |
permissions.ask | Prompts you every time |
permissions.deny | Prevents the call |
{
"permissions": {
"allow": ["Bash(npm run *)", "Bash(git commit *)"],
"deny": ["Bash(git push *)"]
}
}Two syntax facts worth having up front. Deny and ask rules can also match a
top-level input parameter with Tool(param:value) — Agent(model:opus),
Bash(run_in_background:true) — but never a tool's primary content field:
command for Bash, file_path for Read, Edit and Write, url for WebFetch. A
rule like Bash(command:rm *) would be bypassable by a compound command, so
Claude Code ignores it and emits a startup warning. Write Bash(rm *).
For file paths, only two tool names are ever consulted. Claude Code checks
file permissions against Edit(path) and Read(path) rules only. A path rule
written for Write, NotebookEdit, Glob or MultiEdit is accepted and never
looked at again — use Edit(docs/**) in place of Write(docs/**), and
Read(docs/**) in place of Glob(docs/**). The startup warning that catches
this requires Claude Code v2.1.210 or later.
Deny, then ask, then allow
This is the whole engine, and it is one sentence:
Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity doesn't change the order. — Configure permissions
Read the second clause twice. Most configuration systems resolve a conflict by asking which pattern is more specific. This one does not. It sorts by array, and the array a rule sits in is the only thing that matters.
| Rules in play | Call | Outcome |
|---|---|---|
allow Bash(git *), deny Bash(git push *) | git push origin main | Denied |
allow Bash(aws s3 ls), deny Bash(aws *) | aws s3 ls | Denied |
allow Bash(npm run *), ask Bash(npm run deploy) | npm run deploy | Prompts |
allow Bash(npm run *), nothing else | npm install | No rule matches; the mode decides |
The same precedence applies one rung down: a matching ask rule prompts even when
a more specific allow rule also matches the same call. Row four is the one
people forget — a call that matches no rule at all is not denied, it falls
through to whatever the session's mode does with it. In Manual mode that is a
prompt; in dontAsk, which auto-denies anything not pre-approved by an allow
rule, it is a refusal. Rules are an override layer, not the whole policy.
Deny wins in every mode, including bypass
Two sentences from the modes page, and they are the reason this lesson exists:
Deny rules block in every mode, including
bypassPermissions. […] Allow rules have no effect inbypassPermissions. — Permission modes
Stated forwards: a deny rule does not care which mode you are in. Nor does an
explicit ask rule — it sits on the docs' list of actions no mode auto-approves,
so a matching ask rule still prompts you under bypassPermissions. Stated
backwards, and this is the half that surprises people: your allow list is
inert the moment somebody launches with bypass, so it is not the thing keeping
that session in bounds — the deny list is.
Two carve-outs to keep the sentence honest. Deny and ask rules don't apply to
EndConversation as long as Claude still has at least one other tool it can
call. And in the other direction, rm and rmdir removals targeting a critical
path are never approved by an allow rule or a PreToolUse hook returning
"allow", in any mode.
A deny rule cannot carry exceptions
The trap in this lesson, and it looks completely reasonable when you write it:
A broad deny rule like
Bash(aws *)blocks every matching call, including calls that also match a narrower allow rule likeBash(aws s3 ls), so a deny rule can't carry allowlist exceptions. — Configure permissions
{
"permissions": {
"allow": ["Bash(aws s3 ls)"],
"deny": ["Bash(aws *)"]
}
}That configuration blocks aws s3 ls. The allow rule is well-formed, it matches
the call, and it changes nothing. If you find yourself writing "deny the family,
allow the one safe member", you have the shape backwards: an allow rule's only
job is to remove a prompt. Leave the family out of deny entirely, allow the
one command you want silent, and let everything else prompt.
Reach for a broad deny only for things that must never run at all — at which point the narrow exception has to go, or the deny has to.
Bare names remove the tool entirely
Bash and Bash(rm *) in the deny array are not the same kind of rule.
A bare tool name like
Bashremoves the tool from Claude's context entirely, so Claude never sees it. — Configure permissions
| Deny rule | Effect |
|---|---|
Bash (or Bash(*)) | Tool removed from context; Claude never sees it |
Bash(rm *) | Tool stays available; matching calls blocked at call time |
mcp__* | Every MCP tool removed from context |
The difference is behavioural. A tool Claude never sees cannot be attempted at all, so Claude plans as though the capability does not exist. A scoped rule leaves the tool in context and blocks the matching call when Claude makes it.
Glob patterns in the tool-name position are for deny and ask only. Allow rules
accept one only after a literal mcp__<server>__ prefix, so the rule names a
server you actually configured; an unanchored allow glob such as "*" or
"mcp__*" is skipped with a warning and approves nothing.
Where the wildcard goes
Claude Code matches everything before the first
*as written, so those words are what limit the rule:Bash(git log *)allows onlygit logcommands, andBash(git *)allows every git command. — Configure permissions
| You write | Matches | Doesn't match |
|---|---|---|
Bash(npm run build) | npm run build | npm run build --watch |
Bash(npm run *) | npm run build, npm run | npm install |
Bash(git log * main) | git log --oneline main | git log main |
Bash(git * main) | git merge main, git push origin main | git log |
Bash(ls *) | ls -la, ls | lsof |
Bash(ls*) | ls -la, lsof | — |
Three rules produce every row. The * stands in for whatever text is in its
place. A trailing * with a space before it also matches the bare command, but
only when it is the rule's only wildcard — Bash(* --help *) matches
npm --help x and not npm --help. And the space before a trailing * is part
of the rule, which is the entire difference between Bash(ls *) and
Bash(ls*). The :* suffix is an equivalent spelling of a trailing wildcard,
recognised only at the end of a pattern.
Compound commands, wrappers, and runners
A shell command is not one string to match against. Claude Code splits it, and allow rules and deny rules read the split in opposite directions.
Claude Code is aware of shell operators, so a rule like
Bash(safe-cmd *)won't give it permission to run the commandsafe-cmd && other-cmd. — Configure permissions
Deny and ask rules apply when any subcommand matches them, including a command nested inside a subshell, a command substitution, or a control-flow body such as a
forloop. — Configure permissions
| Rule type | Fires when | Consequence |
|---|---|---|
| allow | every subcommand matches | The narrow direction; one unmatched part and the rule approves nothing |
| deny and ask | any subcommand matches, nested included | The wide direction; a subshell does not hide it |
The asymmetry is in your favour both times. An ask rule like Bash(git clean *)
still prompts for cd /tmp && git clean -f and for echo "$(git clean -f)".
Wrappers are stripped before matching. Bash(npm test *) also matches
timeout 30 npm test: the built-in strip list covers timeout, time, nice,
nohup, stdbuf, the shell builtins command and builtin, zsh's noglob,
and bare xargs with no flags. It is not configurable.
Environment runners are not on that list, and that is the second trap:
Because these tools execute their arguments as a command, a rule like
Bash(devbox run *)matches whatever comes afterrun, includingdevbox run rm -rf .. — Configure permissions
direnv exec, devbox run, mise exec, npx and docker exec all behave
this way. The documented fix is to name both halves — Bash(devbox run npm test)
— one rule per inner command you want to allow. Exec wrappers such as watch,
setsid, ionice and flock cannot be prefix-approved at all, and neither can
find with -exec or -delete.
Two smaller mechanics from the same family. A leading assignment of a known-safe
variable is stripped, so Bash(npm test *) matches NODE_ENV=test npm test; an
allow rule will not match past any other variable's assignment, while a deny
or ask rule matches past all of them. And a redirect adds a check on its target:
Bash(git commit *) allows the command, not the file it writes.
Rules across settings files
Rules do not live in one file, and the two questions are which file wins and whether the others still count.
When you set the same list key, such as
permissions.allow, in more than one file, Claude Code combines the lists instead of picking one. — Settings files and precedence
So every scope contributes rules; higher precedence does not erase lower. Which makes the deny guarantee a cross-scope one:
If a tool is denied at any level, no other level can allow it. — Configure permissions
It holds in both directions: a user-level deny blocks a project-level allow, and a project-level deny blocks a user-level allow, because deny rules from any scope are evaluated before allow rules from any scope. Managed settings sit at the top — no other level, command line arguments included, can override a managed permission rule.
One asymmetry to carry into Level 12. A project's checked-in
.claude/settings.json grants capability with permissions.allow, so those
rules apply only after you accept the workspace trust dialog for the folder.
deny and ask rules are not gated that way, since they only restrict.
What a rule is not: hooks and prompts
Everything above is enforced below the model:
Permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or
CLAUDE.mdshape what Claude tries to do, but they don't change what Claude Code allows. — Configure permissions
Lesson 6.2 argued CLAUDE.md is for things Claude should know. This is the hard
edge of that argument: a sentence in CLAUDE.md is intent, and intent is not a
boundary. "Never push to main" belongs in permissions.deny as well as in prose.
Hooks are code rather than prose, and they still do not outrank a rule:
Hook decisions don't bypass permission rules. — Configure permissions
A PreToolUse hook returning "allow" does not silence a matching ask rule and
does not open a matching deny rule. It runs in the other direction fine: a hook
that exits with code 2 stops the call before rules are evaluated, so it
overrides an allow rule. That gives you the documented shape for
allow-everything-except: put "Bash" in the allow list and register a hook that
rejects the specific commands. A scoped deny rule reaches the same outcome
whenever the thing you are blocking can be spelled as a pattern; the hook is
what the docs reach for when it cannot, because a Bash pattern that tries to
constrain a command's arguments is fragile — Bash(curl http://github.com/ *)
misses curl -X GET http://github.com/…, the https:// spelling, a redirect
through a shortener, and URL=http://github.com && curl $URL. Lesson 9.4
covered how to write that hook without leaving the gate open.
The order, in one line
Deny is a guarantee and allow is a convenience. A deny rule holds across every mode and every settings scope and cannot be re-opened by anything — a hook, a narrower allow, a mode, or a sentence in your prompt. An allow rule only removes a prompt, and it stops meaning anything the moment the session runs with bypass. Write the deny list for what must never happen. Write the allow list for the prompts you are tired of.
Checkpoint
-
A repo's settings deny
Bash(aws *)and your user settings allowBash(aws s3 ls). Claude proposesaws s3 ls. What happens, and which of the two rules is doing nothing?Show answerAnswer
The call is denied, and the allow rule is the one doing nothing. Rules are evaluated deny, then ask, then allow; the first match in that order determines the outcome, and rule specificity doesn't change the order. The allow rule is well-formed and it does match the call — it simply never gets consulted.
Living in different files changes nothing. Lists set in more than one file are combined rather than one file winning, and "if a tool is denied at any level, no other level can allow it" holds in both directions, because deny rules from any scope are evaluated before allow rules from any scope. This is the documented shape of a deny rule can't carry allowlist exceptions. If you want
aws s3 lssilent, take the family out ofdenyentirely, allow the one command, and let everything else prompt. -
You want
git logto run silently but notgit push. Write the allow rule, and say whatBash(git *)would have cost you.Show answerAnswer
"allow": ["Bash(git log *)"]. Everything before the first*is matched as written, so those two words are what limit the rule, and a trailing*with a space before it also matches the baregit log.git pushthen matches no rule and falls through to the mode — a prompt in Manual, a refusal indontAsk. If it must never run at all, that is adenyentry, not an omission; an allow rule's only job is to remove a prompt.Bash(git *)allows every git command,git pushincluded, so it would have silently approved the exact thing you were trying to keep visible. And a*standing in for the subcommand hands over more than git:Bash(git * main)matchesgit -c core.fsmonitor=<script> diff main, andgit -cmakes git run a program you name. From v2.1.246 Claude Code warns at startup about an allow rule with a*before the subcommand, naming the rule and its source — but it keeps the rule and the matching is unchanged, so that warning is a prompt to narrow the rule, not a fix. -
A colleague reports that the session is in
bypassPermissionsbut a command was still blocked. Give two different rule-level explanations, and say which one would also have applied in Manual mode.Show answerAnswer
Either a matching
denyrule or a matching explicitaskrule. Deny rules block in every mode,bypassPermissionsincluded; an ask rule sits on the docs' list of actions no mode auto-approves, so it still forces a prompt under bypass — which is easy to report as "blocked". For both, check the whole command rather than the obvious part: deny and ask fire when any subcommand matches, including one nested in a subshell, a command substitution or aforloop, and the rule can come from a scope your colleague did not write, up to managed settings, which nothing on the command line can override.Both would also apply in Manual mode, but only the deny rule would have produced a block there — a matching ask rule yields a prompt rather than a block, and
permissions.askprompts you every time it matches. Note what is not a candidate explanation either way: an allow rule, which has no effect inbypassPermissionsat all. -
Your allow list has
Bash(npm test *). Claude runsdevbox run npm test && rm -rf dist. Which parts, if any, does that rule approve?Show answerAnswer
None of it. An allow rule fires only when every subcommand matches, and Claude Code is aware of shell operators, so
&& rm -rf distis enough on its own to make the rule approve nothing — one unmatched part and the whole command falls through to the mode.The first half would not have matched either.
devbox runis an environment runner, not a wrapper: the built-in strip list istimeout,time,nice,nohup,stdbuf, the builtinscommandandbuiltin,nogloband barexargs, and it is not configurable, so matching starts atdevbox. The documented fix is to name both halves —Bash(devbox run npm test), one rule per inner command — and specifically notBash(devbox run *), which matches whatever comes afterrun, includingdevbox run rm -rf ..