Skip to content

Claude Code Permission Rules That Look Right and Do Nothing

A permission rule can be valid, accepted at startup, and never consulted. Nothing distinguishes a rule that is protecting you from a rule that is decoration. Here are the shapes that fail.

August 8, 20269 min readShift The Culture

You wrote an allow rule and Claude Code still asks. Or — worse and quieter — you wrote a deny rule, nothing has prompted you since, and you have been assuming it is doing something. Both have the same root cause: a permission rule can be syntactically valid, accepted at startup, and then never consulted. Nothing in the interface distinguishes a rule that is protecting you from a rule that is decoration. Here are the exact shapes that fail, why, and the replacement for each.

First: the evaluation order, because half of it is this

Rules are evaluated deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity does not change the order. That single sentence explains most “my allow rule does nothing” reports:

  • A broad deny like Bash(aws *) blocks every matching call, including one that also matches a narrower allow like Bash(aws s3 ls). A deny rule cannot carry allowlist exceptions. If you want most of a command family blocked with one exception, you cannot express it as deny-broad plus allow-narrow — the allow is unreachable.
  • The same applies between ask and allow: a matching ask rule prompts even when a more specific allow also matches the same call. If you inherited an ask rule from a shared config, your allow will never be reached.
Specificity is not a tiebreaker. The categories are ordered, and the first category to match wins outright.

Second: it is not one file. It is up to five, merged

Settings arrive from several scopes at once, in this precedence order:

settings scopes, highest precedence first
1  managed     /Library/Application Support/ClaudeCode/managed-settings.json   (macOS)
               /etc/claude-code/managed-settings.json                          (Linux/WSL)
               C:\Program Files\ClaudeCode\managed-settings.json               (Windows)
2  command-line arguments  (temporary session overrides)
3  local       .claude/settings.local.json      (gitignored, personal)
4  project     .claude/settings.json            (committed, team-wide)
5  user        ~/.claude/settings.json          (lowest)

And here is the part that catches people who know the precedence list: permission rules merge across scopes rather than overriding.Ordinary settings follow the precedence list — the higher scope's value wins. Permission rules do not. The allow, ask and deny lists from every applicable scope are combined into one set, and then evaluated in the deny→ask→allow order above. A deny rule from anyscope blocks the action, even if a higher-precedence scope allows it. You cannot override an inherited deny from your local file. That is deliberate, and it is also why “I put it in the higher-priority file” is not a fix.

Failure 1: a path rule on Write is never consulted

This is the most damaging one, because the rule people write most often to protect secrets is exactly the shape that does nothing:

looks right, is never checked
{
  "permissions": {
    "deny": ["Write(.env*)"]
  }
}

Claude Code checks file permissions against Edit(path) and Read(path) rules only. A path rule written for Write, NotebookEdit, Glob, or the legacy MultiEdit is accepted and never consulted. Recent versions print a startup warning:

the warning, in a scrollback you have already lost
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).

The fix: use Edit(...) in place of Write(...), NotebookEdit(...) or MultiEdit(...), and Read(...) in place of Glob(...). Edit rules cover all file-editing tools — that is the point of the consolidation. Note the exception that keeps this from being a blanket rule: a tool-name rule with no path, such as a bare deny for Write, does match, at the tool level, everywhere. It is specifically the parenthesised path form that is ignored.

Failure 2: one leading slash does not mean the filesystem root

Read and Edit rules use gitignore pattern syntax, and the anchoring is where a rule quietly protects the wrong directory:

