2026-09-10 · security · devops · ai · claudecode
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/**)"] } }
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 subcommand — git 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
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
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
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
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
The workflow itself is available
Quartet, the four-persona version, is published free under MIT. Quintet adds a UI Designer persona, review criteria, a per-Issue parallel execution script, and a 11-chapter guide.
See the free version Product page