10 / 20Security7 MIN READ

Permissions

Permissions are allow, ask, and deny rules that Claude Code evaluates before every tool call, layered on top of a permission mode that sets the baseline for what runs without asking.

A tool call passing through an approval gateA request travels from Claude on the left toward the tool on the right, passing through a three-stage gate that evaluates deny rules, then ask rules, then allow rules.CALLRULESDENYASKALLOWTOOLfirst match wins, in this order

What it is

Two things decide whether a tool call runs. The permission mode sets the baseline: default (labelled Manual) approves reads only; acceptEdits adds file edits and common filesystem commands; plan blocks edits entirely; auto runs everything past a classifier; dontAsk denies anything not pre-approved; bypassPermissions skips the checks. On top of that, rules written as Tool or Tool(specifier) allow, prompt for, or block specific calls.

Rules are evaluated deny, then ask, then allow. The first match wins and specificity does not change the order, so a broad Bash(aws *) deny blocks a narrower Bash(aws s3 ls) allow. Deny rules also behave differently depending on shape: a bare tool name like Bash removes the tool from Claude’s context entirely, while a scoped rule like Bash(rm *) leaves the tool available and blocks matching calls.

Rules live in the same layered settings files as everything else, and permission rules merge across scopes rather than overriding. A deny at any level wins: a user-level deny blocks a project-level allow, and managed settings cannot be overridden even by command-line flags. This is enforcement by Claude Code, not by the model — nothing you write in a prompt or in CLAUDE.md changes what is allowed.

What it does for you

  • It removes the prompts you always approve. Allowing Bash(npm run test *) turns a dozen interruptions a day into zero without widening anything else.
  • It draws a boundary Claude cannot argue with. Read(./.env) in deny means the file is unreadable through Claude’s file tools and through the Bash commands Claude Code recognises, regardless of how the request is phrased.
  • It makes a policy shareable. Rules checked into .claude/settings.json apply to every developer on the repository, and managed settings apply to every machine in an organisation.

How it works

  1. 01The tool call is matched against your rules

    Deny first, then ask, then allow. The first match determines the outcome. A rule with no parentheses matches every use of the tool; Bash(*) is equivalent to a bare Bash.

  2. 02Bash rules match subcommand by subcommand

    Claude Code recognises &&, ||, ;, |, |&, &, and newlines as separators, so Bash(safe-cmd *) does not authorise safe-cmd && other-cmd. Every subcommand must match a rule independently.

  3. 03Path rules use gitignore syntax with four anchors

    //path is absolute from the filesystem root, ~/path is home-relative, /path anchors at the settings source that defines it, and path or ./path is relative to the current directory. A single leading slash is not an absolute path.

  4. 04Read and Edit rules cover the tools that share their shape

    Edit(...) applies to Write and NotebookEdit too; a rule written for Write(...) is accepted but never consulted, and Claude Code warns at startup. A Read deny also blocks Edit on that path.

  5. 05Some commands never prompt at all

    A built-in read-only set — ls, cat, echo, pwd, head, tail, grep, find, wc, which, diff, stat, du, cd, and read-only forms of git — runs without a prompt in every mode. The set is not configurable; add an ask or deny rule to require a prompt.

  6. 06Hooks can override the outcome in one direction

    A PreToolUse hook that exits 2 blocks the call before rules are evaluated, so it beats an allow rule. A hook returning "allow" does not beat a deny or ask rule.

