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.
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 likeBash(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
askrule prompts even when a more specificallowalso matches the same call. If you inherited anaskrule 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:
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:
{
"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:
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:
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: "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 oradditionalDirectories.plan— reads and runs read-only shell commands to explore, but does not edit your source files.bypassPermissions— skips permission prompts. Explicitaskrules, MCP tools markedrequiresUserInteraction, and root/home removals likerm -rf /still prompt as circuit breakers, but writes to protected paths such as.gitand.claudeare 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.
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
- Run
/permissionsand note which files are contributing. If you expected one and see three, stop and read the other two. - Grep every settings file for
Write(,MultiEdit(,NotebookEdit(andGlob(with a path inside. Rewrite each asEdit(orRead(. - 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.
- Find deny rules that were written expecting an allow exception underneath. There is no such construct — narrow the deny instead.
- Check
defaultMode, in every scope. It is the ceiling your rules operate under. - 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.