Module 3 — The Harness: Designing the Environment Where the Agent Works
4. Tools and permissions: least privilege for the agent
Description
By the end of this lesson you will be able to configure, in your own repository, a least-privilege
permissions policy: what the agent can do without asking you anything, what needs your explicit
confirmation every time, and what stays blocked no matter what the current prompt says — using the real
allow, ask, and deny rules your tool already supports, not a promise of good behavior written in
prose.
This matters in real work because the question isn't theoretical: any team that gives an agent shell
access with no explicit policy is, in practice, deciding by default that the agent can attempt any
command that occurs to it — including one that deletes a shared branch, applies a migration against the
wrong database, or leaks a credential that was sitting in plain sight in a .env. The agent doesn't
need bad intent: it's enough for it to misread the task, or for a file it reads to contain an injected
instruction, for the result to be the same.
Connection to the module: in the previous lesson you saw how to write an instructions file the agent genuinely respects. This lesson takes the other half of the problem: what happens when, for whatever reason, the agent doesn't respect that rule. Instructions shape what the agent tries to do; permissions decide, at the harness level itself, what it's allowed to execute, no matter what it tries — they're two different systems, and confusing them is this lesson's first common mistake. The next lesson completes the loop: once you know what the agent can touch, what's missing is whether it knows if what it touched actually worked.
The badge system: what opens on its own, what needs a signature, and what has no door
Think of a new employee's first day at a large warehouse. They're handed an access badge, and that badge already comes programmed with a criterion, not an endless list of forbidden doors. It opens the main entrance on its own, the packing area, the common tool storage — the employee never has to ask permission to enter there, and nobody from the previous shift has to approve it each time. For the electrical maintenance room, the badge doesn't open on its own: it triggers an alert to the supervisor, who has to come over and authorize that specific entry, every time, no exception. And for the high-value inventory vault, there isn't even a card reader on that door — it's not that the employee has low priority there, it's that there's physically no way in with what they were given on day one, no matter how urgent the request sounds.
A coding agent works with the same three-tier logic, and the industry's word for each tier is almost
literal: allow rules (the door that opens on its own), ask (the door that always calls the
supervisor), and deny (the door with no reader, the one no instruction in the moment can open).
There's a detail from the warehouse badge that translates exactly to how this works in practice: if a
door has no reader, it doesn't matter that the employee also has a special, very specific authorization
for "any storeroom in the building" — that authorization doesn't override the physical absence of the
reader. Claude Code documents this same hierarchy unambiguously: rules get evaluated in a fixed order —
first deny, then ask, then allow — and the first one that matches decides the outcome, no matter
how specific a competing rule at another level is. A broad rule like Bash(git push *) in deny blocks
any variant of that command, even if a more specific allow rule exists elsewhere for one particular
push case — specificity doesn't change the evaluation order.
In Claude Code, this policy lives in a configuration file — usually .claude/settings.json at the
repository root, so the whole team shares the same badge — with three arrays: allow, ask, and
deny. Every rule has the shape Tool(pattern): Bash(pytest *) for a shell command prefix,
Read(./.env) for a file path, WebFetch(domain:example.com) for a domain. Other tools — Cursor,
Codex CLI, and the rest — solve the same problem with an equivalent mechanism under a different name;
what follows uses Claude Code's syntax because it's the one that can be verified line by line against
its documentation, but the design criterion — what runs on its own, what asks for confirmation, what
stays blocked — transfers unchanged to any tool you use.
Worked example: the same repository, two permissions policies
Go back to lesson 2's FastAPI project — the one where you added the GET /api/profile endpoint. The
team wants the agent to keep iterating on that repository without someone having to approve every
command, but they also don't want to give it free rein over everything. Start by seeing what happens
with no written policy at all.
ls -la .claude/settings.json 2>/dev/null
What to expect (no policy): the command prints nothing — the file doesn't exist. With that, the
default permission mode (default) stays active: every new shell command the agent tries — pytest,
ruff check, git commit — triggers a question the first time it appears in the session, with no
distinction between a low-risk one and a high-risk one. The only exception is a fixed set of pure
read-only commands Claude Code recognizes in any mode — among them ls, cat, grep, and read-only
forms of git, like git status or git log — which run without asking even if no configuration file
exists.
Now give the agent this task: "Add a DELETE /api/profile endpoint that deletes the authenticated
user's account. Run the tests and commit the change." With no policy, every step that isn't that pure
reading — running pytest, running ruff, making the commit — triggers an individual approval. And
there's no barrier distinguishing "run the project's tests" from "apply a migration against the wrong
database": both ask for exactly the same thing, your on-the-spot approval, in the moment, under the
pressure of wanting to keep moving.
Now the team writes the policy, versioned in the repository, in .claude/settings.json:
{
"permissions": {
"allow": [
"Bash(pytest *)",
"Bash(ruff check *)",
"Bash(mypy *)",
"Bash(git commit *)"
],
"ask": [
"Bash(git push *)",
"Bash(alembic upgrade *)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Bash(git branch -D*)",
"Bash(terraform apply*)",
"Bash(terraform destroy*)"
]
}
}
With this file at the repository root, run the same task again.
What to expect (with policy): the agent runs pytest, ruff check, and mypy with no
interruption at all — they're in allow. When it reaches the commit, it doesn't ask either:
Bash(git commit *) is also allowed. If at some point it decided to run alembic upgrade head — for
example, because it interpreted that deleting an account required a schema change — it stops right
there and asks you, because an ask rule always requests confirmation, no matter how routine it seems
that time. And if for any reason — a misread instruction, injected content in a file it read — the
agent tried to read .env or force-push, the response doesn't even reach you as a question: the tool
is blocked before the session shows it to you.
The difference isn't that the second repository has a "more disciplined" agent. It's that in the first one, every decision about what's safe to run is made by you, live, command by command; in the second, you made it once, in writing, and the harness itself enforces it without depending on the model remembering or respecting it.
Typical off-limits zones: what no agent should be able to touch without you
Five zones show up again and again, in any repository with something in production behind it, as
mandatory deny candidates — not ask, because there's no context where it's worth asking twice
before saying no:
| Zone | Why it's a deny candidate, not ask | Example rule |
|---|---|---|
| Credentials | Once a secret enters the conversation's context, there's no reliable way to "forget it" — and asking doesn't help either, because by the time the question arrives the file has already been read. | Read(./.env), Read(./.env.*), Read(./secrets/**), Read(~/.ssh/**) |
| Git history and branches | Rewriting the past (push --force, reset --hard) or deleting a branch affects anyone else or any other session working on the same repository in parallel, not just the agent that ran it. | Bash(git push --force*), Bash(git reset --hard*), Bash(git branch -D*) |
| Infrastructure | terraform apply or kubectl delete don't modify a file: they modify a production system that keeps running after the agent's session ends. | Bash(terraform apply*), Bash(terraform destroy*), Bash(kubectl delete*) |
| Migrations against production | A badly written schema migration doesn't roll back with a git checkout: it can lose real data from real users. | Depends on which database the command points to — the hooks section below explains why a Bash prefix isn't enough to distinguish this. |
| Production database | Any direct read or write access to production, outside the application code that's already been through review. | Block the MCP server or the entire tool connecting to production; the real separation of environments is covered in more depth in lesson 6 of this module. |
On teams where several people — or several agents — work on the same working tree at the same time, the
"history and branches" row usually goes even further: it's not just rewriting the past that gets
blocked, creating new branches without someone explicitly asking for it gets blocked too
(Bash(git checkout -b*), Bash(git switch -c*)), because a branch change made by one session changes
what any other session working in parallel on that same directory sees.
Least privilege in practice: why the allowlist beats the denylist
The table above is deliberately short, and it's worth not misreading it: it isn't an exercise in "enumerate everything dangerous you can think of" — it's a curated list of zones that never have a legitimate use case within an agent session. For everything else, the general strategy isn't symmetric between "allow and block the dangerous" versus "block everything and allow the verified," and it's worth understanding why before writing your own policy from scratch.
Imagine that, instead of the previous example's policy, someone tries to solve the same problem backwards: leave everything open by default and block, one by one, the commands that occur to them as dangerous. To restrict the agent to only fetching code from a trusted repository, they write this rule:
{ "permissions": { "allow": ["Bash(curl http://github.com/*)"] } }
The intent is clear: only let curl reach GitHub. But a shell pattern that tries to bound arguments
like this is fragile by construction, and it doesn't even take an attempt to dodge it — normal variants
of the same command already slip past it: passing an option before the URL
(curl -X GET http://github.com/...), using https:// instead of http://, relying on a redirect from
a domain that does match but forwards elsewhere, or simply storing the URL in an environment variable
before invoking curl. Every one of those variants is one more case to anticipate, and the list of
variants has no ceiling — there's always some combination of flags, protocol, or indirection nobody's
written yet.
That's the asymmetry. Enumerating everything dangerous that exists is an endless list, because danger
reinvents itself with every new flag of every new tool. Enumerating what your team actually needs to run
daily — pytest, ruff check, git commit — is a short, known list, verifiable at a glance. That's why
the design that works isn't "everything open, I block what I recognize as dangerous": it's "everything
closed by default — anything not on the list asks for confirmation — and I add to the allowed list only
what I've already verified is safe in this repository." The denylist still has a place — the zones from
the table above — but as a last-resort lock over already-known cases, not as the main defense.
When the prefix isn't enough: hooks for zones allow/deny can't tell apart on their own
The "migrations" row in the table was left open on purpose. A Bash prefix can't distinguish "run
alembic upgrade head against the local test database" from "run that exact same command against the
production database" — the command's text is identical in both cases; what changes is an environment
variable or a configuration file the prefix can't see.
For this kind of case, Claude Code offers a separate mechanism: a PreToolUse hook, a script you write
yourself that runs before any approval prompt gets shown. The hook can inspect the full call — not just
the command's prefix — and decide whether to block it, force a question, or let it through. The
documentation itself recommends this exactly for this kind of case: for the curl example from the
previous section, the robust alternative it offers isn't "write a longer prefix," it's "use a hook that
validates the real URL before letting the command run."
Applied to migrations: a hook can read the DATABASE_URL variable (or your migration tool's connection
argument) before letting the command through, and block any call whose connection string doesn't point
to a host you recognize as a test environment. That's exactly what an allow/ask/deny rule over the
command's text can't do on its own, because the text doesn't change between the two cases — the
decision depends on data only known at the moment of execution.
This is the only tool in this lesson that requires writing your own code, and that's why it's left as a
deeper dive: most of the off-limits zones from the previous table get solved with simple
allow/ask/deny rules; only when the same command text can be safe or catastrophic depending on
external data does a hook become necessary.
The real cost of approving everything to avoid interruptions
Writing a least-privilege policy, with its three lists, takes time. Approving everything and never
thinking about it again takes a second. The temptation of the second option is real, and tools usually
offer a mode that does exactly that — in Claude Code it's called bypassPermissions, and it skips
approval for basically any action, with two specific exceptions: hand-written ask rules, and an
attempt to delete the filesystem root or your entire home directory (rm -rf /, rm -rf ~), which
still triggers a question as a last-resort lock against a gross model error.
Everything else in that mode runs unreviewed — including writes inside .git, .claude, .vscode, and
other tool configuration folders that mode explicitly leaves unprotected. The documentation itself is
explicit about where that risk is acceptable: use it only in an isolated environment — a container or a
virtual machine — where, if something goes wrong, there's nothing of real value to lose. Turning it on
as the default mode on every developer's laptop on the team is a different thing: there, real
credentials exist, there's a copy of the repository with real history, and there's no isolation between
what the agent can touch and what actually matters.
You already saw in lesson 1 of this module that predefining what the agent can touch — instead of asking action by action — reduced permission interruptions by 84% in Anthropic's internal use, without lowering security. That data point matters again here because it shows friction and security aren't necessarily at odds: that reduction didn't come from turning off checks, it came from investing time in writing, once, an allowlist reflecting what the team actually uses every day. That list is exactly what you built in this lesson's worked example — the difference between "never interrupt me" and "don't interrupt me over what I've already verified is safe" is what separates a repository with real least privilege from one that just turned off the alarm light.
Common mistakes
Confusing a rule written in the instructions file with a real permissions restriction
(conceptual). What happens: the team writes something like "never touch the production database" in
CLAUDE.md and calls the problem solved, without adding an equivalent rule in settings.json. Why it
happens: the sentence sounds like a rule, and in most sessions the agent does in fact respect it — that's
exactly what you saw in the previous lesson — so it's easy to forget instructions and permissions are
two different systems. Claude Code's documentation says it plainly: "permission rules are enforced by
Claude Code, not by the model" — instructions shape what the agent tries, permissions decide what the
harness allows, and only the second system is a real barrier. How to spot it: for every "never" you have
written in your instructions file about credentials, production, or infrastructure, check whether the
equivalent deny rule exists in settings.json — if it only exists as prose, there's no enforced
barrier, there's an expectation. How to fix it: every "never" rule about a sensitive zone needs its
counterpart in permissions; the instruction explains the why, the rule guarantees the outcome even if
the agent misreads or ignores the prose.
Treating a command denylist as if it gave the same protection as an allowlist (conceptual). What
happens: the team writes a handful of deny rules trying to name every dangerous command they can think
of — rm -rf, a pattern for DROP TABLE, terraform destroy — and considers the repository protected.
Why it happens: enumerating known dangers feels concrete and thorough, but a blocking pattern over shell
arguments is fragile by design — there's always an unconsidered variant: another flag, an environment
variable, an alias, an intermediate script — as you saw with the curl-restricted-to-GitHub example.
How to spot it: ask yourself how many different ways exist to achieve the same effect your rule is
trying to block; if you think of a second one in under a minute, your denylist has a gap. How to fix it:
invert the logic — first define what the agent can do without asking (a short, verified list), leave
everything else in default ask-first mode, and reserve explicit deny only for zones that should never
execute no matter what.
Turning on a mode that approves everything to stop being interrupted, without bounding it to an
isolated environment (practical). What happens: someone configures bypassPermissions (or their
tool's equivalent) as the default mode on every developer's work laptop, not in a disposable container.
Why it happens: the friction of approving command by command is real and gets tiring fast, and that mode
looks like the simplest way out. How to spot it: check where that mode is configured — if it's the
defaultMode of a shared .claude/settings.json running against repositories with real credentials and
history, you've already fallen into this. How to fix it: real friction gets solved with a curated
allowlist of what the team actually uses daily, like in this lesson's worked example — not with a switch
that turns off every check at once.
Exercises
Exercise 1 — Classify and write the rule. You have the same FastAPI repository from lesson 2, with
alembic for migrations, and this list of operations the agent might need in a typical session. For
each one, decide whether it belongs in allow, ask, or deny, and write the exact rule:
- Running the full suite with
pytest. - Running
git commit -m "..."on changes already reviewed by the agent. - Running
git push origin feature/delete-account. - Reading the
.envfile at the project root. - Running
git reset --hard HEAD~3. - Running
terraform applyon the project's infrastructure.
See solution
allow—Bash(pytest *). It's the return signal the team wants the agent to use all the time with no friction; there's no real risk in running the test suite.allow—Bash(git commit *). A local commit is reversible —git resetundoes it without touching the remote — and it's exactly the kind of routine operation a well-thought-out allowlist should let through.ask—Bash(git push *). Pushing to the shared remote does have a cost if it goes wrong — other people and other sessions see it — but it isn't severe enough to always block; a one-off confirmation is proportional to the risk.deny—Read(./.env). A credential doesn't get "asked about first": once it enters the conversation's context it's already exposed. There's no scenario where it's worth asking instead of blocking.deny—Bash(git reset --hard*). It irreversibly rewrites local history and affects anyone sharing that working tree; it isn't a routine operation that merits just a question.deny—Bash(terraform apply*). It modifies real infrastructure that stays alive after the session ends; the blast radius completely exceeds what a coding session should be able to decide on its own.
Why this classification works: the criterion separating ask from deny isn't "how bad the command
looks" but whether any legitimate scenario exists where asking once is enough (push) versus one where no
context justifies even attempting it (reset --hard, terraform apply, reading a secret).
Exercise 2 — The denylist that doesn't protect what it thinks it protects. Someone on your team writes this rule, convinced they've already blocked access to the project's credentials:
{ "permissions": { "deny": ["Bash(cat ./secrets/*.json)"] } }
What two ways does the agent have to read the content of those files without this rule catching it? What rule actually closes the problem, and what limit does even that better-written rule still have?
See solution
Two concrete ways: (1) using any other read command on that same path — head ./secrets/api-key.json,
tail, even cat written a different way — because the rule matches against that specific pattern, not
against "any way of reading that directory"; (2) writing its own script — two lines of Python that do
open("secrets/api-key.json").read(), run with Bash(python *) — because that's no longer a shell
command recognized as file reading, it's an arbitrary process opening the file on its own.
The rule that does close the first case is Read(./secrets/**) as deny: Claude Code applies
Read/Edit rules not only to its own reading tools, but also to Bash commands it recognizes as file
reading — cat, head, tail, sed — so a head or a cat with another path inside that folder also
gets blocked by the same rule.
The limit that even that better-written rule still has is the second case: a script the agent writes and
runs itself opens the file on its own, not through a command Claude Code recognizes as file reading, so
the Read rule doesn't catch it. Fully closing that gap — for any arbitrary process, not just recognized
commands — is the job of an operating-system-level isolation layer, which is outside this lesson's scope.
Why this answer works: it identifies exactly the conceptual mistake from the allowlist-versus-denylist
section — a rule enumerating a specific case (cat over a name pattern) always leaves gaps a rule acting
on the protected resource (Read over the path) doesn't, at least for paths the tool recognizes.
Exercise 3 — The switch that turns off everything. A team, tired of approving command by command,
adds this to the .claude/settings.json committed to the repository and used by everyone on their work
laptops:
{ "permissions": { "defaultMode": "bypassPermissions" } }
Explain exactly what stopped being protected with this change, and what you'd do instead to achieve the same friction reduction without the same risk.
See solution
With bypassPermissions as the default mode, practically everything runs without asking — including
writes inside .git, .claude, .vscode, and other configuration folders that mode explicitly leaves
unprotected — with only two specific exceptions: hand-written ask rules, and an attempt to delete the
filesystem root or the entire home directory. No credential, no migration, no force push is covered by
those two exceptions. And the problem isn't just technical: the team configured it in the shared file
running on every developer's laptop, not in an isolated container with nothing of real value inside,
which is the only context the documentation itself recommends this mode for.
Instead, real friction reduction comes from what you already saw in this lesson: a curated allow list
with the commands the team actually runs every day (tests, lint, types, local commits), leaving the rest
in default ask mode, and a short deny list for zones that should never execute no matter what. That
combination achieved, in Anthropic's internal use, a reduction in interruptions comparable to what this
team is looking for, without turning off any real check.
Why this answer works: it applies the full "real cost of approving everything" argument to a concrete case — friction and security aren't at odds; what's at odds is the laziness of not writing the policy and the risk of having none.
Summary and next step
You can now write, for a real repository, a least-privilege permissions policy: what runs without
asking, what always requests confirmation, and what stays blocked no matter the prompt of the moment,
using real allow, ask, and deny rules, not an expectation written in prose. You can also name the
zones that almost always deserve deny — credentials, git history and branches, infrastructure,
migrations, and production databases — and explain why a list of what's allowed holds up better than a
list of what's forbidden, even when the two seem to cover the same cases on paper.
Everything you did in this lesson answers a single question: what can the agent touch. It doesn't
answer the next question, which is whether what it touched actually worked. An agent with a perfect
permissions policy can run pytest with no interruption at all and still hand you a broken change, if
nobody configured a signal telling it, in the moment, that something failed.
Before moving on to lesson 5 you should be able to: write from memory the minimal structure of a
.claude/settings.json with the three lists; explain in your own words why deny beats allow no
matter how specific the competing rule is; and name at least three zones in your own work repository
that today have no deny rule and should have one.
The next lesson takes that pending question — did it actually work? — and builds the complete feedback loop: tests, types, and linter as the three cheapest sources of signal, and why that loop's duration matters more than its thoroughness.
Resources
- Configure permissions — Claude Code — full reference for
allow/ask/denyrules,Bash,Read,Edit,WebFetch, andmcp__syntax, the deny-ask-allow evaluation order, and why an argument pattern overcurlis fragile. - Settings — Claude Code — structure and location of
.claude/settings.json, and how to distribute the same permissions policy to the whole team from the repository. - Permission modes — Claude Code — when to use each mode (
default,acceptEdits,plan,bypassPermissions) and the concrete risks of skipping approvals. - Automate actions with hooks — Claude Code — how to write a
PreToolUsehook for cases where a command prefix isn't enough, like distinguishing a test migration from a production one. - Beyond permission prompts: making Claude Code more secure and autonomous — Anthropic — the source of the 84% interruption reduction data point cited in this lesson and in module lesson 1.