[BUG] Permission model: let a specific allow override a broad deny (specificity-aware precedence)

Status Open
Maintainer reply None cached
Activity 3 comments · opened Jul 21, 2026

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 allowevery 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 allow punches through a broad deny
  • a narrow deny punches through a broad allow
  • 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.

View original on GitHub ↗

3 Comments

saleem-mirza · 1 month ago

Suggested labels for triage: bug and enhancement.

S-Luiten · 17 days ago

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:

"permissions": {
	"allow": [
		"Bash(git branch:*)",
		"Bash(git diff:*)",
		"Bash(git grep:*)",
		"Bash(git log:*)",
		"Bash(git ls-tree:*)",
		"Bash(git merge-base:*)",
		"Bash(git show:*)",
		"Bash(git stash list:*)",
		"Bash(git status:*)"
	],
	"ask": [
		"Bash(git:*)"
	]
},

The above config asks permission for every git command, instead of auto-allowing the more specific read-only git command patterns.

saleem-mirza · 17 days ago

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 allow entries, same ask: ["Bash(git:*)"], no other rules:

git status --short                 allow   specific allow beats the blanket ask
git log --oneline -5               allow
git stash list                     allow
git commit -m x                    ask     unlisted subcommand falls to Bash(git:*)
git push --force                   ask
git stash pop                      ask     `git stash list` does not widen to all of `git stash`
git                                ask     bare git is not an allowed subcommand
git  status                        allow   extra whitespace, same subcommand
/usr/bin/git status                allow   absolute path, same command
git -C /tmp status                 allow   global option before the subcommand
git status && git push             ask     the asking segment sets the whole line
git log --oneline | head -5        deny    `head` matches no rule, no default mode set

Two details behind those results:

  1. Precedence is per matched rule, by literal-character count in the specifier. Bash(git status:*) scores above Bash(git:*), so it wins regardless of which list each sits in. A deny still wins over both.
  2. A compound command is decided per statement and takes the strictest result, so git status && git push asks rather than being allowed on its first segment.

The loader also warns at load time, once per narrow allow that outranks the prompt:

warning: allow rule `Bash(git status:*)` is a subset of ask rule `Bash(git:*)` and
outranks it on specificity, so matching calls are allowed without the prompt.
Confirm the prompt was not meant to cover them.

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 permissions shape, without changing how the lists are written.

I wrote up the full precedence model, including why a broad deny does not always win, here: https://blogs.zethian.com/when-deny-doesnt-win.html