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:

ZoneWhy it's a deny candidate, not askExample rule
CredentialsOnce 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 branchesRewriting 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*)
Infrastructureterraform 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 productionA 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 databaseAny 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:

  1. Running the full suite with pytest.
  2. Running git commit -m "..." on changes already reviewed by the agent.
  3. Running git push origin feature/delete-account.
  4. Reading the .env file at the project root.
  5. Running git reset --hard HEAD~3.
  6. Running terraform apply on the project's infrastructure.
See solution
  1. allowBash(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.
  2. allowBash(git commit *). A local commit is reversible — git reset undoes it without touching the remote — and it's exactly the kind of routine operation a well-thought-out allowlist should let through.
  3. askBash(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.
  4. denyRead(./.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.
  5. denyBash(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.
  6. denyBash(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