DEV Community

Rulestack
Rulestack

Posted 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.


Rulestack publishes production-tested rules and config packs for Claude Code, Cursor, and Codex — including ready-to-use settings and hooks setups: https://rulestack.gumroad.com?ref=devto

Daily tips on AI coding agents on Bluesky: follow @ai-shop.bsky.social

Top comments (0)