How to implement it

  1. 01Start from what you keep approving

    Run /permissions to see the current rules and their source file, or run /fewer-permission-prompts, which scans your transcripts and proposes an allowlist for the read-only calls you approve most.

  2. 02Write allow rules narrowly

    Prefer Bash(npm run test *) over Bash(npm *). Remember the space before the wildcard enforces a word boundary: Bash(ls *) matches ls -la but not lsof.

  3. 03Deny the paths that must never be read

    Add Read(./.env), Read(./.env.*), and Read(./secrets/**). Deny rules match a single-segment directory pattern at any depth, so Read(secrets/**) covers nested copies too.

  4. 04Split the file between shared and personal

    Team rules go in .claude/settings.json and get committed. Your own approvals land in .claude/settings.local.json at the repository root, which Claude Code gitignores when it writes there.

  5. 05Verify the rules are actually applied

    Project allow rules only take effect after you accept the workspace trust dialog. Run /permissions and confirm each rule shows the file you expect.

Examples

.claude/settings.jsonjson
{
  "permissions": {
    "defaultMode": "acceptEdits",

    "allow": [
      "Bash(npm run lint)",
      "Bash(npm run test *)",
      "Bash(npm run build)",
      "Bash(git status *)",
      "Bash(git diff *)",
      "Bash(git log *)",
      "Bash(git add *)",
      "Bash(git commit *)",
      "Read(~/.config/app/**)",
      "WebFetch(domain:code.claude.com)",
      "mcp__github__get_*"
    ],

    "ask": [
      "Bash(git push *)",
      "Bash(npm publish *)",
      "Bash(docker *)"
    ],

    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Read(~/.ssh/**)",
      "Read(~/.aws/**)",
      "Edit(./db/migrate/**)",
      "Bash(git push --force *)",
      "Bash(curl *)",
      "Bash(rm -rf *)",
      "Agent(Explore)"
    ],

    "additionalDirectories": ["../shared-types"]
  }
}
A real project block: routine commands allowed, dangerous ones gated, secrets and history blocked outright.
Why the anchors matterbash
# In ~/.claude/settings.json, this blocks ~/.claude/secrets/**
#   — NOT a "secrets" directory in your project.
Read(/secrets/**)

# To write a user-level rule that applies inside every project,
# use an absolute or home-relative anchor instead:
Read(//**/.env)          # any .env anywhere on the filesystem
Read(~/.ssh/**)          # your SSH keys, from any project

# In .claude/settings.json (project scope), /src anchors at the project root:
Edit(/src/**)            # <project>/src/** only
Edit(**/src/**)          # any src directory at any depth
The same pattern means different things depending on which file it lives in.

Use it when

  • Removing the daily interruptions for npm test, git status, and git diff while keeping git push gated.
  • Blocking reads of .env, SSH keys, and cloud credentials so a prompt injection cannot exfiltrate them through the file tools.
  • Preventing edits to migrations, generated schemas, or vendored code with an Edit deny rule.
  • Locking a CI run to exactly the tools it needs with --permission-mode dontAsk plus a short allow list.
  • Disabling a specific subagent organisation-wide with Agent(Explore) in a managed deny list.

Avoid it when

  • You are trying to constrain command arguments precisely. Bash(curl http://github.com/ *) misses flags before the URL, https, redirects, and variables. Deny the network commands and use WebFetch(domain:...) instead.
  • The boundary must hold against every process. Read and Edit deny rules cover Claude’s file tools and the file commands Claude Code recognises in Bash, not a Python script that opens the file itself. That needs the sandbox.
  • You want a deny rule with exceptions. Deny beats allow unconditionally, so a broad deny cannot carry an allowlist — write narrower denies instead.
  • The condition is dynamic. Rules are static patterns; a PreToolUse hook can inspect the actual arguments and decide.

Common mistakes

  • SYMPTOMA Write(docs/**) deny rule is accepted but never blocks anything.

    FIXOnly Edit(path) and Read(path) rules are consulted for file paths. Use Edit(docs/**) — it covers Write and NotebookEdit as well. Claude Code prints a startup warning for the wrong form.

  • SYMPTOMA user-level Read(/secrets/**) rule does not protect the project directory it was meant for.

    FIXA single leading slash anchors at the settings source, so in user settings it resolves to ~/.claude/secrets/**. Use // for absolute paths or ~/ for home-relative ones.

  • SYMPTOMAn allow rule for a command still prompts when Claude runs it inside an environment runner.

    FIXClaude Code strips only a fixed wrapper list — timeout, time, nice, nohup, stdbuf, command, builtin, noglob, and bare xargs. devbox run, npx, and docker exec are not stripped. Write a rule that names both the runner and the inner command.

  • SYMPTOMProject allow rules in a cloned repository do nothing.

    FIXpermissions.allow and additionalDirectories in .claude/settings.json grant capability, so they apply only after you accept the workspace trust dialog for that folder.

  • SYMPTOMAdding a bare Bash deny rule makes the next turn slow and expensive.

    FIXA bare tool name removes the tool definition from the system prompt layer, which invalidates the whole prompt cache. Use a scoped rule like Bash(rm *) when you only mean to block specific calls.

Best practices

  • Allow narrowly and deny broadly: a specific allow list plus blanket denies on secrets is easier to reason about than the reverse.
  • Keep team rules in .claude/settings.json and personal approvals in .claude/settings.local.json.
  • Use ask rather than deny for actions you sometimes want, so you get a checkpoint instead of a wall.
  • Anchor path rules deliberately: // for absolute, ~/ for home, / for the settings source.
  • Pair permission rules with the sandbox when the boundary matters, since rules do not cover arbitrary subprocesses.
  • Press Ctrl+E on a Bash prompt to get an explanation of the command before approving something unfamiliar.

Try it in five minutes

Prove that deny beats allow, and that a deny rule reaches Bash too.

  1. 1.In a test repository, create .env containing SECRET=hunter2, then add {"permissions":{"allow":["Read(./.env)"],"deny":["Read(./.env)"]}} to .claude/settings.json.
  2. 2.Start claude and ask it to read .env. It is refused: deny is evaluated first.
  3. 3.Ask it to run cat .env. Still refused — deny rules apply to the file commands Claude Code recognises in Bash.
  4. 4.Ask it to run python3 -c "print(open('.env').read())". It succeeds, because a subprocess that opens the file itself is outside the rule system.
  5. 5.Run /permissions and confirm which file each rule came from.

Verified against code.claude.com/docs/en/permissions on 2026-08-09. See content/SOURCES.md for the full table.

← / → MOVE BETWEEN CONCEPTS