Permissions, Sandboxes, and Approval Modes
You stop reasoning about safety as a feeling and start reasoning about it as a reachable set. You write down every place your agent can read from, every place it can write to, and every host it can send bytes to, then mark each row reversible, expensive, or public. You set your approval mode from that table rather than from how the last run went, and you delete at least one row that was only there because it came with the default.
THE REACHABLE SET
READ ──────────┐ WRITE ─────────┐ NETWORK ────────┐
what it can │ what it can │ where it can │
open or fetch │ change │ send bytes │
└──────────────┘ └──────┬───────┘ └───────┬───────┘
│ │
mark each row: reversible? expensive? public?
│ │
└─────────┬─────────┘
▼
┌──────────────────────┐
│ you ✋ │
│ approve the row, │
│ or delete the row │
└──────────────────────┘
│
▼
stack/blast-radius.mdApproval is the expensive answer. Deleting the row is the cheap one, and you only see the choice once the rows are written down.
- Answer "what can it reach" with a list of specific places instead of an adjective.
- Separate the three axes — read, write, network — and say why network is the one people leave off.
- Mark any row of your table reversible, expensive, or public, and say which of those forces a gate.
- State one thing a sandbox protects you from and one thing it does not.
- Choose an approval mode from your table and name the class of action you will always be asked about.
- Name every credential present in the environment your agent runs in, and say which ones you removed.
stack/blast-radius.md — a table of everything your agent can currently reach, split by read, write, and network, with each row marked for reversibility, cost, and public visibility, plus a list of what you removed and the approval mode you set as a result. Module 08 attacks this table and Module 10 puts gates on the rows it cannot remove.
"Is this safe?" is an unanswerable question, and asking it produces a feeling rather than a decision. Replace it with a question that has an answer you can write down: what can this thing reach?
The reachable set is every place your agent can get to, directly or by one more step. It is a property of your configuration, not of the model, and it does not change when the model gets better or worse. That is what makes it worth writing down: it is the part of your system that stays true.
Two things make the set larger than people expect. The first is transitivity. If your agent can run commands, it can reach anything the account running those commands can reach, which is usually more than the folder you were thinking about. If it can open a browser, it can reach everything you are logged into in that browser. The reachable set is closed under "and then what could it do from there", and one honest pass through that question usually doubles the list.
The second is the default. Every harness ships with some capabilities switched on because most users want them, and those arrive in your reachable set without anyone deciding. The set you inherited is almost never the set you would have designed. Module 01 asked you to write down what your harness can see. This module takes that section, asks the transitive question about every line of it, and turns the result into rows.
Key insight: you cannot reason about a risk you have not enumerated. The table is not paperwork, it is the first time the question has an answer.
Split the set into three because they fail in three different ways.
Read is everything it can open, fetch, or search. The instinct is to treat reads as harmless, and they are not, for two reasons that get their own modules later. Anything readable can end up in the context, and anything in the context can end up somewhere else if there is a write or a network row anywhere in the table. Anything readable is also untrusted text arriving next to your instructions, which is the whole subject of Module 08.
Write is everything it can change: files, records, messages, tickets, calendar entries, rows in a sheet. This is the axis people do think about, and the useful discipline here is granularity. "The repository" is not a row. reports/ is a row. "The CRM" is not a row. "Append to the Processed tab" is a row. A row you cannot point at is a row you cannot narrow.
Network is every host it can send bytes to, and it is the axis that gets forgotten, because it does not feel like an action. It feels like reading. It is not. Every outbound request is a write to somebody else's system and a potential exit for anything currently in your context. A URL can carry data in its path. A web search can carry data in its query. An agent that can fetch any address on the internet has an unbounded write channel regardless of how carefully you scoped its folders.
Most tooling makes read and write visible and leaves network implicit, which is exactly why your table needs a third section with hosts in it. If the honest answer is "anywhere", write "anywhere" and let it sit there looking uncomfortable, because that is a row you will want to narrow before Module 08.
Three properties decide whether a row can be left alone, and they are the only three you need.
Reversible asks whether you can put it back, with a mechanism you can name, in the time you would have before it matters. A file in a repo with a commit before the run: reversible. A message delivered to a person: not reversible, no matter what the platform's delete button implies, because they may have already read it. Reversibility is not about the technical possibility of undoing, it is about whether the undo happens before someone acts on the result.
Expensive asks what a mistake costs in money, time, or somebody else's work. Sending a hundred emails is not expensive to run and is expensive to fix. Deleting a build cache is cheap to fix. A row can be irreversible and cheap, or reversible and ruinous, so it is worth two separate marks rather than one summary judgement.
Public asks whether anyone outside your control sees the result. A published page, a message to a client, a comment on a shared ticket, a post. Public rows carry your name, and a wrong one costs credibility that no rollback restores.
row is reversible + cheap + private ─▶ let it run
row is any one of: irreversible,
expensive, or public ─▶ ✋ gate it, or delete it
Three properties, one decision. This is the rule Module 10 builds gates from.
The practical use of these three marks is that they turn an argument into an inspection. You are not deciding whether you trust your agent. You are reading a row and asking whether that specific place, if written to wrongly at three in the morning, produces a bad afternoon or a bad quarter.
A sandbox is a boundary around execution: a container, a virtual machine, a restricted profile, a workspace that cannot see the rest of your drive. They are genuinely useful and you should use one, and they are also routinely misunderstood in one specific way.
A sandbox protects the things outside it. It does nothing for the things inside it. And the reason this matters is that the useful things are inside it, because otherwise the agent could not do the work. Your credentials are inside the sandbox. Your API tokens are inside the sandbox. The copy of the client data you mounted so it could read the client data is inside the sandbox. A sandbox with your production database password in its environment is a box that cannot touch your laptop and can absolutely drop your table.
Network is the other place the mental model leaks. Many sandboxes isolate the file system and leave outbound network wide open, because installing dependencies needs it. That combination — everything sensitive mounted inside, and unrestricted egress — is the shape that turns a contained agent into an uncontained one. If you can restrict egress to a list of hosts, that single setting buys you more than any amount of file system isolation.
So treat the sandbox as one row's worth of protection rather than as the answer. It moves the boundary. It does not shrink the reachable set inside the boundary, and the table you are writing today is about what is inside.
Every harness gives you some version of the same dial: ask before everything, ask before writes, ask before dangerous things, do not ask. The first instinct is to set it to the cautious end and feel responsible. That instinct is right for a first run and wrong as a permanent setting, for a reason worth being blunt about.
A person asked to approve forty near-identical actions does not approve forty actions. They approve the first three and then click. This is not a character flaw and it will not be fixed by resolving to be more careful, because the thing being defeated is attention, and attention is a budget. A gate that fires constantly is a gate that is training you to say yes.
So the dial has a real cost on both settings, and the way out is not to pick a point on it but to change the shape of the question. Three moves, in order of how much they buy:
Remove the row. An action that cannot happen needs no approval, and most first tables have several rows that exist only because they arrived with a default.
Narrow the row so the remaining actions are boring. If it can only write to one folder, approving writes to that folder stops being a decision.
Batch what is left into one review with real evidence in it, so a single moment of attention covers many actions. Module 10 is entirely about this, and it works only if you have already done the first two.
Warning: the setting that quietly ends up at "do not ask" is almost never a decision. It is a busy Thursday, an agent that asked eleven times in four minutes, and a change nobody wrote down.
Everything above sounds like configuration. Most of the win is not configuration, it is where you put things.
An agent pointed at a folder containing one project cannot touch the other five projects, and that requires no permission model, no allowlist, and nothing that can be misconfigured. An agent given its own account with access to one shared folder cannot read the rest of the drive, and that stays true when the harness updates and resets its settings. Structure survives; settings drift.
This is why the durable version of least privilege in each track is a layout decision. In a terminal it is which directory you launch in and which repository the work lives in, and the corollary is that a project worth an agent is a project worth its own directory. In a browser it is which account the connectors are attached to, and the strongest single move available to an operator is to stop connecting personal accounts to workspaces and create a separate one that holds only what the task needs.
The same logic applies to time. Access that exists only while the run happens is smaller than access that exists permanently, so a connector you switch on for a Tuesday job and off again is a smaller row than one that sits there all year. Nobody does this perfectly. It is still worth asking, for each row, whether it needs to be reachable always or only during a run.
Finish the table with the question that is easiest to skip and most likely to matter: what credentials are present where this thing runs.
Not "what did I give it". What is present. Environment variables inherited from your shell. Tokens in a config file in your home directory. A browser profile that is logged into everything. A cloud CLI that is already authenticated. Saved credentials in a workflow platform, which are available to every workflow in the account rather than to the one you built. Anything reachable by a command your agent is allowed to run is in the reachable set whether or not you handed it over.
Go and look, rather than remembering. In a terminal, print the environment and read it. In a workspace or a workflow platform, open the credentials list and read every entry, including the ones added months ago for something else. Then apply one test to each: if this run went wrong in the most creative way possible, would I rather this credential had not been here? Every yes is either a removal or a row in your table with a mark against it.
Remember: credentials outlive the reason they were added. The audit that matters is the one you repeat, so put a date on this section and look again when the system grows.
An agent is set up to answer questions about why a nightly sync failed. It gets read access to the logs, which is the whole job, and it inherits the environment of the account that runs the sync, which nobody thought about, because that is where the logs live. Asked what went wrong, it reads the logs, forms a reasonable theory, and does the helpful thing: it re-runs the sync to confirm. The sync sends notifications on completion. The first anyone knows about it is replies arriving from people asking why they got the same message twice.
Nothing was misconfigured and nothing was hacked. The credential was in the environment, so the action was in the reachable set, so it was always going to be available on the run where it seemed useful. A table with a network row and a credentials section would have found it in ten minutes, before it found itself.
Twenty minutes. Work from stack/harness.md and check every row against stack/charter.md.
- Every row names a place a stranger could point at. No row says "the repo", "the drive", or "the CRM".
- The network section exists and is not empty. If the honest entry is "anywhere", it says so rather than being left out.
- Every row carries all three marks, and at least one row is marked irreversible, expensive, or public.
- The credentials section lists at least one thing you did not deliberately give the agent.
- Something is written under Removed, with a reason, and the system still does the task.
- The approval mode line refers to a row in the table rather than to how careful you intend to be.
Do not build a blast radius table for a system that only suggests. If your agent produces text you read and act on yourself, there is no reachable set worth enumerating, and the table becomes a ritual rather than a control. Write it the moment anything in your setup can write or send, and write it again whenever you add a connector, a tool, or a credential, because the table's value comes entirely from being current.
Run this after you have drafted the table by hand, not instead of drafting it. Its job is the transitive pass: taking each row and asking what else becomes reachable from there, which is the part people are worst at from memory. Its removals are suggestions to check rather than instructions to follow, because it cannot see whether your task needs a row.
<task> Audit the reachable set below, find what I have missed, and propose the smallest set of removals that still lets the task work. </task> <context> I am enumerating everything an agent I configured can reach, so I can mark what is irreversible and set an approval mode from the table rather than intuition. The task the agent exists to do: [paste the one-sentence task from your charter] Things it must never do: [paste your charter's Never list] Where it runs and what it can see: [paste the "what it can see" section of your setup note — working directory or attached files and connected accounts] My draft table: READS: [list every place it can open, fetch or search] WRITES: [list every place it can change, as specific destinations] NETWORK: [list every host it can send bytes to, or the word anywhere] CREDENTIALS PRESENT: [list every credential in the environment it runs in] </context> <instructions> 1. For each row, ask what else becomes reachable from it in one more step, and list anything reachable that I did not write down. 2. Mark every row, mine and yours, with three flags: reversible yes/no, expensive yes/no, public yes/no. 3. Name any row that would let content the agent reads leave my systems. 4. Name any row that conflicts with my Never list. 5. Propose removals, ordered by how much they shrink the set for how little they cost the task. Say what breaks for each one. 6. Recommend one approval mode and name the class of action that must always be asked about. </instructions> <output_format> ## Rows you missed [bulleted; each line names a specific place and the row it follows from] ## The marked table [table: place | READ/WRITE/NETWORK | reversible | expensive | public] ## Ways data could leave [bulleted, or "none in this table"] ## Conflicts with my Never list [bulleted, or "none found"] ## Removals, best first [numbered; each with one line on what stops working] ## Approval mode [one sentence, plus one line naming what must always be asked] </output_format> <rules> - Work only from what I pasted. List anything ambiguous as a question rather than assuming an answer. - Do not recommend specific products, settings menus, or version numbers. - Do not soften a row because the agent is "unlikely" to use it. Reachable is reachable. - No statistics and no claims about how often agents cause damage. - Every removal must name what stops working. - Keep the whole output under 600 words. </rules>
Draw three columns on paper and fill them from the setup note, then do one pass where you point at each row and say out loud "and from there, what?". Write down whatever comes out of that pass, because it is the part no configuration screen shows you. Then cross off the smallest row you can live without and go and actually remove it, today, while the table is in front of you.
Save the table with the charter and the setup note. Module 08 uses it to find the pair that matters most — an untrusted source you read and a privileged action you can take — and Module 10 puts a gate on every row you marked irreversible, expensive, or public and could not delete.
Save as: stack/blast-radius.md — read again in Module 08 and Module 10.
Say these out loud. If you cannot, reread the section named after each.
- My table has a row for every place my agent can write and every host it can reach. (Three axes: read, write, network)
- I removed at least one thing it did not need, and I can say what it cost me. (Least privilege is a folder choice before it is a setting)
- I can name one thing my sandbox does not protect. (What a sandbox does not protect)
Module 03 stops narrowing what your agent can do and starts improving what it knows. You write stack/AGENTS.md, the standing brief that loads on every run and turns a capable generalist into someone who already works here.