DEV Community

Cover image for The Deny Rule I Wrote Was Never Consulted
quintetkit
quintetkit

Posted on

The Deny Rule I Wrote Was Never Consulted

I had a rule meant to keep an agent inside one directory:

{ "permissions": { "deny": ["Write(src/generated/**)"] } }
Enter fullscreen mode Exit fullscreen mode

It is valid. It loads. It is never consulted.

File path rules apply to Read(...) and Edit(...). Written for Write,
NotebookEdit, Glob, or the legacy MultiEdit, the rule is accepted and then
never looked at. There is a startup warning; I had not been reading startup
output, because nothing had ever been wrong in it before.

That was the cheap one. Here is the expensive one.

Bash(git * main) allows every git subcommand

A * in a Bash rule stands in for whatever text is in its place. So the
earlier it sits, the wider the rule is.

You write What it actually allows
Bash(git log *) commands starting git log
Bash(git * main) every git subcommandgit push origin main included
Bash(* --version) every program on the machine

Bash(git * main) reads like "one git command, on main". It permits
git push origin main. It also permits git -c core.pager=… diff main, and
-c makes git run a program you name.

I wrote Bash(git * main) thinking I had allowed a diff.

There is a startup warning for an allow rule with a wildcard before the rest of
the command. Same as above: the rule still applies while the warning scrolls by.

Whitespace decides whether lsof is allowed

Bash(ls *)   matches  ls -la,  ls        does not match  lsof
Bash(ls*)    matches  ls -la,  lsof
Enter fullscreen mode Exit fullscreen mode

The space before a trailing * is part of the rule. And Bash(ls *)
matches bare ls only because that trailing * is the rule's only wildcard —
Bash(* --help *) matches npm --help x but not npm --help.

Three more that load and do nothing

An unanchored allow glob is skipped. "*", "B*", "mcp__*" in allow
auto-approve nothing. Allow rules accept a glob only after a literal
mcp__<server>__ prefix, because the rule has to name a server you configured.

A parameter rule on a tool's own content field is ignored.
Bash(command:rm *) is bypassable with a compound command, so it does not
apply. The fields you cannot match this way:

Bash, PowerShell  → command        Grep, Glob   → path
Read, Edit, Write → file_path      WebFetch     → url
NotebookEdit      → notebook_path
Enter fullscreen mode Exit fullscreen mode

Write Bash(rm *), Read(./.env), WebFetch(domain:host) instead.

An mcp__ rule with parentheses is skipped at load. mcp__memory(read)
never applies. It is listed in the invalid-settings dialog and in
claude doctor — neither of which you see in CI.

And one that is only a syntax trap: Bash(ls:*) is the same as Bash(ls *),
but only as a suffix. In Bash(git:* push) the colon is a literal
character and the rule matches nothing at all.

Why this bites harder than it looks

If you are running several agents in parallel, reduced permissions are usually
the safety design. Mine was: the implementing agent may only touch the files
its Issue declared.

I had expressed that as Write(...) path rules. The mechanism I was relying on
was the one that is never consulted.
What was actually holding scope was the
Read/Edit rules, the declared scope in the Issue text, and the wording of
the request — none of which I had been treating as the load-bearing part.

The lesson I took is narrower than "read the docs":

A permission rule that is doing nothing looks exactly like one that is
working.
Both are silent.

Checking a config

npx @quintetkit/ccheck
Enter fullscreen mode Exit fullscreen mode
warn  .claude/settings.json:14
      `Write(src/generated/**)` is accepted but never consulted. File path rules
      apply only to `Read(...)` and `Edit(...)`.
      why: https://code.claude.com/docs/en/permissions
Enter fullscreen mode Exit fullscreen mode

Building that checker, the tests were the interesting part. It runs the exact
rule strings from the documentation
— six that must produce a finding, and
eleven that must not, including Read(./src/**/*.ts) and Bash(ls*).

Eleven-to-six, deliberately. Permission rules have many valid shapes, so calling
a correct one broken is the likely failure, and one false finding is enough for
the whole output to stop being read.

The five forms, with what each does instead of what it looks like:

https://quintetkit.github.io/en/reference/claude-code-permission-rules.html


I publish the configuration for splitting Claude Code into separate personas —
Architect, Coder, Reviewer, Conflict Resolver — under MIT. Copy it, run
./setup.sh, and it works. It does not depend on your tech stack.

https://github.com/quintetkit/quartet

I built one real tool using nothing but this workflow. Every Issue, PR, review
and merge is still there. The parts that went wrong were not deleted.

https://github.com/quintetkit/mdlinkcheck

The version that adds a UI Designer persona, review criteria, a per-Issue
parallel execution script and a 11-chapter guide is on the
product page.

The full kit — five personas, the scripts and the complete guide — is available here.

https://quintetkit.gumroad.com/l/quintet

Top comments (2)

Collapse
 
hamid_ahmadian_3570449f72 profile image
Hamid Ahmadian

The "silent because it's structurally impossible, not because it's misconfigured" distinction is the part I'd underline even more. A Write(...) path deny doesn't fail loudly-adjacent like a typo would -- it loads clean, shows up as valid in claude doctor, and produces zero signal that it isn't doing what its name implies. That's a worse failure mode than a broken rule, because a broken rule at least looks suspicious enough to double-check.

We hit an adjacent version of this running unattended nightly agents: some of what we wanted to restrict was a file mutation happening as a side effect inside a shell pipeline rather than through a direct Write tool call, so it can't be expressed as a permission rule at all -- not misconfigured, just outside the tool-call surface the rules match against. Once we internalized that permission rules only ever see the tool boundary, we moved anything path-scoped that actually mattered into a PreToolUse hook that greps the raw command/path fields directly, and left the declarative rules for what they're actually built for (MCP tool names, WebFetch domains). Your ccheck idea of testing against the documented rule shapes is the right instinct -- a check that flags any Write/Edit-path deny sitting next to a Bash allow covering the same directory would catch exactly the false-confidence combination that bit you here.

Collapse
 
raknaos profile image
Baptiste Le Bouquin

The Bash(git * main) example is the one that should be on a billboard. I run a small fleet of coding agents on a VPS and audit their permission files monthly — the early-* widening pattern shows up in almost every config a human wrote by hand, because the intuition is "restrict to git, restrict to main" and the glob reads the other way.

What I added after reading this class of bug: a startup check that greps the permission file for any rule with a * that is not the last token before the closing paren, and refuses to boot the agent until it's rewritten. Ugly, but a deny rule that silently does nothing is worse than no rule at all.