Claude Code Permission Rules: Exact Match, Wildcards, and Precedence
How permission rules in Claude Code's settings.json actually parse — exact-match versus prefix wildcards, allow/deny/ask precedence, and the gotchas that cause silent prompt loops.
Claude Code Permission Rules: Exact Match, Wildcards, and Precedence
Permission rules in Claude Code look deceptively simple. You drop a string into permissions.allow in settings.json, restart the session, and expect the prompt to disappear. Half the time it works. The other half, you get a permission prompt anyway, or worse, the rule swallows commands you wanted to review.
The rules are not regex. They are not full glob. They are a small DSL with three buckets (allow / deny / ask), tool-specific matchers, and a precedence order that is easy to get wrong. This walks through the actual matching behavior and the patterns that hold up in production.
The three buckets and how they resolve
Every tool call is checked against three lists in this order:
deny\u2014 if matched, the call is blocked. No prompt, no override.allow\u2014 if matched and not denied, the call runs without a prompt.ask\u2014 if matched and not denied or allowed, the user is prompted.
If none match, the harness falls back to its default policy for that tool (read-only tools usually auto-allow, write tools usually ask).
The mental model that keeps people out of trouble: deny wins, then allow, then ask, then default. A rule in allow cannot override a rule in deny. A rule in ask cannot override allow. This is the opposite of how a lot of firewall syntaxes work, where last-match wins.
{
"permissions": {
"deny": [
"Bash(rm -rf *)",
"Bash(git push --force *)"
],
"allow": [
"Bash(git status)",
"Bash(git diff:*)",
"Read(./src/**)"
],
"ask": [
"Bash(npm install:*)"
]
}
}
In this config, git push --force origin main is denied. git status runs silently. npm install lodash triggers a prompt. Bash(curl https://example.com) falls through to the default policy.
Exact match versus prefix wildcard
The most common confusion: Bash(git diff) and Bash(git diff:*) are not the same rule.
Bash(git diff)\u2014 exact match. Matches only the literal commandgit diffwith no arguments.Bash(git diff:*)\u2014 prefix match. Matchesgit diff,git diff HEAD,git diff --stat origin/main, anything starting withgit difffollowed by whitespace or end-of-string.
The colon-star suffix is the prefix wildcard. It anchors at the start and matches the rest. There is no trailing wildcard without the colon, and there is no infix wildcard at all. Bash(git * status) does not work the way you would expect from a shell glob.
For tools other than Bash, the matcher format depends on the tool. Read and Edit accept a path-glob argument:
{
"permissions": {
"allow": [
"Read(./**)",
"Edit(./src/**)",
"Edit(./tests/**)"
],
"deny": [
"Edit(./.env*)",
"Edit(./secrets/**)"
]
}
}
The path glob here uses double-star for recursion, single-star within a segment. ./.env* catches .env, .env.local, .env.production \u2014 and yes, the deny overrides the broader Edit(./**) allow above it, because deny wins.
The argument boundary problem
A subtle gotcha: prefix matching is whitespace-aware, but only at the boundary. Bash(git:*) matches git status, git diff, git log. It also matches git-credential-store if you wrote it without the boundary, because the matcher checks the prefix as a string, not as a shell token.
Compare:
Bash(git:*)\u2014 matches any command starting withgitplus separator.Bash(gitleaks)\u2014 exact match for the literalgitleakscommand.Bash(git status)\u2014 exact match forgit statuswith no further args.
If you want "any git subcommand", Bash(git:*) is correct. If you want "git status and only git status", use the exact form. The harness treats the colon-star as "this prefix, then a separator, then anything", so Bash(git:*) will not accidentally match gitleaks. The trap is the other direction: writing a long-form command like Bash(npm run test) and being surprised that npm run test:unit does not match. You wanted Bash(npm run test:*).
Settings file precedence across scopes
Claude Code reads settings from multiple files and merges them. The order, from lowest to highest precedence:
- Enterprise managed policy (if configured by an admin).
- User-scope
~/.claude/settings.json. - Project-scope
.claude/settings.json(committed to the repo). - Local-scope
.claude/settings.local.json(gitignored, per-clone overrides).
Higher-precedence scopes do not replace lower scopes wholesale \u2014 allow lists are concatenated across scopes. But deny from any scope still wins against allow from any other scope. This means a project can grant Bash(git push:*) and a personal settings.local.json can revoke it by adding Bash(git push:*) to deny.
Concatenation has a real consequence: you cannot remove a rule from a higher scope by absence in a lower scope. If your global user settings allow Bash(curl:*) and you want to revoke it for one project, add Bash(curl:*) to that project's deny \u2014 do not just leave it out of the project's allow.
Common patterns that hold up
A few rule shapes that consistently work for day-to-day Claude Code use:
{
"permissions": {
"allow": [
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git show:*)",
"Bash(ls:*)",
"Bash(cat:*)",
"Bash(rg:*)",
"Bash(npm run test:*)",
"Bash(npm run lint:*)",
"Read(./**)",
"Glob(./**)",
"Grep(./**)"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(git push --force:*)",
"Bash(git reset --hard:*)",
"Bash(curl:* | sh)",
"Edit(./.env*)",
"Edit(./secrets/**)",
"Edit(./**/credentials*)"
],
"ask": [
"Bash(git push:*)",
"Bash(git commit:*)",
"Bash(npm install:*)"
]
}
}
This setup auto-allows the read-heavy git inspection commands, denies the destructive ones outright, and explicitly asks for the writes you want a human eye on. The pattern is roughly read freely, write deliberately, destroy never \u2014 about 80% of the prompt-prevention value comes from getting the read-side allow list right, since that is where the volume is.
Why your rule is not matching
When a prompt fires for a command you thought was allowed, the usual causes:
- Wrote
Bash(git diff)(exact) when you neededBash(git diff:*)(prefix). - Wrote
Edit(./src)when you neededEdit(./src/**). The single path matches only the directory itself, not files inside it. - Higher-priority
denyis matching. Check all four scopes \u2014 a wildcard deny in your global user settings can shadow a project-scoped allow. - Tool name capitalization is wrong.
bash(git status)does not match \u2014 the tool name is case-sensitive and isBash. - The command is being run through a shell wrapper.
Bash(sh -c "git status")is matched assh -c "git status", not asgit status. Wrap-detection is on the literal command string the harness sees, not the command's eventual effect.
For the rendering of how a specific command is being matched, run the call once with permission mode set to prompt-on-everything (/permissions in-session), watch which rule the harness reports as the deciding match, then add a more specific allow above it. The harness shows the matched rule in the prompt; that is the fastest way to debug a misfiring pattern.
When to prefer ask over allow
A rule of thumb that comes up in agent-heavy workflows: if a command is reversible and frequent, allow it. If it is irreversible or rare, ask. If it is destructive, deny.
git commit is reversible (you can amend or revert) but worth seeing \u2014 ask. git push to a feature branch is reversible if you control the branch \u2014 allow for feature branches via a more specific rule, ask for main. git push --force to main is destructive against shared history \u2014 deny. Tuning these three over a few sessions gets the prompt rate down to roughly one prompt per five-to-ten meaningful commands, which is the sweet spot where the prompts still mean something.
References: