Harness Engineering / Tool Permissions / design standard

Permissions are the mechanism. Intent is what you meant to model.

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.

the argument · a permission rule models a command string. You meant to model a piece of work
the collapse · a make target is the unit of intent, so pointing the list at targets makes the mechanism and the model the same thing
the stack · Docker holds the toolchain, make is the only way in, and the allowlist names make
substitutions · make = your task runner, just or npm run if you prefer · docker compose = your isolation
the worked charter · one production Laravel and React portal of mine: 40 rules, a locked toolchain rule, four hook events, 78 make targets. Its allowlist is quoted verbatim, and audited
the syntax · Claude Code's, checked against the current permissions reference. The posture applies to any harness that gates tool calls
what is honest · that charter is one of the better ones I have, and section 3 finds seven accidental grants in twenty-four lines of it. Section 12.3 is what I have not fixed
reads alongside · Five Guardrails (rung 1 is this page's example, in one screen) · The Capability Ladder · Constraint Engineering · Prompt Injection
license · CC BY 4.0. Take the posture, not the vocabulary
Deny
Evaluated first, from any settings scope, and it cannot carry an exception. A refusal returned to the agent as the result of its call.
Ask
A prompt, every time, even when a narrower allow rule matches. The middle setting, and the one that decides whether the posture survives the week.
Allow
The work that runs without interrupting anyone. The sanctioned path, and the shorter it is the more of it you can read.
Floor
What holds when the list is wrong: the container, the sandbox, the credential that is not on the machine.
Part I

The dial

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

0 Two failure modes

Too loose is a blast radius. Too tight is a bypass flag by Thursday

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.

too loose one bad afternoon too tight prompt fatigue, then bypass the fix fewer things to permit

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.

0.1 · The other end, which is where teams actually live

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 mode that has exactly one legitimate home

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.

0.2 · The postures, and what each one is for

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.

permission modes, by how much attention a human is payingpick the mode, then size the list for it
ModeWhat it doesThe human isRight when
default · manualprompts on first use of each toolwatching every stepa new repository, a new agent, or work you do not trust yet
planreads and runs read-only commands, edits nothingwaiting for a proposalyou want the approach before anything moves
acceptEditsauto-accepts edits and common filesystem commands in the working directorieswatching the roomthe loop is edit-heavy and the diff is your review surface
autoauto-approves with background safety checks against your requestreading outcomes, not callsthe work is routine and the floor underneath is real
dontAskauto-denies anything not pre-approved. No prompts at allabsent, on purposeunattended runs. This is the posture the whole page is built for
bypassPermissionsskips promptsgone, and so are the checksa 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.

The reframe this page runs on

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.

1 What a rule is

Three lists, one order, and a deny that cannot carry an exception

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.

deny first ask second allow last
the three lists, in evaluation orderfirst match wins, and specificity is not consulted
ListEffectThe consequence people miss
denyrefuses the calla broad deny cannot hold an exception. Bash(aws *) in deny blocks Bash(aws s3 ls) in allow
askprompts every timeit beats a narrower allow. A matching ask prompts even when an exact allow also matches
allowruns without a promptit 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.

1.1 · Two ways to say no, and they are not the same tool

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.

bare name versus scoped patternchoose by whether you want the agent to learn
RuleWhat happensUse it when
deny Bashthe tool is gone from context. The agent cannot want itthe 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 resultthe 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.

The line the book keeps drawing

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.

1.2 · The specifier forms worth knowing

one rule shape per surfacethe shapes differ, and mixing them silently fails
SurfaceFormNote
shellBash(make test) · Bash(make *)matches the whole command text. * stands in for any text
files, readRead(./.env) · Read(~/.ssh/**)gitignore syntax. A read deny also blocks edits and writes to the path
files, writeEdit(/src/**)use Edit for every writing tool. A Write(...) path rule is accepted and never consulted
networkWebFetch(domain:example.com)hostname match. *.example.com covers subdomains, not the apex
serversmcp__github · mcp__github__get_*an allow glob must sit after a literal server prefix
sub-agentsAgent(Explore)deny to switch one off. See grants, in the ladder
parametersAgent(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.

2 Where rules bite

Six places the syntax does something other than what you meant

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.

position of the star spaces are part of the rule runners are not stripped

2.1 · The star, and everything before it

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.

what each rule shape actually matchesrow three is the one that surprises people
You writeIt matchesIt does not match
Bash(npm run build)npm run build, exactlynpm run build --watch
Bash(npm run *)npm run build, npm run test --watch, and bare npm runnpm install
Bash(git * main)git merge main, git push origin main, and git -c core.fsmonitor=<script> diff maingit log
Bash(ls*)ls -la and lsofthe space is part of the rule, and it is missing
Bash(ls *)ls -la, lslsof

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.

2.2 · Compound commands, which the harness handles

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.

  1. allow side Every subcommand needs its own match. The recognized separators are &&, ||, ;, |, |&, & and newlines.
  2. deny side One matching subcommand anywhere is enough. echo "$(git clean -f)" trips a Bash(git clean *) rule.
  3. unparseable A dangling operator such as npm test && is not split at all, and allow rules do not approve it. It prompts.

2.3 · Wrappers: some are stripped, and the dangerous ones are not

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 *):

An environment runner in an allow rule is an execution grant

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.

2.4 · Rules that constrain arguments are fragile

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.

2.5 · Path rules, and the single-slash trap

four path forms, and only one of them is absolutea single leading slash is not the filesystem root
PatternAnchored atExample
//paththe filesystem rootRead(//Users/alice/secrets/**)
~/paththe home directoryRead(~/.ssh/**)
/paththe settings source, not the rootEdit(/src/**) in project settings means the working directory's src
path or ./paththe current directoryRead(./.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.

2.6 · The read-only set you are already getting for free

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.

Part II

The collapse

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.

3 The gap, in one real file

Twenty-four entries, seven of which grant more than the person writing them meant

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.

intent what you meant to permit pattern what the rule matches gap everything in between
.claude/settings.json · a real allow list, uneditedread it as twenty-four attempts to say something the language cannot say
"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.

the same twenty-four entries, as intentsthe right column is what a reviewer is actually approving
EntryThe intent behind itWhat 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

3.1 · The two rows that are already right

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.

five kinds of junk, and what each one is a symptom ofevery row is the same missing thing
KindIn this fileSymptom of
the program grantgit *, gh *, jira *the intent had no name, so the program's name was used instead
the runnerdocker compose exec *, xargs *a general execution grant wearing a specific label. Section 2.3
the reopenerdocker compose exec * above a deny list for php and npmtwo layers written at different times by the same person, and never read together
the dead entryfive read-only commandsa default nobody rechecks. It costs nothing and it hides the real entries
the self-grantSkill(update-config)the enforcement sitting inside its own blast radius. Section 8
The diagnosis, and it is structural rather than personal

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.

4 The target is the intent

Restrict the permissions to the allowable set of intents, and give every intent a name

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.

one target one intent ARGS= the variation, not a wider grant one entry pointing at the set

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.

4.1 · The seven accidental grants, resolved

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.

from program grant to named intentthe middle column is the sentence somebody should have written down
WasThe intent, namedBecomes
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.

4.2 · Arguments are what keep a parameterized intent a single intent

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.

one intent, four invocations, no new permission entriesthe grant did not widen; the argument did the varying
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.

4.3 · Why one entry for seventy-eight intents is not cheating

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.

two ways to permit the same workthe difference is whether the set is written down anywhere
A pattern per commandOne entry, and a target per intent
the permitted set isimplied by the patterns, and nobody has listed itthe target list. make help prints it
entries to writeone per invocation shape, foreverone: Bash(make *)
adding a capability isa click on a prompt, in an untracked filea target, in a diff, with a reviewer
when the toolchain changesevery entry churnsmake test keeps its name; the recipe changes
how the agent discovers ittrial, refusal, retrymake help, and one always-on rule pointing at it
the residual grant iswhatever nobody noticed in a program's surfacethe recipes, plus the flags that redirect make. Section 8

4.4 · A name beats a command, for a second reason

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.

4.5 · Three artifacts pointing the same direction

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:

  1. the rule Always-on prose: use 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.
  2. the config A deny list for the raw tools and an allow for the door. This is the part that refuses, and it explains nothing.
  3. the Makefile The enumeration of intents, self-documenting, so the permitted set is discoverable rather than guessable. This is the part that makes the other two survivable.

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.

make help · the intent catalog, printeda reviewer reads the permitted surface in ten seconds
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".
5 Docker is the floor

The allowlist decides what may run. The container decides what it can touch

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.

gate one may this run gate two what can it reach result no host toolchain to reach for

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.

the stack, and what each layer can still get wrongbrick is the layer that holds when the layer above is wrong
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
what each layer decides, and what it cannotneither row is sufficient alone
LayerDecidesCannotFails when
permission ruleswhether a command string may run at allsee what the command does; a target, a script or an exec runs whatever it likesthe pattern is wider than you read it as. Section 2
makewhich named tasks existstop make -f, make -C, or a hostile ARGS=the Makefile is not reviewed, or the escape hatches are open. Section 8
the containerwhat any of it can touch: files, network, processesprotect what you mounted, or a secret you put inside itthe repository is bind-mounted with a .env in it, which is the normal case
the missing credentialwhat is reachable at all, from anywherebe undone by any config mistake, which is its whole appealsomebody 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.

5.1 · The harness has a sandbox too, and it is not the same boundary

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.

The division, in one line each

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.

6 One refusal, end to end

php artisan test is refused, make test is not, and the agent works it out unaided

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

denied php artisan test permitted make test explained in always-on prose
.claude/rules/toolchain.md · the always-on ruleit teaches, and it enforces nothing
**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.
.claude/settings.json · the enforcementdeny is evaluated first, from any scope
"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/**)"
  ]
}
the six steps, and the one that does the teachingno human is interrupted anywhere in this sequence
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

6.1 · The consequence of deny-first, which changes how you write the escape

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:

three ways to permit one exceptionthe first one is the only one that leaves the list short
MoveWhat it costsWhat it produces
add a targeta line in the Makefile and a reviewmake tinker. The list does not change at all, and the capability is now documented
move the family to aska prompt every time, for every php calla human decides per call. Honest, and it does not scale to unattended runs
narrow the denyone deny per dangerous subcommand, foreverthe 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.

6.2 · And when the work varies

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.

7 What you delete

Thirteen of those twenty-four entries should not be in the file

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.

replaced by a target dead already free kept and narrowed

7.1 · Replaced by the door

toolchain entries the door removeseach becomes a target, and moves to the deny side
DeleteBecauseNow
Bash(php *) · Bash(php artisan *)there is no PHP on the host to runmake 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 programsmake install, make lint
Bash(npm *) · Bash(npx *) · Bash(vite *)same. npx is also an unstripped runner, so it was an execution grantmake build, make dev
Bash(bundle *) · Bash(rails *) · Bash(rake *)the Ruby version of the same list, one framework overmake test, make console
Bash(python3 *) · Bash(pytest *)a general interpreter grant, which is every capability at oncea target, or a bundled script the skill runs by name
Bash(docker compose exec *)an unstripped runner: exec web sh -c '…' is arbitrary codetargets. Keep Bash(docker compose logs *) and ps if you want them

7.2 · Dead on arrival

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.

the five free entries, and the four that are nottwo thirds of this block can go, and one line of it is the whole machine
"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.

7.3 · Kept, and narrowed

what stays after the collapsethese are the entries that deserve the argument
KeepWhy it is not a targetNarrow it to
makeit is the doorBash(make *), with the section 8 denies beside it
git, the writing halfthe agent's work is commits and branchesBash(git commit *), Bash(git checkout -b *). Never Bash(git *)
the code host CLIit reaches a system outside the container by designBash(gh pr view *), Bash(gh pr create *), by subcommand
the tracker CLIsame, and it holds a credentialone entry per subcommand you actually use
fetchingthe network is not a task runnerWebFetch(domain:…) per host, plus a deny for curl and wget
serversMCP tools are not shell at allmcp__server__tool, per tool. the ladder, §4
The arithmetic, on the real charter this page is drawn from

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.

Part III

The residue

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.

8 The holes make leaves

A door is only as narrow as the file behind it, and the agent can edit that file

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.

-f and -C another makefile ARGS= a shell string Edit(Makefile) the door writes itself make shell a target that means anything
six holes, and the narrower rule for eachthe last row is the one that undoes everything above it
The holeWhat it gives backThe 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.

the denies that keep the door a doorwithout these, Bash(make *) is Bash(*)
"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.

8.1 · The target that means “anything”

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.

what a general target is actually grantingthese are the rows that make the door decorative
TargetWhat somebody uses it forWhat it grants
make shellpoking at a failing test, reading a log, checking a file in the containerevery 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 jobarbitrary code inside the application, with its database credentials and its full object graph
make artisan ARGS=…a migration, a queue command, a cache flushevery framework command, including the destructive ones and any package's own
any target with free-form ARGS=scoping a check to one pathwhatever the flag surface of the underlying tool allows, plus shell if the variable is unquoted

8.2 · Restrict the targets by operation instead

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.

Makefile · one target per operation, with typed parameterseach recipe quotes its variable and accepts one kind of value
# 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.

the denies that carve the general targets out of the doordeny is evaluated first, so a narrow deny beats the broad allow
"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.

Three of these are live in the charter from section 3

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.

8.3 · Why removing Skill(update-config) does not fix it

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

  1. the skill was never the capability It is a wrapper over the 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.
  2. so deny the path 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.
  3. so deny that too 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.
  4. and the config is bigger than the file A project skill can carry its own 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.

8.4 · Where the rule has to live instead

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.

four places to put the rule, and what each one survivesthe bottom two are the only ones outside the agent's write path
WhereWritten asSurvives
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.

One honest exception

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.

8.5 · What the door still buys, honestly stated

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.

9 What deny is not

A deny list stops an accident and teaches a habit. It does not stop an adversary

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.

accident what deny is for adversary what the sandbox is for absence what the missing credential is for

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.

what belongs in deny, and what each entry is actually protecting againstif a row's real answer is the sandbox, put it there too
EntryProtects againstReal floor
Read(./.env), Read(~/.aws/**), Read(~/.ssh/**)a curious agent reading a credential into contextthe credential not being on the machine, and OS-level path enforcement
Bash(git push --force *)a rewrite of shared history during a helpful cleanupbranch protection on the remote, which does not depend on your laptop
Bash(rm -rf *)the overnight run that reads a stale TODO literallya fresh clone per run, and a container that owns nothing you need
Edit(/Makefile), Edit(/.claude/**)the agent widening its own grant. Section 8review. 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 URLnetwork isolation in the sandbox or the container, plus a fetch allowlist
anything production-shapedthe unbounded, irreversible mistakeno credential, no route, and a human at the last step

9.1 · The injection case, where deny earns more than its keep

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.

Two settings to lock, not to argue about

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.

10 When a list cannot say it

Some rules are not patterns, and a hook is where they go

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.

PreToolUse runs before the prompt exit 2 blocks before rules deny still wins over an allowing hook

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.

four rules a list cannot expresseach one is a few lines of shell in a hook
The ruleWhy a pattern cannot say it
no make deploy from a branch that is not mainthe branch is not in the command string
ARGS= must be a path inside the repositorythe pattern cannot resolve a path or reject shell metacharacters
edits to the billing module need a human, everything else does nota path pattern can say it, but not the why, and the refusal should name the reason
everything is allowed except these six commandsallow-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.

Part IV

The practice

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.

11 Sizing and auditing

Five passes over a list you already have, and a rule about where entries live

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.

one screen the target size one reason per entry, sayable out loud
  1. pass 1 · free Delete every entry for a command in the read-only set: 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.
  2. pass 2 · shadowed Sort the list and look for a broad entry above narrow ones. 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.
  3. pass 3 · runners Find every entry whose program executes its arguments: 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.
  4. pass 4 · frozen Every remaining entry: say out loud why it is there. An entry whose reason is a prompt fired once in July is not a decision. Delete it and let the prompt fire again, once, with a person present to make the call properly.
  5. pass 5 · intent For each survivor, name the one intent it permits, in the vocabulary of the work rather than the tool. If the sentence needs an and, the entry is standing in for several intents and at least one of them is not the one you wanted. Give that intent a target and point the entry at the door instead. This is the pass that shortens the list permanently; the four above it only tidy.
the two commands the audit actually needsone reads the resolved state, one reads the file
# 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

11.1 · Where an entry lives is a statement about who decided it

three files, three meaningsthe middle row is where junk accumulates, by design
FileWhat an entry there meansTreat it as
.claude/settings.jsonthe team decided this is how the project is runconfig. Reviewed in a pull request like any other file
.claude/settings.local.jsonsomebody clicked do not ask againa queue of pending decisions, not configuration. Drain it weekly
~/.claude/settings.jsonthis is true of me in every project I openrare. 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 size test

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.

12 The artifact

The pair of files, fifteen rules, and where a live charter fails them

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.

.claude/settings.json · the whole permission surfaceone door, and a deny list that is now where the decisions are
{
  "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"
  }
}
~/.claude/settings.json · or managed settings, in an organizationthe self-protection cannot live in the file it protects
{
  "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.

Makefile · the enumeration the allowlist points atdocker underneath, one name per task, help for free
.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

12.1 · Fifteen rules

  1. Every allow entry names one intent, in the vocabulary of the work. An entry standing in for a program's whole surface is a bug, however convenient.§3 · §4
  2. The project has one entry point for work, and the allow list names it. The intents are enumerated behind it, as targets.§4
  3. Work that varies takes an argument. Widening a pattern to cover a family is the failure this replaces.§4.2
  4. No target grants the agent general execution. A shell, a language console and the framework CLI are denied by name, and the operations people reached for them with are named targets with typed parameters.§8.1 · §8.2
  5. The toolchain lives in a container. There is no host copy of it to reach for.§5
  6. Adding a capability is a target and a review, never a settings entry and a click.§6
  7. The door's own files are not writable by the agent — the 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.4
  8. Redirecting flags are denied: make -f, make -C, and their long forms.§8
  9. Every variable a recipe interpolates is named for its value, quoted, and validated, and no recipe takes a program name from an overridable variable.§8.2
  10. No entry names a program that executes its arguments unless the inner command is named too.§2 · §11
  11. No allow entry duplicates the read-only set, and no allow entry is shadowed by a broader one.§11
  12. Wildcards sit after the subcommand, and the startup warnings about rule shape are read, not dismissed.§2
  13. The shell's network tools are denied and fetching is allowlisted by host.§9
  14. The bypass posture is locked off, and production has no credential on the machine.§9
  15. The local settings file is drained weekly: every entry becomes a target, a reviewed entry, or nothing.§11

12.2 · Three levels

conformance levelslevel 2 is where unattended runs become reasonable
LevelRulesWhat it gets you
1 · one door1, 2, 5, 6, 11, 12every entry names an intent, and a reviewer can read the whole permitted surface
2 · the door holdslevel 1, plus 3, 4, 7, 8, 9, 10the catalog and the list cannot drift apart, and the agent can edit neither. Unattended runs stop being reckless
3 · the floor holdslevel 2, plus 13, 14, 15a list that stays short, egress that is bounded, and no credential that could become an incident

12.3 · Where I fail it

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.

The one paragraph to keep

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.