A permission rule answers one question: may this string run? The question you actually have is a different one: is this work permitted? Every allowlist is a lossy translation between the two, and the loss is where the accidental grants live. Wrap the work in named tasks and the two questions become one question, because a task is a unit of intent somebody decided on. The list stops approximating what you meant and starts enumerating it.
make target is the unit of intent, so pointing the list at targets makes the mechanism and the model the same thingmake is the only way in, and the allowlist names makemake = your task runner, just or npm run if you prefer · docker compose = your isolationmake targets. Its allowlist is quoted verbatim, and auditedThree sections before any of my opinions: what goes wrong at each end of the dial, what a permission rule actually is, and the six places the syntax does something other than what the person writing it expected.
Both ends of the dial fail, and they fail asymmetrically. A loose list fails once, loudly, on the day an ambiguous prompt meets a general tool. A tight list fails quietly and continuously, by generating so many prompts that somebody turns the whole thing off. The second failure is the common one and it does not look like a security event. It looks like productivity.
Harness Engineering tells this the long way in the chapter on bounding tools. A staff
engineer wires up a server exposing a runSql tool that takes a string and runs it.
No allowlist of statement types, no dry run, no confirmation. The task is a schema cleanup, the
planning doc has moved, the agent infers intent from stale schema comments and runs
DROP TABLE on the wrong table. Staging is restored from the previous night's
snapshot and nothing ships that day.
He was not embarrassed about the model. He was embarrassed about the charter. The model did what the tool let it do, and the tool let it drop a table because nobody had told the tool that dropping tables was special. That is the loose end of the dial, and it is the one everybody writes about.
Picture the strict charter instead. One broad rule, and a prompt on every tool call. The agent works and every fifteen seconds a dialog interrupts. Over an afternoon the engineer spends as much time answering prompts as the agent spends writing code, and the team blames the agent. The agent did what the charter asked. The charter asked too often.
Then somebody finds the bypass mode, and the strictness converts to zero enforcement in one keystroke. This is the part worth saying plainly: a list too strict to work inside does not produce caution. It produces a session with no checks at all. The failure mode of over-restriction is under-restriction, arriving later and unlogged.
The bypass posture belongs in a disposable sandbox where the engineer is training trust with a new agent and intends to throw the work away. In a production-adjacent repository, with secrets, deploy access and migration scripts, it is the charter saying I have given up on this discipline. If you cannot get through an afternoon without it, the list is the problem and section 4 is the repair.
Modern harnesses ship more than one dial position, and knowing the ends is not enough. These are Claude Code's, and the shape generalizes: one posture per level of human attention.
| Mode | What it does | The human is | Right when |
|---|---|---|---|
| default · manual | prompts on first use of each tool | watching every step | a new repository, a new agent, or work you do not trust yet |
| plan | reads and runs read-only commands, edits nothing | waiting for a proposal | you want the approach before anything moves |
| acceptEdits | auto-accepts edits and common filesystem commands in the working directories | watching the room | the loop is edit-heavy and the diff is your review surface |
| auto | auto-approves with background safety checks against your request | reading outcomes, not calls | the work is routine and the floor underneath is real |
| dontAsk | auto-denies anything not pre-approved. No prompts at all | absent, on purpose | unattended runs. This is the posture the whole page is built for |
| bypassPermissions | skips prompts | gone, and so are the checks | a throwaway container. Lock it off everywhere else |
Read the dontAsk row twice, because it is the one that changes how you write a
list. A posture that denies anything unlisted and never prompts is only usable if the list is
complete. Completeness is impossible while the project can be run eleven ways, and
straightforward when it can be run one way. That is the whole argument of Part II, arrived at
from the strict end rather than the loose one.
Stop asking how permissive should this list be. Ask what work do I mean to permit. The first question has no stable answer and gets renegotiated every week. The second has a finite answer that a person can write down, and once it is written down the list becomes a transcription of it rather than a negotiation.
A permission rule is Tool or Tool(specifier). There are three
lists, they are evaluated in a fixed order, and the first match decides. Specificity does
not enter into it. Almost every confused permission setup I have read is a person expecting
the narrow rule to win.
| List | Effect | The consequence people miss |
|---|---|---|
| deny | refuses the call | a broad deny cannot hold an exception. Bash(aws *) in deny blocks Bash(aws s3 ls) in allow |
| ask | prompts every time | it beats a narrower allow. A matching ask prompts even when an exact allow also matches |
| allow | runs without a prompt | it is a capability grant, so in project settings it waits for the workspace-trust dialog. Deny and ask do not |
The same order runs across settings scopes. A deny anywhere beats an allow anywhere: user settings, project settings, local settings, command-line flags. Managed settings sit above all of it and cannot be overridden even from the command line. So the deny list is the one place a platform team can write something a project cannot argue with, and the allow list is the place a project describes its own work.
A deny rule that names a bare tool removes that tool from the model's context. It does not exist; nothing is refused, because nothing is attempted. A deny rule with a specifier leaves the tool in place and refuses the matching calls when the agent tries them.
| Rule | What happens | Use it when |
|---|---|---|
deny Bash | the tool is gone from context. The agent cannot want it | the capability has no place in this repository at all |
deny Bash(php *) | the tool stays. The call is refused and the refusal comes back as the result | the command is wrong here and there is a right one to learn instead |
The second row is the one that teaches. A refusal returned as the result of the call is
readable: the agent sees that php artisan test was denied, reads the rule that says
what to run instead, and runs that. A capability quietly absent from context produces no
learning and no confusion either. Both are fine. Confusing them produces a charter that
explains a rule the agent has no way to encounter.
Permission rules are enforced by the harness, not by the model. CLAUDE.md shapes
what the agent tries; the rules decide what runs. A bound that lives only in
prose is a suggestion, and the model is free to misread it, be talked out of it, or forget it
thirty tool calls deep. That is why the charter states the intent and the config does the
refusing, and why Five Guardrails pairs them in every example.
| Surface | Form | Note |
|---|---|---|
| shell | Bash(make test) · Bash(make *) | matches the whole command text. * stands in for any text |
| files, read | Read(./.env) · Read(~/.ssh/**) | gitignore syntax. A read deny also blocks edits and writes to the path |
| files, write | Edit(/src/**) | use Edit for every writing tool. A Write(...) path rule is accepted and never consulted |
| network | WebFetch(domain:example.com) | hostname match. *.example.com covers subdomains, not the apex |
| servers | mcp__github · mcp__github__get_* | an allow glob must sit after a literal server prefix |
| sub-agents | Agent(Explore) | deny to switch one off. See grants, in the ladder |
| parameters | Agent(model:opus) · Bash(run_in_background:true) | deny and ask only, one parameter per rule, and never the tool's main content field |
The last row has a reason worth carrying into section 2. You
cannot write Bash(command:rm *), and the harness ignores it with a warning, because
a rule that matched only the command parameter would be defeated by a compound
command. The surface that decides has to be the surface the shell will actually execute.
The rule language is small and it is not intuitive. Wildcard position changes the blast radius. A missing space changes which programs match. Some wrappers are stripped and some run arbitrary commands. Every one of these has cost somebody a wider grant than they thought they were writing, including me.
Everything before the first * matches as written, so the words in front of the
star are the entire constraint. Put the star after the subcommand, because the subcommand is the
word that decides what the program does.
| You write | It matches | It does not match |
|---|---|---|
Bash(npm run build) | npm run build, exactly | npm run build --watch |
Bash(npm run *) | npm run build, npm run test --watch, and bare npm run | npm install |
Bash(git * main) | git merge main, git push origin main, and git -c core.fsmonitor=<script> diff main | git log |
Bash(ls*) | ls -la and lsof | the space is part of the rule, and it is missing |
Bash(ls *) | ls -la, ls | lsof |
Row three is not a curiosity. git -c makes git run a program you name, so any rule
whose star sits before the git subcommand is a general execution grant with a git-shaped label
on it. The harness warns at startup about an allow rule with a star before the subcommand. Read
the warning.
The :* suffix is the same thing as a trailing space-star, and only at the end.
Bash(ls:*) and Bash(ls *) are one rule written two ways. In the middle
of a pattern the colon is a literal character, so Bash(git:* push) matches nothing
you intended.
This one is good news, and it is worth knowing so you stop writing defensive rules against it.
The harness understands shell separators. A rule must match each subcommand independently, so an
allow of Bash(safe-cmd *) does not authorize safe-cmd && other-cmd.
Deny and ask rules go the other way: they fire if any subcommand matches, including one
nested in a subshell, a command substitution, or a loop body.
&&, ||, ;, |, |&, & and newlines.
echo "$(git clean -f)" trips a Bash(git clean *) rule.
npm test && is not split at all, and allow rules do not approve it. It prompts.
Before matching, a fixed set of wrappers comes off, so Bash(npm test *) also covers
timeout 30 npm test. The stripped set is timeout, time,
nice, nohup, stdbuf, the builtins command and
builtin, zsh's noglob, a leading assignment of certain known-safe
environment variables, and bare xargs.
The list is built in and it is not configurable, which means the runners are not on it. This is
the single most important sentence on this page for anybody about to write
Bash(docker compose exec *):
docker exec, npx, devbox run, mise exec and
direnv exec execute whatever follows them, and they are not stripped. So
Bash(devbox run *) matches devbox run rm -rf ., and
Bash(docker compose exec *) matches docker compose exec web sh -c
'anything at all'. To approve work inside a runner, name the runner and the
inner command: Bash(devbox run npm test), one rule per inner command. The charter
in section 3 gets this wrong, in the line directly above its
own deny list, and it is mine.
Two smaller relatives. Exec wrappers such as watch, setsid,
ionice and flock cannot be prefix-approved at all, so in manual mode
they always prompt. And find with -exec or -delete is not
covered by Bash(find *); those forms need an exact-match rule for the whole command
string.
A rule like Bash(curl http://github.com/ *) looks like a domain restriction and is
not one. Options before the URL miss it. A different protocol misses it. A redirect through a
shortener defeats it. URL=http://github.com && curl $URL defeats it. Two
spaces instead of one defeats it.
The working shape is to deny the network tools in the shell, allow the fetch tool with an explicit domain list, and put anything more specific in a hook. The general rule underneath: constrain the program, not its arguments. Argument shapes are an unbounded space and a permission pattern is a finite guess at it.
Which is the moment to say what all six of these traps have in common, because it is the
argument of the rest of the page. Every one of them is the gap between what the rule
matches and what the author meant. Nobody writing Bash(git *)
meant may execute arbitrary programs; they meant may do the git work this project
involves. The pattern language cannot say the second thing, so it says the first thing and
the difference is a grant nobody reviewed. Part II is about closing that gap by changing what
the list points at.
| Pattern | Anchored at | Example |
|---|---|---|
//path | the filesystem root | Read(//Users/alice/secrets/**) |
~/path | the home directory | Read(~/.ssh/**) |
/path | the settings source, not the root | Edit(/src/**) in project settings means the working directory's src |
path or ./path | the current directory | Read(./.env) |
The third row bites in user settings, where Read(/secrets/**) resolves under
~/.claude/ rather than into any project. If you want a rule in user settings to
apply inside every project, write it absolute or home-relative.
A built-in set of shell commands is treated as read-only and runs without a prompt in every
mode: ls, cat, echo, pwd, head,
tail, grep, find, wc, which,
diff, stat, du, cd, and the read-only forms
of git. Redirection changes that, because the target of a > is
checked against your Edit rules as if the agent had written the file directly.
Which means a large fraction of the entries in a typical allowlist are doing nothing at all. Section 7 counts them in my own repositories.
Why a list grows on its own, and the move that makes it small: one entry point into the project, a container holding the toolchain, and a rule that names the entry point. Five sections, one of them a worked refusal from a real charter.
Below is the allow list from a production charter of mine, verbatim. It is one of the better
charters I have: forty rules, a locked rule that says drive everything through
make, hooks on four events, seventy-eight targets. The rule layer models the
intent correctly. The permission layer does not, and it cannot, because it is written in a
language that has no word for intent.
"allow": [
"Bash(make *)",
"Bash(git *)", "Bash(gh *)", "Bash(jira *)",
"Bash(ls *)", "Bash(wc *)", "Bash(find *)", "Bash(grep *)", "Bash(echo *)",
"Bash(docker compose exec *)",
"Bash(curl *)", "Bash(xargs *)", "Bash(tee *)",
"mcp__puppeteer__puppeteer_navigate",
"mcp__puppeteer__puppeteer_screenshot",
"mcp__puppeteer__puppeteer_fill",
"mcp__puppeteer__puppeteer_click",
"mcp__puppeteer__puppeteer_evaluate",
"mcp__puppeteer__puppeteer_hover",
"mcp__puppeteer__puppeteer_select",
"mcp__…__createJiraIssue", "mcp__…__editJiraIssue", "mcp__…__createIssueLink",
"Skill(update-config)"
],
"deny": [
"Bash(php *)", "Bash(php artisan *)", "Bash(npm *)",
"Bash(npx *)", "Bash(composer *)", "Bash(./vendor/bin/*)"
]
The bottom half of that file is the toolchain deny list from section 6, and it works. The top half is where the intent gap lives. Read the next table one row at a time: the middle column is what somebody was trying to say, and the right column is what the rule says instead.
| Entry | The intent behind it | What it permits |
|---|---|---|
Bash(make *) |
run this project's tasks | the seventy-eight named targets. An enumerated set, in a reviewed file |
Bash(git *) |
commit, branch, read history | any program on the machine. git -c core.fsmonitor=<script> executes what you name |
Bash(gh *) |
open and read pull requests | every subcommand, including arbitrary authenticated API calls, secrets, and workflow runs |
Bash(jira *) |
move the ticket I am working on | every mutation the tracker credential can perform, on any project |
Bash(docker compose exec *) |
run one thing inside the container | every program in the container. It reopens all six deny entries below it |
Bash(curl *) |
hit the local API while developing | arbitrary egress, unboundable by pattern. Section 2.4 |
Bash(xargs *) |
feed a file list into a command | xargs sh -c, which is arbitrary execution, and with flags it is not stripped |
Bash(tee *) |
capture output while still seeing it | a write to any path, through a program the file rules do not recognize as a file command |
ls wc find grep echo |
let the agent look around without prompting | nothing. All five are in the read-only set already. Section 2.6 |
Skill(update-config) |
let it fix a hook when I ask it to | edits to the settings file that contains this list. The grant can rewrite the grant |
| the ten MCP tools | drive a browser; file and link tickets | exactly those ten operations. One entry, one intent, ten times over |
Look at the first and last rows before the eight in the middle, because they are the answer and
they are already in the file. The MCP entries name one operation each: navigate, screenshot,
fill, click. Nobody wrote mcp__puppeteer__*, because the tools have names and
naming them was easy. And Bash(make *) is one line standing in for seventy-eight
named tasks, which is the same trick performed on the shell.
Both are cases where the thing being permitted had a name. That is the whole difference. Where the unit of permission has a name, the list enumerates intents and reads correctly. Where it does not, the author reaches for the program name and a wildcard, and grants the program's entire surface because that is the only thing the language lets them say.
| Kind | In this file | Symptom of |
|---|---|---|
| the program grant | git *, gh *, jira * | the intent had no name, so the program's name was used instead |
| the runner | docker compose exec *, xargs * | a general execution grant wearing a specific label. Section 2.3 |
| the reopener | docker compose exec * above a deny list for php and npm | two layers written at different times by the same person, and never read together |
| the dead entry | five read-only commands | a default nobody rechecks. It costs nothing and it hides the real entries |
| the self-grant | Skill(update-config) | the enforcement sitting inside its own blast radius. Section 8 |
That file was not written carelessly. The charter it belongs to has a rule marked
locked that says drive everything through make, a hook on every
write, and a target for every task. The rule models the intent exactly right. The
allowlist still drifted, because a rule speaks about work and an allowlist speaks about
strings, and nothing keeps the two in agreement. The next section is what happens when you
make them the same artifact.
This is the whole move. A make target is a unit of intent: a name a person chose,
for work the project has decided it does, in a file that gets reviewed. Point the permission
list at targets and it stops being an approximation of what you meant. It becomes an
enumeration of it, and the surface area falls out of that rather than being the goal.
A target carries four things a command pattern cannot. A name, chosen by a person, in the vocabulary of the work rather than the vocabulary of the tool. A recipe, which says exactly what the intent expands to. A place in a tracked file, so adding one is a diff and a reviewer. And a help line, so the permitted set is discoverable instead of guessable. That is the smallest artifact I know of that carries we decided the project does this.
So the principle, stated once: permit the allowable set of intents, and nothing else. Every entry in the allow list names one intent, or names a file that enumerates them. An entry that grants a program's whole surface is not a stricter or looser version of that principle. It is a different thing, standing in for an intent that was never written down.
Each row takes one entry from section 3 and names the intent it was standing in for. The right column is what replaces it, and in five of the seven the varying part becomes an argument rather than a wider pattern.
| Was | The intent, named | Becomes |
|---|---|---|
Bash(git *) |
commit the current work; start a branch for a ticket | make commit MSG="…" and make branch NAME=…, or two narrow git entries and no wildcard |
Bash(gh *) |
open a pull request; read the one under review | make pr ARGS=…, make pr-view ARGS=… |
Bash(jira *) |
file, edit and link a ticket | the three tracker tools already in the list, named one at a time |
Bash(docker compose exec *) |
run an artisan command; open a console; get a shell | make artisan ARGS=…, make tinker ARGS=…, make shell |
Bash(curl *) |
call this app's own API while developing | make api ARGS=… against the container, plus WebFetch(domain:…) for the real web |
Bash(xargs *) · Bash(tee *) |
run a check over a file list; keep the output | the target takes the file list (FILES="a.php b.php") and writes its own report |
Skill(update-config) |
fix a hook when I ask | a human edit, enforced from a scope the agent cannot write. Removing the entry is not enough, and section 8.3 is why |
The FILES= row is not hypothetical: that charter already has
make psalm-files FILES="a.php b.php", written because a full analysis run was too
slow, and it is the exact shape the argument needs. The parameterized target was already there.
The allowlist just never caught up with it.
This is the part that makes the strict posture livable, and it is why the answer is not simply
write narrower patterns. Work varies. You want style fixed in one directory today and
another tomorrow; a migration rolled back one step, then three. A pattern-shaped list handles
variation by widening until it covers the family, which is how
Bash(./vendor/bin/*) gets written. A target handles it by taking an argument, and
the permitted set does not move.
make pint-fix
make pint-fix ARGS=app/Services/Billing
make migrate-rollback ARGS=--step=1
make psalm-files FILES="app/Actions/Approve.php app/Http/Api.php"
# Every one of these is Bash(make *). The list is unchanged.
# A pattern-shaped list would have grown a rule for each shape,
# then one wildcard that swallowed all four.
There is a bill for this and section 8 is where it comes due: an
argument interpolated into a shell recipe can carry shell. The charter above interpolates
$(ARGS) unquoted in about a dozen recipes, including make tinker
ARGS=--execute='…', which is arbitrary code execution by documented design. Arguments are
the right mechanism and they are the part of the door that needs a lock.
Bash(make *) is one rule standing in for a large set, which looks like exactly the
move the eight bad rows made. The difference is where the set is written down. A program grant
stands in for a set nobody has ever listed: the union of everything git or
gh can do, discovered at the moment it matters. A door stands in for a set that
exists as text, in the repository, with a name and a recipe per member, in front of a reviewer.
| A pattern per command | One entry, and a target per intent | |
|---|---|---|
| the permitted set is | implied by the patterns, and nobody has listed it | the target list. make help prints it |
| entries to write | one per invocation shape, forever | one: Bash(make *) |
| adding a capability is | a click on a prompt, in an untracked file | a target, in a diff, with a reviewer |
| when the toolchain changes | every entry churns | make test keeps its name; the recipe changes |
| how the agent discovers it | trial, refusal, retry | make help, and one always-on rule pointing at it |
| the residual grant is | whatever nobody noticed in a program's surface | the recipes, plus the flags that redirect make. Section 8 |
make test is a stable name for an unstable thing. Underneath it may be
pest, then phpunit, then two tiers run concurrently by
paratest inside a container. Each of those rewrites a command-shaped allowlist and
none of them touch a target-shaped one. The charter's instruction to the agent (run
make test, run make lint) also stops going stale, which is the
same benefit arriving in the prose layer.
And a target records a decision, where an allowlist entry records a click. A command in an allowlist means a prompt fired and a human said yes once. A target means a person decided this is a thing the project does, wrote it down, and named it. Those are different artifacts with different review paths, and only one of them is legible six months later.
A rule states the intent, the config makes it non-negotiable, and the Makefile
makes compliance the easy path. Five Guardrails works this trio in one
screen as the cheapest guardrail there is; here it is as the shape:
make targets, never the raw tool, and here is what to do when no target fits. This is the part that teaches, and it enforces nothing. In the charter above it is marked locked, with a why field recording the incident that produced it.
Miss the third and you have built the strict charter from
section 0: a wall with no gate, which lasts until somebody finds
the bypass flag. The escape hatch in the rule text matters as much as the wall does, and the real
one says it in two lines: no appropriate target exists, add one to the Makefile and document
it; an existing target is not flexible enough, add ARGS= support rather
than reaching for the raw tool.
test ## both PHP test tiers concurrently — the pre-commit gate
test-parallel ## use-case tier via paratest, no database
test-adapter ## adapter tier against an in-memory database
pint-fix ## fix PHP code style (ARGS= for extra flags)
psalm-files ## analyse a specific file set: FILES="a.php b.php"
migrate-rollback ## roll back the last batch (ARGS=--step=1)
tinker ## a Laravel console (ARGS=--execute='…')
shell ## bash in the app container
# Seventy-eight of these. Two — tinker and shell — are general
# execution, which means they are not intents at all. Section 8.1
# is what to do about that, and it is not "decide carefully".
Two gates, deliberately, because a single line of defense is a single point of failure. The permission rule is a check the harness performs on a string. The container is the operating system refusing. They fail differently, which is the only reason to have both, and together they produce the property the whole stack is for: the wrong command is not merely refused, it could not have worked.
Here is the sentence from the guardrails page that this whole stack exists to produce. A project
runs entirely inside Docker. An agent that types php artisan migrate runs it on the
host, which has no PHP extension set and no database. Then, being helpful, the agent invents a
workaround. The failure is not the wrong command. It is that the wrong command was
available.
Move the toolchain into the container and that availability goes away as a matter of fact rather
than policy. There is no host php to run. The deny rule is then a courtesy that
returns a readable refusal instead of a confusing environment error, and the courtesy is worth
having, but it is no longer the thing holding the line.
flowchart TD
A["the agent proposes a command"] --> R{"permission rules · deny, ask, allow"}
R -->|"not make"| X["refused, with a readable reason"]
R -->|"make target"| M["make · the only door"]
M --> C["docker compose · the container"]
C --> T["the toolchain, which exists only here"]
C --> B["filesystem bound · no host credentials · ephemeral"]
X --> L["the agent reads the rule and runs make instead"]
class A dim
class R warn
class X warn
class L dim
class M pass
class C pass
class T pass
class B block
| Layer | Decides | Cannot | Fails when |
|---|---|---|---|
| permission rules | whether a command string may run at all | see what the command does; a target, a script or an exec runs whatever it likes | the pattern is wider than you read it as. Section 2 |
| make | which named tasks exist | stop make -f, make -C, or a hostile ARGS= | the Makefile is not reviewed, or the escape hatches are open. Section 8 |
| the container | what any of it can touch: files, network, processes | protect what you mounted, or a secret you put inside it | the repository is bind-mounted with a .env in it, which is the normal case |
| the missing credential | what is reachable at all, from anywhere | be undone by any config mistake, which is its whole appeal | somebody puts a production credential in the dev environment |
Read the last row as the strongest control on the page. The cheapest way to bound exposure to production is to have none: no write access, no read access, and no credential on the machine that could become either. Production deploys, production data and production migrations sit behind a human. That is not least privilege applied carefully; it is a line drawn before the privilege question is asked, and it is the one grant I do not make.
Claude Code can enforce filesystem and network bounds at the OS level for Bash commands and
their children, and it merges those bounds with your Read and Edit
deny rules and your WebFetch domain rules. That is a real floor and it is worth
turning on. It is also not your container: it bounds the shell the agent runs, while the
container bounds the environment your project runs in.
Two honest notes about the overlap. Path deny rules cover the built-in file tools and the file commands the harness recognizes in Bash, and they do not cover an arbitrary subprocess that opens files itself, which a test suite does constantly: for that you need OS-level enforcement, container or sandbox. And a container with your repository bind-mounted does not protect a secret that lives in the repository. Both layers leave a gap the other one covers, so run both and know which one you are relying on for what.
make is the interface: it decides what the project can be asked to do. Docker is
the floor: it decides what any of that can reach. The permission list is neither, and once the
first two are in place it becomes what it should have been all along, which is a short, boring
file naming one door and a handful of things that were never going through it.
php artisan test is refused, make test is not, and the agent works it out unaidedOne command, three artifacts, six steps. The interesting part is not the refusal. It is that the refusal is readable, the sanctioned path is discoverable, and the agent recovers inside the same turn without a human being interrupted. A guardrail that costs nobody an interruption is a guardrail that is still there next month.
**Always use `make` targets.** Never run `npm`, `npx`, `php`,
`php artisan`, `composer`, `vite`, or similar tools directly — they
execute on the host, not in the container, and bypass the project's
environment.
- No appropriate target exists → add one to the `Makefile` and
document it in `.claude/features/common-commands.md`.
- An existing target is not flexible enough → add `ARGS=` support
rather than reaching for the raw tool.
- Coreutils (`ls`, `grep`, `tail`, `git`, etc.) are fine to run directly.
"permissions": {
"allow": [
"Bash(make *)",
"Bash(git diff *)", "Bash(git commit *)",
"Bash(gh pr *)"
],
"ask": [
"Bash(git push *)"
],
"deny": [
"Bash(php *)", "Bash(composer *)", "Bash(npm *)",
"Bash(npx *)", "Bash(./vendor/bin/*)",
"Bash(git push --force *)", "Bash(git push -f *)",
"Read(./.env)", "Read(~/.aws/**)", "Read(~/.ssh/**)"
]
}
sequenceDiagram participant A as the agent participant P as the permission gate participant M as make participant D as the container A->>P: php artisan test P-->>A: denied by Bash(php *) Note over A: the refusal is the result of the call, so the agent reads it A->>A: re-reads the always-on rule: use a make target A->>P: make test P-->>M: allowed by Bash(make *) M->>D: docker compose exec app pest D-->>A: 214 passed, 2 failed, with file and line
Somebody will want php artisan tinker next week. You cannot get it by adding an
allow rule, because Bash(php *) in deny is evaluated first and a deny cannot carry
an exception. That constraint is a feature, and it leaves exactly three moves:
| Move | What it costs | What it produces |
|---|---|---|
| add a target | a line in the Makefile and a review | make tinker. The list does not change at all, and the capability is now documented |
| move the family to ask | a prompt every time, for every php call | a human decides per call. Honest, and it does not scale to unattended runs |
| narrow the deny | one deny per dangerous subcommand, forever | the sprawl from section 3, rebuilt on the deny side |
The first move is the one that compounds, and notice what it does to the argument. A request to widen the agent's capability has become a pull request against a file, with a name, a recipe and a reviewer. That is the same shape as every other change to how the project is built, which is the point: permission decisions stop being a private settings file and start being engineering.
The next request will not be make test. It will be the test suite over one
directory, or style fixed in one module, or a rollback of exactly one migration batch. That is
the same intent with a parameter, and section 4.2 is where it
goes: into ARGS= or a named variable, not into a wider entry. The allowlist stays
one line, and section 8 is the bill for interpolating a variable
into a shell recipe.
Five were free all along, seven were standing in for an intent nobody had named, and one could
rewrite the file itself. What remains falls into three groups: the entries a target replaces,
the entries that were never doing anything, and the entries that stay because they were never
going through make in the first place. Only the third group needs judgment.
| Delete | Because | Now |
|---|---|---|
Bash(php *) · Bash(php artisan *) | there is no PHP on the host to run | make test, make migrate, and a deny for the raw tool |
Bash(composer *) · Bash(./vendor/bin/*) | same, and the second one is a directory of arbitrary programs | make install, make lint |
Bash(npm *) · Bash(npx *) · Bash(vite *) | same. npx is also an unstripped runner, so it was an execution grant | make build, make dev |
Bash(bundle *) · Bash(rails *) · Bash(rake *) | the Ruby version of the same list, one framework over | make test, make console |
Bash(python3 *) · Bash(pytest *) | a general interpreter grant, which is every capability at once | a target, or a bundled script the skill runs by name |
Bash(docker compose exec *) | an unstripped runner: exec web sh -c '…' is arbitrary code | targets. Keep Bash(docker compose logs *) and ps if you want them |
These were never doing anything, because the read-only set runs without a prompt in every mode. Five of the twenty-four entries in section 3 are in this category, which is how you know the pattern is not a beginner's mistake but a default nobody rechecks.
"allow": [
"Bash(ls *)", // already free
"Bash(wc *)", // already free
"Bash(find *)", // already free, and -exec was never covered anyway
"Bash(grep *)", // already free
"Bash(echo *)", // already free
"Bash(git *)", // git -c core.fsmonitor=<script> runs anything
"Bash(xargs *)", // xargs sh -c runs anything
"Bash(tee *)", // writes any path, unseen by the file rules
"Bash(curl *)" // unbounded egress
]
Bash(git *) is the one to look at twice, because it is the most reasonable-looking
line in the file. It reads as a convenience and it is a general execution grant, since
git -c runs a program you name. The read-only git forms are already free, so the
honest version of this entry is two rules for the writing subcommands, or a
make commit target, and no wildcard at all.
| Keep | Why it is not a target | Narrow it to |
|---|---|---|
make | it is the door | Bash(make *), with the section 8 denies beside it |
| git, the writing half | the agent's work is commits and branches | Bash(git commit *), Bash(git checkout -b *). Never Bash(git *) |
| the code host CLI | it reaches a system outside the container by design | Bash(gh pr view *), Bash(gh pr create *), by subcommand |
| the tracker CLI | same, and it holds a credential | one entry per subcommand you actually use |
| fetching | the network is not a task runner | WebFetch(domain:…) per host, plus a deny for curl and wget |
| servers | MCP tools are not shell at all | mcp__server__tool, per tool. the ladder, §4 |
Before: twenty-four allow entries. Five were free, seven granted more than they named, one
could rewrite the file itself, and eleven were already right. After: the door, two narrow git
entries, three code-host entries, the ten MCP tools by name, and a deny list that grew,
because it is now where the interesting decisions live. The reviewable surface stopped being a
file nobody read and became a Makefile that somebody does.
One door is not one lock. Three sections on what the collapse leaves open: the holes
make itself brings, what a deny list is and is not for, and the point where a list
cannot express the rule and code has to.
Bash(make *) is a runner grant. It differs from
Bash(docker compose exec *) in one way that matters: what it runs is enumerated in
a reviewed file rather than supplied per call. That difference collapses the moment the file
stops being reviewed, or the flags that redirect make at another file stay open,
or the agent can write the file itself.
| The hole | What it gives back | The rule |
|---|---|---|
make -f /tmp/x.mk |
a makefile the agent wrote, run through the door | deny Bash(make -f *) and Bash(make --file *) |
make -C ../other |
another project's targets, outside this repository's review | deny Bash(make -C *) and Bash(make --directory *) |
make test ARGS='; curl …' |
arbitrary shell, if the recipe interpolates the variable unquoted | quote it in the recipe, validate it, or accept only an enumerated set |
make test PHP=/tmp/evil |
a substituted program name, if a recipe takes its interpreter from a variable | never name a program with an overridable variable, or mark it override |
make shell · make tinker ARGS=--execute='…' |
everything the container can do. A target whose intent is run what I say is not an intent | deny them by name, and enumerate the operations people reached for them with. Below |
Edit(Makefile) |
everything. The agent adds a target, then runs it through the allowed door | deny Edit(/Makefile) and Edit(/.claude/**) — from a scope the agent cannot write. Below |
The last row is the load-bearing one, and it is the same lesson the measured work on rule
placement produced: never let the thing that enforces a rule sit inside the thing it
enforces against. A Makefile the agent can edit is not a permission
boundary. It is a suggestion with extra steps, because the path from I need a capability
to I have it is two tool calls and no human.
"deny": [
"Bash(make -f *)", "Bash(make --file *)",
"Bash(make -C *)", "Bash(make --directory *)",
"Edit(/Makefile)",
"Edit(/.claude/**)"
]
# Changing what the project can do is now a human's edit and a
# reviewer's read. That is the whole security property of the stack.
Two targets in that catalog are general execution: make shell opens a shell in the
container, and make tinker ARGS=--execute='…' evaluates whatever you hand it. Both
are useful. Both are also the exact thing the door was built to prevent, and having them inside
the door is worse than having them outside it, because they are now covered by the one entry
that looks safe.
Say it in the vocabulary of section 4 and the problem is obvious.
The permitted set is supposed to be the allowable set of intents. An intent of run arbitrary
code in the app container is not a member of that set; it is the set. One such target
collapses seventy-eight named intents back down to Bash(*), and the allowlist still
reads like one tidy line.
| Target | What somebody uses it for | What it grants |
|---|---|---|
make shell | poking at a failing test, reading a log, checking a file in the container | every binary in the image, the mounted repository, and whatever the container can reach on the network |
make tinker ARGS=--execute='…' | inspecting a record, clearing a cache, retrying a job | arbitrary code inside the application, with its database credentials and its full object graph |
make artisan ARGS=… | a migration, a queue command, a cache flush | every framework command, including the destructive ones and any package's own |
any target with free-form ARGS= | scoping a check to one path | whatever the flag surface of the underlying tool allows, plus shell if the variable is unquoted |
The repair is the same move applied one level down. The reason those targets exist is that people had specific operations to perform and no named target for them, so they reached for the general one. Name the operations. Then the general targets are not load-bearing and can be refused.
# instead of: make tinker ARGS=--execute='User::where(...)->first()'
user-show: ## one user record as JSON. EMAIL=
@$(EXEC_T) php artisan app:user-show "$(EMAIL)"
queue-retry: ## retry one failed job. ID=
@$(EXEC_T) php artisan queue:retry "$(ID)"
cache-clear: ## clear application cache. No parameters
@$(EXEC_T) php artisan cache:clear
migrate-rollback: ## roll back one batch. STEP= (an integer)
@test "$(STEP)" -gt 0 2>/dev/null || { echo "STEP must be a positive integer"; exit 2; }
@$(EXEC_T) php artisan migrate:rollback --step="$(STEP)"
# instead of: make shell
logs: ## tail container logs. SERVICE= (app|queue|db)
db-console: ## a read-only psql session against the dev database
Three properties changed there and each one matters. The parameter is named for what it
is, so EMAIL= cannot carry a flag and STEP= is checked to be
an integer before anything runs. The variable is quoted, so it cannot carry
shell. And the operation is one operation, so the help line is an honest
description of what the agent is permitted to do rather than a category.
"allow": [ "Bash(make *)" ],
"deny": [
"Bash(make shell*)", // interactive container shell
"Bash(make tinker*)", // arbitrary application code
"Bash(make artisan*)", // the whole framework CLI
"Bash(make -f *)", "Bash(make --file *)",
"Bash(make -C *)", "Bash(make --directory *)"
]
This is the one place on the page where the precedence rule from
section 1 pays for itself. Deny is evaluated before allow and
specificity is not consulted, so a narrow deny reliably carves a hole out of a broad allow. The
general targets stay in the Makefile for the humans who type them in their own
terminal, where no permission rule applies, and they are unavailable to the agent. One artifact,
two audiences, and the difference expressed in the config rather than in a comment.
Note what the deny list looks like now: five or six entries about make itself. That
is the right shape. Once the allow side names one door, the deny side is where the interesting
decisions live, and each one reads as a sentence about intent — the agent does not get an
interactive shell, the agent does not evaluate arbitrary application code.
Bash(docker compose exec *) sits directly above a deny list refusing
php, npm, composer and ./vendor/bin/*, and
reopens every one of them, because that runner is not stripped. About a dozen recipes
interpolate $(ARGS) unquoted, and one of them is make tinker
ARGS=--execute='…', which is arbitrary code execution as the documented usage. And
Skill(update-config) grants edits to the settings file that holds the grant.
Three holes, in the file whose rule layer gets all of this right, and I wrote all three.
Two of the three have ordinary fixes and one does not. The runner entry becomes three targets.
The unquoted variables get quotes, or a validating hook, and the two deliberately-general
targets (tinker, shell) stay in the catalog and out of unattended
profiles. The self-editing grant has no safe narrow form: a capability that can rewrite the
permission file is the permission file. It comes out, and a human edits config.
Skill(update-config) does not fix itI said earlier that the self-editing grant comes out and a human edits config. That is the right instinct and it does not work, and the reason it does not work is worth more than the fix, because the same shape defeats several other repairs on this page.
Edit tool on a path. Deny the skill and the agent edits .claude/settings.json directly, with the tool it already has for editing files in the repository.
Edit(/.claude/**). Now the door is the way in: a new Makefile target whose recipe runs sed or a script over the settings file. Path rules cover the built-in file tools and the file commands the harness recognizes in a shell command. They do not cover a subprocess that opens a file itself.
Edit(/Makefile). Both denies now live in .claude/settings.json, which is the file they protect. The rule guarding the file is inside the file, so any path to writing that file is also a path to deleting the rule.
allowed-tools frontmatter, which pre-approves tools for that skill, and workspace trust never gates it. So .claude/skills/ is a permission surface too, and an agent that can write files under .claude/ can grant itself tools without touching settings.json at all.
That is the circularity, and it is the reason the measured work on rule placement puts it as a law rather than a tip: never let the rung that enforces a rule sit inside the rung it enforces against. A deny rule protecting its own file is an enforcer inside its own blast radius. It stops an incurious agent, which is worth something, and it stops nothing that is actually trying.
Deny rules from any scope beat allow rules from any scope, and managed settings cannot be overridden even from the command line. So move the self-protection up, out of the workspace the agent works in, and back it with the one control that is not a runtime check at all.
| Where | Written as | Survives |
|---|---|---|
| project settings | Edit(/.claude/**) |
nothing. The agent can edit the file that holds it |
| user settings | Edit(//**/.claude/**), Edit(//**/Makefile) |
anything the agent does inside a repository. Note the //: a single-slash /.claude/** in user settings resolves under ~/.claude/ and protects nothing. Section 2.5 |
| managed settings | the same rules, plus disableBypassPermissionsMode |
every other scope, including command-line flags. The correct home in an organization |
| the review path | code owners on .claude/** and the Makefile, and a branch that cannot be pushed to directly |
everything, because it is not a runtime check. A change to the permitted set becomes a human's approval |
The last row is the one that actually closes it, and it closes it for the
Makefile at the same time. Every control in this article can be edited by whoever
can write the repository; the question is only whether writing it requires a person. Runtime
rules make the agent's path expensive. Code owners on those two paths make the agent's path go
through a reviewer. Use both, and stop expecting a config file to defend itself.
settings.local.json is written by the harness itself when a human clicks
do not ask again. That write does not go through the Edit tool, so a
path deny does not block it, and it should not: it is a human decision being recorded. It is
also why section 11 treats that file as a queue rather than
as config. The agent cannot write it. It can, however, benefit from what a tired human clicked
into it, which is a slower version of the same loop.
With those denies in place, Bash(make *) grants exactly the set of targets in a
reviewed file, plus whatever a recipe's own escape hatches allow. That is a real and checkable
bound, and it is weaker than a per-command allowlist would be in theory. In practice it is
stronger, for one reason: it is short enough that somebody reads it. A forty-line allowlist
nobody reviews grants whatever nobody noticed.
So the claim is not that a door is airtight. It is that a door moves the enumeration from a file written by clicking into a file written by deciding, and puts it in front of a reviewer. Then section 5's container catches what the review misses, and section 9 is about what to do with the layer underneath that.
Permission rules are enforced by the harness against a command string it can parse. That is enough to stop a confused agent, which is the failure mode you actually have. It is not enough to stop content that is trying to get out, and a team that believes otherwise has bought one layer and stopped building.
Two facts bound what a rule can do. Path denies cover the built-in file tools and the file commands the harness recognizes in a shell command; they do not cover a subprocess that opens files itself, which is what every test suite and every script does. And the read side of the boundary is not the interesting one anyway: a secret that reaches the model's context can leave it, in a PR description, in a quoted error, in a sub-agent's report.
So the deny list is a short list of things it is worth being categorical about, and the floor under it is somewhere else.
| Entry | Protects against | Real floor |
|---|---|---|
Read(./.env), Read(~/.aws/**), Read(~/.ssh/**) | a curious agent reading a credential into context | the credential not being on the machine, and OS-level path enforcement |
Bash(git push --force *) | a rewrite of shared history during a helpful cleanup | branch protection on the remote, which does not depend on your laptop |
Bash(rm -rf *) | the overnight run that reads a stale TODO literally | a fresh clone per run, and a container that owns nothing you need |
Edit(/Makefile), Edit(/.claude/**) | the agent widening its own grant. Section 8 | review. There is no runtime substitute for a human reading the diff |
Bash(curl *), Bash(wget *) | exfiltration by the most convenient tool, and argument patterns that cannot bound a URL | network isolation in the sandbox or the container, plus a fetch allowlist |
| anything production-shaped | the unbounded, irreversible mistake | no credential, no route, and a human at the last step |
There is one place a deny list does real security work rather than habit work. Untrusted content that reaches the context can ask the agent to run something, and the agent has no reliable way to tell that request from yours. A deny rule refuses it without the agent's cooperation, and deny rules fire on any subcommand, including one hidden in a command substitution or a loop body. So the list is exactly the layer that turns the model was persuaded into the call did not run.
That is the argument Prompt Injection develops properly, and the short version here is that the entries that matter for it are the egress ones. Deny the shell network tools, allowlist fetch by host, and treat everything that comes back through either as data rather than instruction.
Set disableBypassPermissionsMode so the strict posture cannot be discarded by a
keystroke, and keep the protected paths protected: bypass mode skips prompts even for writes
to .git and .claude. Both work from any settings scope, so you can
lock yourself out of your own escape hatch, which is the correct use of the feature. In an
organization they belong in managed settings, where a project cannot override them.
A permission list matches strings. Plenty of real rules are conditional on something a string does not carry: which branch you are on, what the argument resolves to, whether the target is inside the high-risk module, whether a human is watching. Those compile into a hook, which is code the model cannot reach and cannot talk out of it.
A PreToolUse hook runs before the permission prompt for every tool call, and its
output can deny the call, force a prompt, or skip the prompt. The precedence is worth memorizing
because it is not symmetric. A hook that returns allow does not defeat a deny or ask
rule: those are evaluated regardless. A hook that exits with code 2 stops the call before the
rules are consulted, so a block beats an allow rule. Refusal composes; permission does not.
| The rule | Why a pattern cannot say it |
|---|---|
no make deploy from a branch that is not main | the branch is not in the command string |
ARGS= must be a path inside the repository | the pattern cannot resolve a path or reject shell metacharacters |
| edits to the billing module need a human, everything else does not | a path pattern can say it, but not the why, and the refusal should name the reason |
| everything is allowed except these six commands | allow-all plus exceptions is exactly what precedence forbids, so the exceptions go in a hook |
That last row is a legitimate and underused posture: put the whole tool in the allow list and register a hook that rejects the specific commands you care about. It inverts the default, which is usually wrong, and is occasionally exactly right for a sandboxed throwaway environment where you want speed with a handful of absolute stops.
Five Guardrails works the whole ascent, from a rule and a deny entry up to a hook that compiles the same rule into a sensor and returns a refusal the agent can act on. The relevant design decision from it, restated for this page: a refusal should name the rule, the reason, and the sanctioned path. A hook can do that in a sentence. A deny rule can only say no, which is why the always-on prose from section 6 has to carry the rest.
Two sections. How to audit a list you already have and keep it at a reviewable size, and the artifact: a complete pair of files, then the conformance checklist and the rule I fail.
You do not need to design a permission list. You need to audit the one that accumulated. Four passes, in this order, remove most of it, and each pass is mechanical enough to hand to somebody who did not write the file.
ls, cat, grep, find, wc, head, tail, which, diff, stat, du, echo, pwd, cd, and the read-only git forms. They ran without a prompt anyway.
Bash(python3 *) makes every specific python entry dead. Delete the narrow ones, then decide whether you actually meant the broad one. Usually you meant a target.
docker exec, npx, devbox run, mise exec, direnv exec, python3 -c, sh -c, git *, find * with -exec. Each one is an execution grant wearing a specific label. Replace with a target, or name the runner and the inner command.
# In session: every rule, and which settings file it came from.
# This is the resolved view, across user, project and local scope.
/permissions
# On disk: the allow list of every project, longest first. The ones
# at the top of this output are the ones to read this afternoon.
for f in */.claude/settings*.json; do
printf '%3d %s\n' \
"$(python3 -c "import json,sys;print(len(json.load(open('$f')).get('permissions',{}).get('allow',[])))")" \
"$f"
done | sort -rn
| File | What an entry there means | Treat it as |
|---|---|---|
.claude/settings.json | the team decided this is how the project is run | config. Reviewed in a pull request like any other file |
.claude/settings.local.json | somebody clicked do not ask again | a queue of pending decisions, not configuration. Drain it weekly |
~/.claude/settings.json | this is true of me in every project I open | rare. Almost nothing is actually true across all your repositories |
The local file is the one that matters for hygiene, because its whole purpose is to absorb clicks.
Reading it once a week is the cheapest audit in this article: each entry either becomes a target
in the Makefile, gets promoted into the tracked settings file with a reason, or goes
away. An entry that survives three of those reviews without becoming either is telling you the
toolchain has a door you have not built yet.
Two mechanics worth knowing while you move entries around. Allow rules in a project's settings
are capability grants, so they apply only after you accept the workspace-trust dialog for that
folder; deny and ask rules are not gated that way, because they only restrict. And a
settings.local.json that is tracked in git is treated as repository-supplied rather
than yours, and waits for the same trust step. If you check the local file in, you have made it
project config with a misleading name.
The allow list fits on one screen and every entry has a reason a colleague would accept. If it does not fit, the question is not which entries to cut. It is which door you have not built, because a list that long is a description of a project with many entrances.
Copy the pair, delete what your project does not have, and add a target rather than an entry the first time something is refused. The checklist below is checkable by reading two files, which is the property I care about most: an intent catalog, and the entry that points at it.
{
"permissions": {
"defaultMode": "default",
"allow": [
"Bash(make *)",
"Bash(git commit *)", "Bash(git checkout -b *)",
"Bash(gh pr view *)", "Bash(gh pr create *)", "Bash(gh pr diff *)",
"WebFetch(domain:docs.internal.example.com)"
],
"ask": [
"Bash(git push *)"
],
"deny": [
// the toolchain: it lives in the container, not on the host
"Bash(php *)", "Bash(composer *)", "Bash(npm *)", "Bash(npx *)",
"Bash(./vendor/bin/*)", "Bash(docker compose exec *)",
// the door stays a door: no redirecting it at another file
"Bash(make -f *)", "Bash(make --file *)",
"Bash(make -C *)", "Bash(make --directory *)",
// the targets that are not intents: general execution
"Bash(make shell*)", "Bash(make tinker*)", "Bash(make artisan*)",
// the irreversible and the secret
"Bash(git push --force *)", "Bash(git push -f *)",
"Bash(rm -rf *)", "Bash(curl *)", "Bash(wget *)",
"Read(./.env)", "Read(~/.aws/**)", "Read(~/.ssh/**)"
],
"disableBypassPermissionsMode": "disable"
}
}
{
"permissions": {
"deny": [
// note the double slash: a single slash in user settings
// resolves under ~/.claude/ and protects nothing. §2.5
"Edit(//**/.claude/**)",
"Edit(//**/Makefile)"
],
"disableBypassPermissionsMode": "disable"
}
}
# Plus code owners on those two paths, because a runtime rule makes
# the agent's route expensive and review is what makes it human.
Three notes on reading those files. The comments are for you, not for the parser: strip them
before you use them, because the settings format is JSON. The Edit denies sit in the
second file rather than the first, for the reason section 8.3
works through. And the docker compose exec entry in the deny list is not a
contradiction of the Makefile below, which runs that exact command. Permission rules match what the agent asks the shell to run. What a permitted
program then spawns is invisible to them, which is the mechanism the whole door depends on: the
agent cannot type docker compose exec, and make test can.
.PHONY: help test lint lint-fix migrate build dev shell
COMPOSE := docker compose
EXEC := $(COMPOSE) exec -T app
help: ## this list
@grep -E '^[a-z-]+:.*##' $(MAKEFILE_LIST) \
| sed 's/:.*## /\t/' | expand -t22
test: ## the whole suite, in the container
@$(EXEC) ./vendor/bin/pest
lint: ## static analysis and style, check only
@$(EXEC) ./vendor/bin/pint --test
@$(EXEC) ./vendor/bin/phpstan analyse
lint-fix: ## fix style. ARGS=path to scope it
@$(EXEC) ./vendor/bin/pint "$(ARGS)" # quoted, on purpose
migrate: ## migrations, dev database only
@$(EXEC) php artisan migrate
shell: ## interactive shell in the container. A deliberate grant
@$(COMPOSE) exec app bash
Makefile and the whole settings directory — and the rule that says so lives in a scope the agent cannot write, backed by code owners on both paths.§8.3 · §8.4make -f, make -C, and their long forms.§8| Level | Rules | What it gets you |
|---|---|---|
| 1 · one door | 1, 2, 5, 6, 11, 12 | every entry names an intent, and a reviewer can read the whole permitted surface |
| 2 · the door holds | level 1, plus 3, 4, 7, 8, 9, 10 | the catalog and the list cannot drift apart, and the agent can edit neither. Unattended runs stop being reckless |
| 3 · the floor holds | level 2, plus 13, 14, 15 | a list that stays short, egress that is bounded, and no credential that could become an incident |
The charter in section 3 is mine and it is running. It fails rule
1 seven times over, rule 4 three times (make shell, make tinker,
make artisan are all reachable through the door), rule 7 through
Skill(update-config) and a self-protecting deny that would sit in the file it
protects, rule 9 in about a dozen recipes that interpolate $(ARGS) unquoted, rule 10
twice, and rule 13 outright: it allows Bash(curl *). What it gets right is the part that took the longest to learn,
which is the rule layer: a locked always-on rule, a why field recording the incident
that produced it, seventy-eight named targets, and hooks on four events.
That split is the honest lesson of the page, and it is why the intent framing is worth the trouble. I modeled the intent correctly in prose and then encoded it in a language that cannot hold it, and the encoding drifted for a year without anybody noticing, because nothing compares the two. Level 2 below is the level at which they stop drifting apart.
A permission list is not a security posture you tune. It is a description of how many ways your project can be run, and it will be exactly as long as that number. Give the project one door, put the toolchain in a container behind it, deny the flags that redirect the door and the edits that rewrite it, and the list becomes short enough that somebody reads it. Everything else on this page is what happens after somebody does.