Quartet / Quintet

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 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

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