Skip to main content
Claude Code Tutorial: From First Command to Custom Workflows

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.

Intermediate15 minBy ToolDix Editorial

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

Claude Code Tutorial practice loop
1

Frame

Name the outcome and constraints.

2

Build

Try one bounded workflow.

3

Review

Keep evidence, revise, and share.

Rule format and precedence

ToolDix original diagram
Permission flow: read vs. write
Read: No approval
Read files, run tests, list directories
Propose: Show diff
Claude Code shows you the changes before writing
Write: Requires approval
Approve, reject, or modify the proposed change

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:

  1. A Bash call matching Bash(rm -rf /) is denied immediately
  2. Any other Bash(rm *) prompts for approval
  3. Bash(npm test) runs without asking
  4. 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 command npm run build
  • Bash(npm run *) matches npm run build, npm run test, etc.
  • Bash(npm *) matches any npm command
  • Bash(* install) matches npm 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 /permissions UI: resolves to current directory

Glob patterns

Glob patterns follow gitignore semantics:

  • * matches any characters within a single path segment (e.g., src/*.ts matches src/file.ts but not src/dir/file.ts)
  • ** matches across directories (e.g., src/**/*.ts matches any .ts file under src/)
  • Bare filenames match at any depth: Read(.env) is equivalent to Read(**/.env)

Examples:

{
  "permissions": {
    "allow": [
      "Edit(/docs/**)",
      "Read(~/.ssh/**)",
      "Edit(//**/.env)"
    ],
    "deny": [
      "Read(/secrets/**)",
      "Edit(.env)"
    ]
  }
}

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) matches http://example.com and https://example.com
  • WebFetch(domain:*.example.com) matches api.example.com and a.b.example.com but not example.com itself
  • WebFetch(domain:example.*) matches example.org but not example.evil.com (the wildcard doesn't cross dots)
  • WebFetch(domain:*) matches all domains and is equivalent to WebFetch

MCP tool rules

MCP rules use the server name and optionally the tool:

{
  "permissions": {
    "allow": ["mcp__puppeteer"],
    "deny": ["mcp__github__*"]
  }
}
  • mcp__puppeteer matches any tool from the puppeteer server
  • mcp__puppeteer__puppeteer_navigate matches a specific tool
  • mcp__* 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):

  1. Managed settings (organization-wide, can't be overridden)
  2. Command-line flags (--allowedTools, --disallowedTools)
  3. Local project settings (.claude/settings.local.json)
  4. Shared project settings (.claude/settings.json)
  5. 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 acceptEdits as 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.

Keep going

Read these next on ToolDix.

Original lessons that build on what you just read.