CCLearn

Rule Syntax, Precedence, and Why Deny Wins

verified against Claude Code 2.1.261 · 2026-09-0914 min

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 Tool or Tool(specifier). — Configure permissions

Parentheses inside the specifier are literal, so a path or command containing them needs no escaping. Three arrays, three jobs:

ArrayWhat it does to a matching call
permissions.allowRuns it without manual approval
permissions.askPrompts you every time
permissions.denyPrevents the call
json
{
  "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 playCallOutcome
allow Bash(git *), deny Bash(git push *)git push origin mainDenied
allow Bash(aws s3 ls), deny Bash(aws *)aws s3 lsDenied
allow Bash(npm run *), ask Bash(npm run deploy)npm run deployPrompts
allow Bash(npm run *), nothing elsenpm installNo 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 in bypassPermissions. — 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 like Bash(aws s3 ls), so a deny rule can't carry allowlist exceptions. — Configure permissions

json
{
  "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 Bash removes the tool from Claude's context entirely, so Claude never sees it. — Configure permissions

Deny ruleEffect
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 only git log commands, and Bash(git *) allows every git command. — Configure permissions

You writeMatchesDoesn't match
Bash(npm run build)npm run buildnpm run build --watch
Bash(npm run *)npm run build, npm runnpm install
Bash(git log * main)git log --oneline maingit log main
Bash(git * main)git merge main, git push origin maingit log
Bash(ls *)ls -la, lslsof
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 command safe-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 for loop. — Configure permissions

Rule typeFires whenConsequence
allowevery subcommand matchesThe narrow direction; one unmatched part and the rule approves nothing
deny and askany subcommand matches, nested includedThe 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 after run, including devbox 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.md shape 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

  1. A repo's settings deny Bash(aws *) and your user settings allow Bash(aws s3 ls). Claude proposes aws s3 ls. What happens, and which of the two rules is doing nothing?

    Show answer

    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 ls silent, take the family out of deny entirely, allow the one command, and let everything else prompt.

  2. You want git log to run silently but not git push. Write the allow rule, and say what Bash(git *) would have cost you.

    Show answer

    "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 bare git log. git push then matches no rule and falls through to the mode — a prompt in Manual, a refusal in dontAsk. If it must never run at all, that is a deny entry, not an omission; an allow rule's only job is to remove a prompt.

    Bash(git *) allows every git command, git push included, 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) matches git -c core.fsmonitor=<script> diff main, and git -c makes 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.

  3. A colleague reports that the session is in bypassPermissions but a command was still blocked. Give two different rule-level explanations, and say which one would also have applied in Manual mode.

    Show answer

    Either a matching deny rule or a matching explicit ask rule. Deny rules block in every mode, bypassPermissions included; 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 a for loop, 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.ask prompts you every time it matches. Note what is not a candidate explanation either way: an allow rule, which has no effect in bypassPermissions at all.

  4. Your allow list has Bash(npm test *). Claude runs devbox run npm test && rm -rf dist. Which parts, if any, does that rule approve?

    Show answer

    None of it. An allow rule fires only when every subcommand matches, and Claude Code is aware of shell operators, so && rm -rf dist is 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 run is an environment runner, not a wrapper: the built-in strip list is timeout, time, nice, nohup, stdbuf, the builtins command and builtin, noglob and bare xargs, and it is not configurable, so matching starts at devbox. The documented fix is to name both halves — Bash(devbox run npm test), one rule per inner command — and specifically not Bash(devbox run *), which matches whatever comes after run, including devbox run rm -rf ..

Sources