aieveryminute

Three ways to write a dead deny rule, one of them silent

Claude Code warns you about two of the three common ways to write a permission rule that never matches. The third gives you nothing at all.

A deny rule that never matches is a security control that is not running. The interesting question is not whether you can write one, it is whether you find out.

I tested five rule forms against the same command, two runs each, with a no-deny negative control. Claude Code catches two of the three failure modes with clear startup warnings. The third produces nothing.

The results

Every row ran echo CANARY_OK and reported either the output or BLOCKED. All results reproduced across two runs.

Deny rule Blocked? Warned?
Bash(echo:*) yes n/a, rule works
Bash(echo:* CANARY_OK) no no
Bash(command:echo *) no yes
Bahs(echo:*) no yes
BashTool(echo:*) no yes
no deny rule at all (control) no n/a

The control matters. Without it, CANARY_OK in rows two through five could just mean the harness never applied any rules.

The two that warn

Misspell the tool name and you get told immediately:

Permission deny rule "Bahs(echo:*)" matches no known tool — check for typos.

Reach for the raw-string matcher and you also get told, with the fix included in the message:

Permission deny rule "Bash(command:echo *)" targets command as a raw string
and will not match — use Bash(…) for Bash's own matcher.

That second one is a deliberate refusal rather than an oversight. The documentation explains that a rule like Bash(command:rm *) would be bypassable by a compound command, so Claude Code ignores it on purpose. Ignoring it loudly is the right call, and the warning names the correct alternative.

Both messages are genuinely good. They identify the rule, say what is wrong, and in one case tell you what to write instead.

The one that does not

{ "permissions": { "deny": ["Bash(echo:* CANARY_OK)"] } }

The command ran. No warning, on stderr or anywhere else.

The behaviour is documented. The :* form is only recognised at the end of a pattern, and in a pattern like Bash(git:* push) the colon is treated as a literal character. So the rule above is looking for a command literally containing echo:* and never matches anything.

What the documentation does not say is that this is the one case you get no diagnostic for. The rule is syntactically valid, names a real tool, and sits in your settings file looking exactly like a working rule.

Why this specific mistake is easy to make

The :* suffix and the space form are interchangeable at the end of a pattern. Bash(ls:*) and Bash(ls *) match identically. Having learned that, writing Bash(git:* push) when you mean Bash(git * push) is a very small slip, and the two forms look equally plausible on the page.

There is also a nudge toward the colon form from the tool itself: the permission dialog writes the space-separated form when you pick “Yes, don’t ask again”, but plenty of hand-written config in the wild uses :*, so both are familiar.

What to do about it

Grep your settings files for a colon that is not immediately followed by the closing paren:

grep -nE '"[A-Za-z]+\([^)]*:\*[^)]' .claude/settings*.json ~/.claude/settings.json

Anything that turns up is a rule matching a literal colon, which is almost certainly not what you meant.

More usefully, do not trust a deny rule you have not watched fail. Run the command you think is blocked and confirm you get refused. Two of the three failure modes here announce themselves, but a silent one is exactly the kind you only discover when the rule was supposed to matter.

And note the direction of the risk. A dead deny rule does not throw an error or break your workflow. It quietly permits. You will not notice from the outside.

All measurements against Claude Code 2.1.223.

POSTaieveryminute.com#behaviourbuilt 2026-08-31 17:47 UTC