[BUG] Permission model: let a specific allow override a broad deny (specificity-aware precedence)
Problem
Claude Code's permission engine resolves rule conflicts with a fixed precedence: a deny always beats an allow, no matter how broad or narrow each rule is. This makes the common least-privilege pattern — block a whole tool, carve out a narrow safe exception — impossible to express.
Reproduction
settings.json:
{
"permissions": {
"deny": ["Bash(aws:*)"],
"allow": ["Bash(aws * describe-*)"]
}
}
Expected: read-only calls like aws ec2 describe-instances are allowed; everything else under aws (e.g. aws ec2 terminate-instances) is denied.
Actual: the broad deny swallows the specific allow — every aws call is denied, including the whitelisted read-only ones.
Motivating use case: read-only cloud access for an infra-debugging agent
I run Claude Code to help triage production incidents. I want the agent to freely inspect AWS and Kubernetes state, but never mutate infrastructure — no terminating instances, no deleting pods, no editing security groups.
The natural way to express this is "deny the tool broadly, allow the safe read-only subset":
{
"permissions": {
"deny": [
"Bash(aws:*)",
"Bash(kubectl:*)"
],
"allow": [
"Bash(aws * describe-*)",
"Bash(aws * list-*)",
"Bash(aws * get-*)",
"Bash(kubectl get:*)",
"Bash(kubectl describe:*)",
"Bash(kubectl logs:*)"
]
}
}
What I want:
| Command | Desired | Native result |
|---|---|---|
| aws ec2 describe-instances | ✅ allow | ❌ deny |
| aws logs get-log-events … | ✅ allow | ❌ deny |
| kubectl get pods | ✅ allow | ❌ deny |
| kubectl logs my-pod | ✅ allow | ❌ deny |
| aws ec2 terminate-instances … | ❌ deny | ❌ deny |
| kubectl delete pod my-pod | ❌ deny | ❌ deny |
Because deny always wins over allow today, the broad Bash(aws:*) deny swallows every read-only allow — so the agent is either fully blocked (useless for triage) or I have to drop the broad deny and enumerate every dangerous mutating verb by hand (fragile, and fails open on any verb I forget).
More use cases
Restrict web access to trusted domains
Block the web tools broadly, allow only vetted internal/docs domains:
{
"permissions": {
"deny": ["WebFetch", "WebSearch"],
"allow": [
"WebFetch(domain:docs.internal.company.com)",
"WebFetch(domain:github.com)"
]
}
}
The broad deny on WebFetch swallows the domain-scoped allows today, so you can't express "no web access except this short allow-list" — exactly the prompt-injection guardrail teams want.
Protect secrets while allowing general file reads
{
"permissions": {
"deny": [
"Read(/**/.env*)",
"Read(/**/.ssh/**)",
"Read(/**/*.pem)"
],
"allow": ["Read(/**)"]
}
}
The narrow secret-file denies must win over the broad Read(/**) allow — which specificity-aware precedence guarantees, but the native "broad allow, narrow deny" combination can't be expressed as a clean least-privilege policy.
Request
Support specificity-aware precedence, where the most specific matching rule wins in either direction:
- a narrow
allowpunches through a broaddeny - a narrow
denypunches through a broadallow - on equal specificity, fall back to most-restrictive-tier-wins (
deny > ask > allow)
This is strictly more expressive than the current model and enables real allow-list-with-exceptions policies. Making it opt-in (e.g. a permissions.precedence: "specificity" setting) would preserve backward compatibility.
Workaround
I built a PreToolUse hook, permcheck, that implements exactly this (most-specific-rule-wins, fail-closed). It works, but this belongs in the native model — which is the actual security boundary — rather than in a hook layered on top.
3 Comments
Suggested labels for triage:
bugandenhancement.I have a similar issue with allow and ask permission modes. Would be nice if this can be fixed, since auto mode's config documentation suggests configuring "permissions.ask" for anything that you don't want auto-approved by auto mode. Example config:
The above config asks permission for every git command, instead of auto-allowing the more specific read-only git command patterns.
I ran this exact config through a PreToolUse hook I wrote that resolves a matched rule by specificity, and it gives the behavior you describe wanting.
Same nine
allowentries, sameask: ["Bash(git:*)"], no other rules:Two details behind those results:
Bash(git status:*)scores aboveBash(git:*), so it wins regardless of which list each sits in. Adenystill wins over both.git status && git pushasks rather than being allowed on its first segment.The loader also warns at load time, once per narrow allow that outranks the prompt:
That warning is the point of contention stated out loud. For your config it confirms the intended carve-out; for someone who meant the prompt to cover everything, it flags the hole before a call is made.
So the ordering you want is implementable against the same
permissionsshape, without changing how the lists are written.I wrote up the full precedence model, including why a broad
denydoes not always win, here: https://blogs.zethian.com/when-deny-doesnt-win.html