DEV Community

Rulestack
Rulestack

Posted on Edited on

Claude Code permission rules: how allow, deny, and ask actually match

Claude Code's permission system is small enough to fit in one settings file and subtle enough that most people write at least one rule that does nothing. The rule looks correct, the JSON parses, Claude Code starts, and the rule is quietly never consulted. This is a walk through how allow/deny/ask actually match, where the files live, and the specifiers that look plausible but are silently ignored.

Where the rules live

Permission rules go in the permissions key of a settings.json. There are four scopes:

  • ~/.claude/settings.json — your user settings, applied in every project.
  • .claude/settings.json — project settings, checked into source control and shared with the team.
  • .claude/settings.local.json — your personal project overrides. When Claude Code creates this file, it configures git to ignore it.
  • Enterprise managed settings — deployed by an administrator and not overridable. The path is OS-specific: /Library/Application Support/ClaudeCode/managed-settings.json on macOS, /etc/claude-code/managed-settings.json on Linux and WSL, and C:\Program Files\ClaudeCode\managed-settings.json on Windows. All three platforms also read a managed-settings.d/ drop-in directory beside that file.

Precedence, highest to lowest: managed → command-line arguments → local → project → user.

There is one thing that trips people up here. Most settings override down the chain — a project value replaces a user value. Permission rules do not. The allow, deny, and ask lists merge across every scope. Your user deny rules and the project deny rules are both in force at once. The practical consequence: if a tool is denied at any level, no other level can allow it. A user-level deny beats a project-level allow, and a managed deny beats everything, including --allowedTools on the command line.

allow, deny, ask — and how deny wins

Three lists, three behaviors:

  • allow — use the tool without a prompt.
  • ask — always prompt for confirmation, even if something else would have allowed it.
  • deny — refuse the call.

Rules are evaluated in a fixed order: deny, then ask, then allow. The first match in that order wins, and specificity does not change the order. This is the opposite of how CSS or firewall rules often work, and it is the single most important thing to internalize.

A broad deny cannot carry allowlist exceptions. If you write:

{
  "permissions": {
    "deny": ["Bash(aws *)"],
    "allow": ["Bash(aws s3 ls)"]
  }
}
Enter fullscreen mode Exit fullscreen mode

then aws s3 ls is blocked. The deny matched first; the more specific allow never gets a turn. The same applies between ask and allow — a matching ask rule prompts even when a narrower allow also matches.

One more distinction worth knowing: a bare tool-name deny and a scoped deny behave differently. "deny": ["Bash"] removes Bash from Claude's context entirely — the model never sees the tool. "deny": ["Bash(rm *)"] leaves Bash available and blocks only matching calls.

Tool specifier syntax

A rule is either Tool or Tool(specifier). A bare tool name matches every use: Bash, WebFetch, Read. Bash(*) is equivalent to Bash.

Bash

Bash specifiers are glob patterns, and the wildcard can sit anywhere:

  • Bash(npm run build) — the exact command.
  • Bash(npm run test:*) — a prefix match. The :* suffix is Claude Code's canonical way to write a trailing wildcard; it is exactly equivalent to Bash(npm run test *). Note :* is only recognized at the end of a pattern.
  • Bash(git * main) — wildcards mid-pattern; matches git checkout main and git merge main.

The space before a trailing * is load-bearing. Bash(ls *) matches ls -la but not lsof, because the space enforces a word boundary. Bash(ls*) with no space matches both.

Here is where a widely repeated claim is wrong. Prefix matching is shell-operator aware. A rule like Bash(safe-cmd *) will not authorize safe-cmd && other-cmd. Claude Code splits compound commands on &&, ||, ;, |, |&, &, and newlines, and every subcommand must match a rule on its own. So the danger is not that operators leak through — they don't.

The real fragility is in trying to constrain arguments. The docs are explicit that a pattern like Bash(curl http://github.com/ *) fails to do what it looks like it does. It won't match curl -X GET http://github.com/... (option before the URL), curl https://github.com/... (different protocol), curl -L http://bit.ly/xyz (redirects to GitHub), URL=http://github.com && curl $URL (variable), or curl http://github.com (extra space). If you want to restrict network destinations, deny curl/wget and use WebFetch(domain:...) instead, or enforce it in a PreToolUse hook. A Bash argument pattern is guidance, not a boundary.

One convenience: Claude Code strips a fixed set of process wrappers before matching, so Bash(npm test *) also covers timeout 30 npm test. The stripped wrappers are timeout, time, nice, nohup, stdbuf, and unflagged xargs. Environment runners like npx, docker exec, and devbox run are not stripped — Bash(devbox run *) would match devbox run rm -rf ., so write the full inner command instead.

Read and Edit paths

Edit rules apply to every built-in file-editing tool, not just the Edit tool. Read rules apply best-effort to Read, Grep, Glob, @file mentions, and IDE-shared context.

Path patterns follow gitignore semantics, with four anchor types that are easy to confuse:

Pattern Anchored at Example
//path filesystem root Read(//Users/alice/secrets/**)
~/path home directory Read(~/.zshrc)
/path the settings source Edit(/src/**/*.ts) → <project root>/src/... in project settings
path or ./path current directory Read(.env) → <cwd>/.env

The trap: a single leading slash is not an absolute path. Read(/secrets/**) in your user settings matches ~/.claude/secrets/**, not /secrets and not your project. For a genuine absolute path use //. For a rule in user settings that should apply inside every project, use // or ~/.

Bare filenames match at any depth, so Read(.env) and Read(**/.env) are equivalent and both cover config/.env. As of v2.1.208 a Read deny rule also blocks the Edit tool on the same path — but not Write or NotebookEdit, so add an explicit Edit(...) deny for paths nothing should change.

WebFetch and MCP

WebFetch matches on hostname, case-insensitively:

  • WebFetch(domain:example.com) — that host.
  • WebFetch(domain:*.example.com) — any subdomain (api.example.com), but not the apex example.com.
  • WebFetch(domain:*) — equivalent to a bare WebFetch.

MCP rules use the server name, optionally with a tool name. Contrary to another common belief, the tool-level wildcard is supported:

  • mcp__puppeteer — every tool from the puppeteer server.
  • mcp__puppeteer__* — also every tool from that server (explicit wildcard).
  • mcp__puppeteer__puppeteer_navigate — one specific tool.

The catch for allow rules: a tool-name glob is only honored after a literal mcp__<server>__ prefix, and the server segment itself must be glob-free. mcp__github__get_* is fine; an unanchored mcp__* in an allow list is skipped with a warning and approves nothing. (mcp__* works fine as a deny rule.)

Parameter matching

deny and ask rules can match a top-level input parameter with Tool(param:value) — Agent(model:opus), Bash(run_in_background:true). The value supports *. This does not work for fields a tool already canonicalizes: command (Bash), file_path (Read/Edit/Write), path (Grep/Glob), notebook_path (NotebookEdit), and url (WebFetch). Which leads directly to the most valuable section.

Rules that silently do nothing

These parse fine and are then ignored. Several now emit a startup warning, but only on recent versions.

Write(path), NotebookEdit(path), Glob(path). As of v2.1.210, the file-permission checks only match Edit(path) and Read(path) rules. A path rule on Write, NotebookEdit, or Glob is accepted and never consulted, and Claude Code now warns at startup for each one. Use Edit(...) in place of Write(...) or NotebookEdit(...) (Edit rules cover all file-editing tools), and Read(...) in place of Glob(...). The exact warning:

Permission deny rule (.claude/settings.json): Write(docs/**) is not matched by file permission checks — only Edit(path) rules are. Use Edit(docs/**) instead (Edit rules cover all file-editing tools).
Enter fullscreen mode Exit fullscreen mode

A bare Write deny with no path is unaffected — it removes the tool everywhere.

Bash(command:rm *) and other canonicalized-field param rules. Because command is a field Bash already matches with its own rules, Bash(command:...) is ignored and warned about. Write Bash(rm *).

Unanchored allow globs. "*", "B*", or mcp__* in the allow list are skipped with a warning and auto-approve nothing.

Typo'd or mislabeled tool names. A deny/ask rule whose tool name matches no known tool warns at startup (names containing _ or * are exempt). Also, the label you see in the transcript is not always the canonical name — the tool shown as Stop Task is canonically TaskStop, and a rule written Stop Task will not match. Use the canonical names from the tools reference.

Prefix rules on exec wrappers. watch, setsid, ionice, and flock always prompt — a Bash(watch *) allow rule can't auto-approve them. The same goes for find with -exec or -delete: Bash(find *) doesn't cover those forms, because the glob could smuggle in an exec. If you need one of these, write an exact-match rule for the full command string.

Debugging

Run /permissions in a session to see every active rule and which settings.json file each one came from — the fastest way to find a rule that isn't taking effect because a higher scope shadows it. Start with --verbose to see the exact parameter names and values Claude Code sees for each tool call, which is what you need when a param-match rule isn't firing.

Modes set the baseline that rules layer on top of. There are more than the usual three: default (labeled Manual; prompts on first use), acceptEdits (auto-accepts edits and common filesystem commands in your working directory), plan (read-only exploration, no edits), auto (auto-approves with a background safety classifier), dontAsk (auto-denies anything not pre-approved — for CI), and bypassPermissions (skips prompts entirely; for throwaway containers only). In the CLI, Shift+Tab cycles between permission modes. Set a persistent default with defaultMode under permissions. Deny and explicit ask rules still apply in every mode, including bypassPermissions.

A starter settings.json

A conservative baseline for a TypeScript repo. Every form here is a verified, matched specifier:

{
  "permissions": {
    "defaultMode": "default",
    "allow": [
      "Bash(npm run build)",
      "Bash(npm run test:*)",
      "Bash(npm run lint:*)",
      "Bash(npx tsc:*)",
      "Bash(git commit *)",
      "Bash(git status)",
      "Bash(git diff:*)"
    ],
    "ask": [
      "Bash(git push *)"
    ],
    "deny": [
      "Read(.env)",
      "Read(.env.*)",
      "Read(**/secrets/**)",
      "Edit(.env)",
      "Bash(curl *)",
      "Bash(wget *)"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The Read(.env) and matching Edit(.env) pair keeps both reading and writing your secrets out of reach; the Bash(curl *)/Bash(wget *) denies close the network side-channel that a Read deny alone leaves open. Keep this in .claude/settings.json for team-wide rules, and put anything personal in .claude/settings.local.json, remembering that both lists merge and deny always wins.


More from this account on the same problem:


Update (2026-11-10)

Claude Code was at 2.1.211 when this was published. I read the CHANGELOG through 2.1.322 and the current permissions page against the text above. The original text is unchanged; these are the points that moved.

A Read deny now covers Write too. The text above says a Read deny also blocks Edit on the same path, but not Write. The permissions page now says a Read deny blocks both the Edit and Write tools on that path, including creating a new file there, and gives v2.1.228 as the minimum for Write. NotebookEdit is still not covered, so the explicit Edit(...) deny for paths nothing should change still earns its place.

Grep(path) belongs on that list too. Alongside Write(path), NotebookEdit(path) and Glob(path), the page lists Grep(path): a Grep(docs/**) deny does not stop Grep from searching docs. Write Read(docs/**) instead.

Fewer silent rules. Since 2.1.260, a rule with text after the closing parenthesis (for example Bash(ls) x) is reported as an invalid setting instead of being ignored. Since 2.1.305, rules that restrict nothing show as warning rows under the banner, and since 2.1.311 each of those warnings shows once per user until the rule changes. The page also says a settings file skips any mcp__ rule that has parentheses; to match a parameter on an MCP tool, pass the deny rule with --disallowedTools.

`dir/ is not symmetric.** Since 2.1.214, a single-segment allow rule such as Edit(src/) approves only /src, while a deny or ask rule such as Read(secrets/) still matches a secrets directory at any depth. To allow a directory name at any depth, write Edit(/src/). Bare filenames like Read(.env)` still match at any depth.

Bash(git * main) now draws a warning. I used it above as a plain example of a mid-pattern wildcard. Since 2.1.246, Claude Code warns at startup about Bash allow rules with a wildcard before the subcommand, because the * also matches options placed before it. The permissions page names -c, which makes git run a program you choose. Keep that shape out of allow.

Mid-pattern :*. The 2.1.282 entry says Bash rules with a mid-pattern :* now work from every source, with a startup warning on how they match. The permissions page still says :* is only recognized at the end, and that in Bash(git:* push) the colon is a literal character. The two disagree; the space form (Bash(npm run test *)) avoids the question.

More stripped wrappers than the list above. The page lists the shell builtins command and builtin and zsh's noglob alongside timeout, time, nice, nohup, stdbuf and flag-free xargs. command -v and zsh's nocorrect are not stripped.

MCP allow rules and server names. Since 2.1.299, an MCP allow rule reaches only the server it names: mcp__a no longer allows a server named a__b or a_. For a server whose name contains __ or ends in _, write mcp__<server>__* with the whole normalized name.

WebFetch(domain:*) is not a bare WebFetch. The text above calls them equivalent. The page says both cover every URL but are applied differently: only the domain: form adds its domain to the sandbox's allowed or denied list. They also differ on Artifact pages: since 2.1.268, a plain WebFetch deny or ask rule no longer applies to Artifact tool reads and updates, while an Artifact rule or WebFetch(domain:claude.ai) does.

Where defaultMode is honored. Since 2.1.257, defaultMode: "bypassPermissions" in .claude/settings.json or .claude/settings.local.json is ignored, as "auto" is; set it in user or managed settings, or pass --permission-mode.

Where Bash denies reach. Since 2.1.257, Bash Read()/Edit() denies refuse < file redirects and reader commands such as tac and egrep, and since 2.1.269 an Edit() deny covers the file a Bash tee writes. A 2.1.259 change that applied Read() denies to Bash arguments more widely was reverted in 2.1.260 after it denied npm run build under Read(./**/build/**), so the curl/wget denies in the starter file still do their job. Fixes also closed cases where Bash deny and ask rules missed a command: a name set through a declare or export prefix (2.1.290) and commands written with quoted words (2.1.319).

Unchanged on the current page: the space before a trailing * (Bash(ls *) does not match lsof), Bash(command:rm *) being ignored with a warning, the warning for unknown tool names and the Stop Task label versus the canonical TaskStop, unanchored allow globs ("*", "B*", mcp__*) approving nothing, watch, setsid, ionice, flock and find -exec/-delete always prompting under a prefix rule, environment runners such as npx, docker exec and devbox run not being stripped, and a Bash rule covering the command text Claude writes rather than acting as a boundary around the program.

Has one of these changes made a rule in your own settings stop matching, or start matching more than you meant? Tell me which one in the comments below.

Primary sources for this update:

Top comments (0)