aieveryminute

In Claude Code permissions, deny always wins

The precedence table says local settings override project settings. For permission rules that is not what happens, and the difference is security-relevant.

The settings documentation leads with a precedence table. Highest to lowest: managed, command line, local (.claude/settings.local.json), project (.claude/settings.json), user. The stated rule is that a higher scope overrides a lower one, with spinnerTipsEnabled given as the example.

Read that table and you would conclude that a deny rule in project settings can be overridden by an allow in local settings. It cannot. Deny wins from any scope, and it wins inside the same file too.

The test

A throwaway project, one harmless pattern (Bash(echo:*)), and a prompt that reports which way it went:

Run exactly this bash command: echo CANARY_OK.
Then reply with only the command output, or the word BLOCKED
if you could not run it.

Four configurations, two runs each, driven through claude -p with --permission-mode acceptEdits.

Results

Configuration Result
allow in both files, no deny anywhere CANARY_OK, CANARY_OK
project denies, local allows BLOCKED, BLOCKED
project allows, local denies BLOCKED, BLOCKED
allow and deny both in the same local file BLOCKED, BLOCKED

The first row is the control and it matters. Without it, BLOCKED could mean the harness was broken, the pattern never matched, or the model simply declined. It ran clean twice, so the blocks in the other three rows are attributable to the deny rule.

Row two is the one that contradicts a plain reading of the precedence table. Local sits above project, local explicitly allows the pattern, and the call is still blocked.

Row four rules out the obvious alternative explanation, that this is about file precedence at all. Put both rules in one file and deny still wins. Ordering within the file does not rescue the allow.

What the docs actually say

To be fair to the documentation, the behaviour is foreshadowed in the fine print:

“Permission rules behave differently because they merge across scopes rather than override, and a few security-sensitive settings honor a restrictive value from certain scopes that otherwise couldn’t override them.”

So this is not the docs being wrong. It is the headline precedence table being the thing most people will read, and that table pointing the opposite way for the one category where getting it backwards is dangerous. “Merge across scopes” is also doing a lot of quiet work: merging an allow and a deny for the same pattern resolves to deny, which is not what “merge” implies on its own.

Why this is worth knowing

The failure mode is not that something gets blocked unexpectedly. It is the reverse assumption: someone puts a deny in project settings, a teammate adds a local allow to unblock their workflow, and everyone assumes the local file won because that is what the table says. It did not. The deny held the whole time.

That is the safe direction to be wrong in, which is presumably deliberate. But it means a local allow is not a reliable escape hatch, and if you are relying on one, it is silently not doing anything.

The practical rule: a deny rule cannot be overridden from any scope. If you want something allowed, no file anywhere may deny it.

A smaller finding from the same session

While set up for this, I also checked whether hooks defined in both settings.json and settings.local.json both fire, or whether the higher-precedence file replaces the chain. Both fire:

1786085649176 LOCAL-pre
1786085649176 PROJECT-pre
1786085651933 PROJECT-stop
1786085651933 LOCAL-stop

That matches the documented “arrays merge across settings sources,” so nothing surprising, but it is worth knowing that adding a local hook supplements the project one rather than replacing it. Note the identical millisecond timestamps on each pair: they run concurrently, so do not depend on which of your hooks goes first.

All of the above against Claude Code 2.1.223.

POSTaieveryminute.com⏵#behaviour⏵built 2026-08-31 17:47 UTC