Learn Claude Code: Safety Permission Rules: Allow, Deny, and Ask

Permission Rules: Allow, Deny, and Ask

Advanced 🕐 13 min Lesson 3 of 12
What you'll learn
  • Write allow, deny, and ask rules using the correct syntax for Bash commands, file paths, web domains, and MCP tools
  • Predict how the deny-first evaluation order interacts with overlapping rules across different settings scopes
  • Avoid the common pitfalls in Bash wildcard matching, compound commands, and file path anchoring that leave rules ineffective

Rules Are the Precision Layer

Permission modes set the baseline. Permission rules let you adjust that baseline with surgical precision — pre-approving specific commands, blocking entire tool categories, or requiring explicit confirmation before Claude touches certain paths. Rules apply in every mode: a deny rule blocks an action whether you're in Manual mode or auto mode, and an allow rule pre-approves an action without prompting regardless of the current mode.

Understanding how rules evaluate — especially when rules from different sources conflict — is essential for building a permission policy that actually does what you intend.

Evaluation Order: Deny First, Always

Rules evaluate in a fixed order: deny, then ask, then allow. The first matching rule wins, and rule specificity does not change this order. 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). There is no way to add exceptions to a deny rule — if you need to allow some aws commands and deny others, the deny rule must be written to exclude the allowed ones, not the other way around.

This also means deny rules from any settings scope win. If user settings contain deny: ["Bash(git push *)"] and project settings contain allow: ["Bash(git push origin main)"], the deny wins. Configure your allow rules at a scope that is not overridden by a deny at a broader scope.

Rule Syntax

Every rule is either a bare tool name or a tool name with a specifier in parentheses:

  • Bash — matches every Bash call. As a deny rule, removes the Bash tool from Claude's context entirely so Claude never attempts to use it.
  • Bash(npm run build) — matches only the exact command npm run build.
  • Bash(npm run *) — matches any command starting with npm run .
  • WebFetch(domain:example.com) — matches any request to example.com.
  • Read(~/.ssh/*) — matches any read of a file inside ~/.ssh/.
  • mcp__puppeteer__puppeteer_navigate — matches that specific MCP tool.

The difference between a bare tool name and a wildcard specifier matters for deny rules: Bash removes the tool from context (Claude never tries to use it); Bash(*) leaves the tool available but blocks every matching call when Claude attempts it. The end result is the same for a blanket deny, but the mechanism differs — and the mechanism matters for auto-mode classifier behavior.

Bash Wildcard Matching

A single * in a Bash rule matches any sequence of characters including spaces. Bash(git *) matches git log --oneline --all, git push origin main, and any other git command. Bash(git * main) matches git checkout main and git push origin main.

The space before * enforces a word boundary. Bash(ls *) matches ls -la but not lsof, because a space is required before the wildcard. Bash(ls*) without the space matches both. This distinction matters when you want to allow one command without accidentally allowing commands that share its prefix.

The :* suffix at the end of a pattern is equivalent to a trailing *, so Bash(npm:*) is the same as Bash(npm *).

Compound Commands and Process Wrappers

Claude Code parses compound commands and checks each subcommand independently. If you have allow: ["Bash(git commit *)"] but not Bash(git push *), the compound command git commit -m "fix" && git push will prompt for the push subcommand even though the commit part is allowed. Each separator (&&, ||, ;, |, and newlines) creates an independent check.

When you approve a compound command with "Yes, don't ask again", Claude Code saves a separate rule for each subcommand — not a single rule for the whole compound string. Up to five rules are saved per compound approval.

A fixed set of process wrappers is stripped before matching: timeout, time, nice, nohup, and stdbuf. A rule like Bash(npm test *) also matches timeout 30 npm test. Bare xargs is also stripped, but xargs with flags is not. Development environment runners like npx, devbox run, and docker exec are NOT stripped — a rule for the inner command does not cover it when run through one of these wrappers.

Read-Only Commands That Never Prompt

A built-in set of Bash commands is treated as read-only and runs without a permission prompt in every mode: ls, cat, echo, pwd, head, tail, grep, find, wc, which, diff, stat, du, cd, and read-only git commands. This list is built-in and not configurable. To require a prompt for one of these, add an ask or deny rule for it explicitly.

File Path Rules and Anchoring

Read and Edit rules use four path anchoring styles, and choosing the wrong one is a common source of rules that silently apply to the wrong location:

  • //path — absolute path from filesystem root. Read(//Users/alice/secrets/**) matches /Users/alice/secrets/**.
  • ~/path — relative to the home directory. Read(~/.ssh/*) matches /Users/alice/.ssh/*.
  • /path — relative to the directory that contains the settings file defining the rule. In project settings, this is the project root. In user settings, this is ~/.claude/. A single leading slash does not mean filesystem root.
  • Bare path or ./path — relative to the current working directory, gitignore-style. A bare filename like .env matches .env at any depth under the current directory.

The most common mistake is writing Read(/secrets/**) in user settings intending to block a project's /secrets directory, when it actually blocks ~/.claude/secrets/** because /path anchors at the settings file's directory.

WebFetch and MCP Rules

WebFetch rules use a domain: prefix: WebFetch(domain:example.com) matches any request to that hostname. WebFetch(domain:*.example.com) matches any subdomain but not the root domain itself. A trailing wildcard like WebFetch(domain:example.*) matches example.org but not example.evil.com because the wildcard stops at dot boundaries.

MCP rules use the server name as configured in Claude Code: mcp__puppeteer matches any tool from the puppeteer server; mcp__puppeteer__puppeteer_navigate matches only that specific tool. Use mcp__* as a deny rule to block all MCP tools across all servers.

Key takeaways
  • Deny always wins — a matching deny rule from any settings scope blocks a tool call before allow rules are consulted, regardless of how specific the allow rule is.
  • A bare tool name like Bash removes the tool from Claude's context entirely; Bash(rm *) leaves the tool available and blocks only matching calls — the mechanism differs even when the outcome looks the same.
  • The space before * in Bash rules enforces a word boundary: Bash(ls *) matches ls -la but not lsof, while Bash(ls*) matches both — precision matters for prefix rules.
  • Compound commands are split at &&, ||, ;, and | and each subcommand is checked independently, so an allow rule for git commit does not authorize git commit && git push.
  • File path anchoring uses four distinct prefixes — // for filesystem root, ~/ for home directory, / for settings-file directory, and bare paths for gitignore-style matching — and mixing them up silently misapplies the rule.