four pattern shapes, four meanings
Read(.env)          bare name — gitignore semantics, matches at ANY depth
Read(/.env)         ONE slash — anchored at the SETTINGS SOURCE, not the filesystem root
Read(//etc/hosts)   TWO slashes — a real absolute path
Read(~/.ssh/**)     home-relative

A pattern like /Users/alice/file is not an absolute path. The single leading slash anchors at the directory associated with the settings source that declared the rule. So Read(/.env) in your user settings protects ~/.claude/.env — not the .env in each of your projects, which is what whoever wrote it meant. The same rule in project settings anchors at the project root and does what you expected.

There is a second-order version worth knowing if you use worktrees: local-settings rules anchor at the directory you started Claude Code from, not the repository root. In a session started at the repo root those are the same place; in a worktree session, Edit(/src/**)matches that worktree's own src/.

Failure 3: Tool(param:value) cannot police a Bash command

The Tool(param:value) form matches a top-level input parameter on any tool, when the tool is called with that parameter set to that exact value. It is available to deny and ask rules only— an allow rule for one parameter value would not establish that the whole call is safe, so allow rules keep each tool's own specifier syntax.

The practical consequence: Bash(command:rm *) is not the deny rule you want. Exact-value matching on a command string is trivially bypassed by a compound command, and Bash has its own specifier syntax for a reason. Write Bash(rm *) — and understand its limits too, because a chained command is a general problem with command-string matching, not a quirk of one rule shape.

Failure 4: an unanchored wildcard in allow auto-approves nothing

Deny and ask rules accept glob patterns in the tool-name position, matching the full tool name: "*" matches every tool, "mcp__*" matches every MCP tool across all servers. Allow rules are deliberately narrower — they accept tool-name globs only after a literal mcp__<server>__ prefix, and the server segment must be glob-free so the rule names a specific server you actually configured.

allow-rule globs: what works and what is skipped
allow: "mcp__puppeteer__*"      ✔  every tool from the puppeteer server
allow: "mcp__github__get_*"     ✔  that server's get_ tools
allow: "mcp__*"                 ✘  skipped with a warning, approves nothing
allow: "*"                      ✘  skipped
allow: "B*"                     ✘  skipped

deny:  "mcp__*"                 ✔  denies every MCP tool (globs are fine in deny)

If you pasted "mcp__*" into your allow list to stop the prompting and the prompting continued, that is why. It was skipped.

The setting that outranks all of your rules

defaultMode sets the baseline your rules operate against, and it is set in a settings file like anything else — including a settings file you did not write:

  • acceptEdits — automatically accepts file edits and common filesystem commands (mkdir, touch, mv, cp) for paths in the working directory or additionalDirectories.
  • plan — reads and runs read-only shell commands to explore, but does not edit your source files.
  • bypassPermissions — skips permission prompts. Explicit ask rules, MCP tools marked requiresUserInteraction, and root/home removals like rm -rf / still prompt as circuit breakers, but writes to protected paths such as .git and .claude are not. Isolated environments only.

If you administer machines rather than just your own, permissions.disableBypassPermissionsMode and permissions.disableAutoMode set to "disable" in managed settings are the versions of this that cannot be overridden downstream.

How to see the merged reality instead of one file

Every failure above shares a property: the rule is fine in isolation and wrong in context. So auditing one file tells you very little. What you want is the merged set — which files contributed, whichdefaultMode won, what proceeds with nobody watching, and which rules are decoration.

/permissions inside a session lists and edits rules and shows which settings file each one came from — start there, it is built in and free. We also built the always-on version as a free MIT MCP server, because we wanted the agent to be able to answer it too: whats-allowed-mcp.

install — one line, no config
claude mcp add --scope user whats-allowed -- npx -y whats-allowed-mcp

Four tools: whats_allowed (the headline — contributing files, rule counts, winning defaultMode, blanket allows, hooks, and how many rules misbehave), permission_sources (which file decides, in precedence order, and what a leading / anchors to in each one), rule_findings (every rule whose documented behaviour differs from its apparent intent, with the documented alternative), and unattended_surface (what proceeds with nobody watching).

Every finding corresponds to behaviour Anthropic documents, and links to the paragraph it comes from. There is no risk score and no “suspicious rule” heuristic — those produce confident nonsense on ordinary configurations. Source on GitHub, package on npm, and the rest of the free set on our agent tools page.

A ten-minute audit

  1. Run /permissions and note which files are contributing. If you expected one and see three, stop and read the other two.
  2. Grep every settings file for Write(, MultiEdit(, NotebookEdit( and Glob( with a path inside. Rewrite each as Edit( or Read(.
  3. Find every rule with a single leading slash and confirm which settings source it is anchored to. Move it, or double the slash if you meant an absolute path.
  4. Find deny rules that were written expecting an allow exception underneath. There is no such construct — narrow the deny instead.
  5. Check defaultMode, in every scope. It is the ceiling your rules operate under.
  6. Check your allow list for unanchored globs. Anything that is not prefixed mcp__<server>__ is being skipped.

Every behaviour above is from Anthropic's Configure permissions and Settings documentation. Some of it is version-dependent — the startup warning for unmatched path rules, in particular, requires a recent release — so check the docs against the version you are running before deciding a rule is broken.

Related: what a cloned repo tells your agent to do covers the other direction — instruction files, hook commands and MCP servers that arrive with code you did not write.

SharePost on X
Free · 13 pages · no upsell inside

Get the Operator Field Kit — free

Six production prompts, the five-step operator setup, and nine rules from our own failure log.

  • 6 complete prompts — printed in full, not previews
  • The five-step setup, each step with a pass/fail test
  • 9 rules from the failure log that produced them

The kit, then the occasional operator note. One click unsubscribes and we never sell the address.

Keep reading