Permission Rule Syntax: Allow, Ask, and Deny Rules
Fine-grained permission rules control exactly which tools Claude Code can use. This lesson covers rule syntax, pattern matching, rule precedence, and how sandbox isolation interacts with permission rules.
Learning objectives
- Write permission rules for Bash, file operations, network requests, and MCP tools
- Understand glob patterns and wildcard matching in rule specifiers
- Apply rule precedence correctly (deny → ask → allow)
- Combine permission rules with sandbox isolation for defense-in-depth
ToolDix original visual
Frame
Name the outcome and constraints.
Build
Try one bounded workflow.
Review
Keep evidence, revise, and share.
Rule format and precedence
Permission rules follow the format Tool or Tool(specifier) and live in permissions.allow, permissions.ask, or permissions.deny arrays in your settings.json. Rules are evaluated in strict order: deny first, then ask, then allow. The first match wins, and rule specificity does not change the order.
{
"permissions": {
"deny": ["Bash(rm -rf /)"],
"ask": ["Bash(rm *)"],
"allow": ["Bash(npm test)"]
}
}
This means:
- A Bash call matching
Bash(rm -rf /)is denied immediately - Any other
Bash(rm *)prompts for approval Bash(npm test)runs without asking- Everything else prompts (the implicit default)
Critical: a broad deny rule blocks all matching calls, including more specific allow rules. Deny(Bash(aws *)) blocks every aws command, even Bash(aws s3 ls) which an allow rule might approve. A deny rule cannot have exceptions through allow rules at the same precedence level.
Bare tool names vs specifiers
Match all uses of a tool
To allow or deny every use of a tool without restriction, use only the tool name:
{
"permissions": {
"allow": ["Bash"],
"deny": ["WebFetch"]
}
}
Bash is equivalent to Bash(*). As a deny rule, both forms remove the tool from Claude's context entirely, so Claude never sees Bash at all. Bare-name removal applies to every tool except EndConversation: you can't deny it while other tools remain, and ask rules never prompt for it.
Scoped rules for fine-grained control
Add a specifier in parentheses to match specific tool uses:
{
"permissions": {
"allow": [
"Bash(npm run build)",
"Edit(src/**/*.ts)",
"WebFetch(domain:github.com)",
"Read(.*env)"
]
}
}
Each tool type uses its own specifier syntax, described below.
Bash rule syntax
Bash rules support glob patterns with * and space-sensitive word boundaries.
Exact and prefix matching
Bash(npm run build)matches the exact commandnpm run buildBash(npm run *)matchesnpm run build,npm run test, etc.Bash(npm *)matches any npm commandBash(* install)matchesnpm install,pip install, etc.
A single * matches any sequence of characters including spaces, so Bash(git *) matches both git log --oneline and git push origin main.
Word boundaries
The space before * enforces a word boundary. Bash(ls *) matches ls -la but not lsof. Without the space, Bash(ls*) matches both because there's no word boundary constraint.
The suffix :* is an equivalent way to write a trailing wildcard: Bash(ls:*) matches the same commands as Bash(ls *).
Compound commands
Claude Code recognizes shell operators &&, ||, ;, |, |&, &, and newlines. A rule must match each subcommand independently. When you approve a compound command with "Yes, don't ask again", Claude Code saves separate rules for each subcommand, not one rule for the whole string.
For example, approving git status && npm test saves a rule for npm test, so future invocations of npm test are recognized regardless of what precedes the &&.
Wrappers that are automatically stripped
Before matching Bash rules, Claude Code strips these wrappers:
timeout,time,nice,nohup,stdbuf(process utilities)command,builtin,noglob(shell builtins)- Bare
xargs(without flags) - Leading assignments of safe environment variables:
NODE_ENV=test,LANG=C,NO_COLOR=1
So Bash(npm test *) matches both npm test and NODE_ENV=test npm test.
Not stripped: docker exec, direnv exec, npx, devbox run, mise exec. These are development environment runners that execute their arguments as a command. A rule like Bash(devbox run npm test) matches that exact form; Bash(devbox run *) would match anything after run, which is too broad.
Built-in read-only commands
Claude Code recognizes these Bash commands as read-only and runs them without any permission prompt:
ls, cat, echo, pwd, head, tail, grep, find, wc, which, diff, stat, du, cd (when staying within your working directory), and read-only forms of git (status, log, show, etc.).
This list is not configurable. To require a prompt for one of these, add an ask or deny rule.
PowerShell rules
PowerShell rules use the same format: PowerShell(Get-ChildItem *), PowerShell(rm:*), etc. Matching is case-insensitive, and common aliases are canonicalized: a rule for Get-ChildItem also matches gci, ls, and dir.
Claude Code parses the PowerShell AST and checks each subcommand independently (split on |, ;, &&, ||).
Read and Edit rule syntax
Read rules control file reading. Edit rules control all file modification tools (Edit, Write, NotebookEdit, and file-modifying Bash commands like rm and sed). Both use gitignore-style path matching with four anchor types:
| Anchor | Example | Resolves to |
|:---|:---|:---|
| // | Read(//Users/alice/secrets/**) | Absolute filesystem path /Users/alice/secrets/ |
| ~/ | Read(~/Documents/*.pdf) | Home directory relative: /Users/alice/Documents/*.pdf |
| / | Edit(/src/**/*.ts) | Project root or settings source relative |
| . or none | Read(src/**.ts) or Read(.env) | Current working directory relative |
The /path anchor resolves differently depending on where the rule is defined:
- In
.claude/settings.json: resolves to<project-root>/path - In
~/.claude/settings.json: resolves to~/.claude/path - In
.claude/settings.local.json: resolves to the directory you started Claude Code from - In CLI flags or
/permissionsUI: resolves to current directory
Glob patterns
Glob patterns follow gitignore semantics:
*matches any characters within a single path segment (e.g.,src/*.tsmatchessrc/file.tsbut notsrc/dir/file.ts)**matches across directories (e.g.,src/**/*.tsmatches any.tsfile undersrc/)- Bare filenames match at any depth:
Read(.env)is equivalent toRead(**/.env)
Examples:
{
"permissions": {
"allow": [
"Edit(/docs/**)",
"Read(~/.ssh/**)",
"Edit(//**/.env)"
],
"deny": [
"Read(/secrets/**)",
"Edit(.env)"
]
}
}
Symlink handling
When Claude accesses a symlink, permission rules check both the symlink path and the file it resolves to:
- Allow rules apply only when both the symlink and its target match
- Deny rules apply when either the symlink or target matches
Example: with Read(./project/**) allowed and Read(~/.ssh/**) denied, a symlink at ./project/key pointing to ~/.ssh/id_rsa is blocked (the target fails the allow rule and matches the deny rule).
WebFetch rules
WebFetch rules use domain: prefix and match against the hostname (case-insensitive):
{
"permissions": {
"allow": [
"WebFetch(domain:github.com)",
"WebFetch(domain:*.api.example.com)"
],
"deny": [
"WebFetch(domain:evil.com)"
]
}
}
WebFetch(domain:example.com)matcheshttp://example.comandhttps://example.comWebFetch(domain:*.example.com)matchesapi.example.comanda.b.example.combut notexample.comitselfWebFetch(domain:example.*)matchesexample.orgbut notexample.evil.com(the wildcard doesn't cross dots)WebFetch(domain:*)matches all domains and is equivalent toWebFetch
MCP tool rules
MCP rules use the server name and optionally the tool:
{
"permissions": {
"allow": ["mcp__puppeteer"],
"deny": ["mcp__github__*"]
}
}
mcp__puppeteermatches any tool from thepuppeteerservermcp__puppeteer__puppeteer_navigatematches a specific toolmcp__*matches all MCP tools (only valid for deny/ask, not allow)
Parameter matching for deny and ask rules
Deny and ask rules can match on any top-level input parameter using Tool(param:value):
{
"permissions": {
"deny": [
"Agent(model:opus)",
"Bash(run_in_background:true)"
]
}
}
This blocks Agent calls requesting Opus model or Bash calls with run_in_background set to true. The value supports * as a wildcard. Parameter matching only works for deny and ask rules; allow rules continue to use each tool's own specifier syntax.
Note: nested fields and parameters that Claude omits are not matchable this way.
Where rules live: settings precedence
Permission rules are read from these scopes in order (deny takes precedence across scopes):
- Managed settings (organization-wide, can't be overridden)
- Command-line flags (
--allowedTools,--disallowedTools) - Local project settings (
.claude/settings.local.json) - Shared project settings (
.claude/settings.json) - User settings (
~/.claude/settings.json)
Rules merge across scopes. If user settings allow a tool but project settings deny it, the deny wins. If managed settings deny a tool, no other level can allow it.
Why rules don't live in project .claude/settings.json for auto mode
Auto mode is available only to user and managed settings, not to project or local settings. The classifier reads autoMode configuration only from ~/.claude/settings.json and managed settings, not from .claude/settings.json or .claude/settings.local.json. This prevents a repository from granting itself auto mode.
How sandboxing interacts with permission rules
Permissions and sandboxing are separate defense layers:
- Permissions control which tools Claude Code can call
- Sandboxing provides OS-level isolation for Bash commands' filesystem and network access
When you enable sandboxing with /sandbox and set autoAllowBashIfSandboxed: true (the default), sandboxed Bash commands run without prompting, even if you have a bare Bash ask rule. The sandbox boundary substitutes for the permission prompt.
Read and Edit deny rules are merged with sandbox filesystem rules: the final boundary is the union of both. WebFetch allow rules are merged with the sandbox's allowedDomains list.
In plan mode, the substitution doesn't apply: a bare Bash ask rule still prompts for every Bash command, even sandboxed ones.
Worked example: a realistic project configuration
Here's a settings file for a team project that uses npm, git, and internal APIs:
{
"permissions": {
"defaultMode": "acceptEdits",
"allow": [
"Bash(npm run *)",
"Bash(npm test)",
"Bash(npm install)",
"Bash(git commit *)",
"Bash(git push origin *)",
"Edit(src/**)",
"Edit(tests/**)",
"Read(docs/**)",
"WebFetch(domain:api.internal.example.com)"
],
"deny": [
"Bash(npm publish)",
"Bash(git push origin main)",
"Edit(.env)"
]
}
}
This configuration:
- Sets
acceptEditsas default, so file edits and basic filesystem commands run without prompting - Allows npm scripts and test commands
- Allows commits but blocks publishes
- Allows pushes to feature branches but denies direct pushes to main
- Allows edits to src and tests but not .env
- Allows reads from docs
- Allows internal API calls but blocks external ones
Common mistake
Writing rules that try to constrain command arguments in fragile ways. For example, Bash(curl http://github.com/ *) intends to restrict curl to GitHub URLs but fails on variations: options before the URL (curl -X GET), different protocol (curl https://), redirects, shell variables (URL=http://github.com && curl $URL), extra spaces. For reliable URL filtering, use WebFetch with WebFetch(domain:github.com) instead, or deny curl entirely and use the WebFetch tool for network requests. Bash pattern matching is a convenience, not a security boundary; use permission rules for policies that must hold.
Sources and license context
These references informed the lesson. ToolDix adds its own explanation, workflow, and practice rather than reproducing source material. Every link below leaves ToolDix and opens the publisher's own site in a new tab.
- Configure permissions (opens code.claude.com in a new tab)External · code.claude.com (Anthropic terms apply)
- Sandboxing (opens code.claude.com in a new tab)External · code.claude.com (Anthropic terms apply)
Keep going
Read these next on ToolDix.
Original lessons that build on what you just read.