Ask before writing, and refuse when nobody answers
Permission is not a true/false flag. It is a seam with a contract, three sandbox modes that speak only about files, and a safe default: no answer means no.
A boolean is not enough
The easiest way to write the permission layer is a variable called allowed. Ask the question, set it to true, run the call.
That breaks in three places, and all three only show up later.
First, true is permanent. A user approves one git status, and the variable stays true for the next rm.
Second, true does not record who approved. There is nothing to audit.
Third, and worst: what happens when nobody can answer? No user, no channel to ask on. A boolean has no room for the answer “could not ask”.
The harness I read turns it into two types with a contract:
type ApprovalPolicy = 'ask' | 'never'Two modes, no third. 'ask' is the default. Under 'never', every ask resolves to 'rejected', without asking anyone.
And this is the detail the whole post rests on. Under 'ask', when the composed answerer chain has nobody in it, the default result is unavailable — not “approved”.
No answer means no. That is the default, not a switch you have to remember to turn on.
One approval, one action
The grant mechanism has a very specific name: allowed-once.
The action runs only after an approval service returns allowed-once and otherwise denies.— dsh, docs/subsystems/approval.md
The name says it. It permits exactly the action that was asked about, once. There is no allowed-for-session, no allowed-for-tool.
Think about an agent loop calling write_file thirty times. Under allowed-once, the user sees thirty prompts. That sounds annoying. But it is the price of every write having been seen by a person.
And there is a subtlety in the contract. The rejected, cancelled and unavailable branches all lead to denial. No branch defaults to approval.
The sandbox speaks only about files
This is the part I want you to read slowest, because it is the most misunderstood limit.
There are three sandbox modes:
| Mode | What it permits |
|---|---|
read-only | Only required sinks such as /dev/null |
workspace-write | Adds the workspace root and the backend’s temp area |
danger-full-access | Bypasses confinement entirely |
And right beside that definition, the documentation puts a limit:
Network and process visibility are outside this vocabulary.— dsh, docs/subsystems/sandbox.md
Network and process visibility sit outside that vocabulary. Those three modes speak only about file system effects.
So a command running under read-only may still open an outbound connection. Read “read-only” as “isolated” and you have it wrong — and the documentation does not hide that.
One more detail is worth noting. Only the first two modes can be sent to a backend. The third does not call the sandbox at all — it spawns the original argv.
And enforcement is reported as a fact, not a promise. full means the backend governs every promised file effect. partial means only a subset.
Older Linux kernels and some Windows boundaries are the current partial cases. Anything needing the absolute promise must reject rather than guess.
Do not merge the dialects
When the sandbox blocks a command, the error text comes from the operating system kernel, not the harness.
The harness writes no denial string of its own. It only records each backend’s own dialect:
The selected backend’s denial DIALECT: the case-insensitive stderr substrings a file effect denied by THIS backend produces (EROFS text under bwrap’s read-only binds, EACCES under Landlock, EPERM under Seatbelt). A consumer that infers denials from a failed run’s stderr matches against exactly these rather than a cross-backend union — the union claims denials a given backend never produces.— dsh, docs/subsystems/sandbox.md
Three backends, three different codes: EROFS when bwrap binds read-only, EACCES under Landlock, EPERM under Seatbelt.
And the warning at the end is the valuable part: do not merge them into a union. A union would claim denials a specific backend never produces — meaning you would match the wrong failure.
That is a small principle that holds in many other places. When three systems speak three languages, do not translate them into one sentence and then guess.
Two documents, not one numeric threshold
I read sandbox.md and approval.md looking for the familiar numbers, and found none.
No timeout. No retry count. No “N strikes and you are locked” rule. Those two documents contain no numeric threshold at all.
For a security subsystem, that is a notable choice. Everything else in the harness has numeric defaults. 0.8 for compaction, 30,000 ms for the web tool, 8,192 characters for the pruner. The permission layer has none.
I read that as an admission. The right number for how often to ask is your call, not one the harness makes for you.
View diagram data
type: quadrant
title: Picking permission level by reversibility
desc: The horizontal axis is access scope, the vertical axis is action reversibility
nodes:
- id: q1
label: Read files
sub: Read-only, naturally reversible
- id: q2
label: Write in workspace
sub: Reversible with git
focal: true
- id: q3
label: Outbound network
sub: Sent packets cannot be recalled
- id: q4
label: Delete outside workspace
sub: High risk, must confirm each time
focal: true
edges: []The price
Thirty prompts for thirty writes is annoying. Users will click “allow” without reading, and at that point the prompt is a ritual with no meaning.
allowed-once does not fix that. It only guarantees each approval is bound to a specific action. To reduce the noise you have to work elsewhere: narrow the agent’s access, or run it inside a workspace where workspace-write already suffices.
And the 'ask' default has its own price. In an environment with no person — CI, cron, a background agent — every question becomes unavailable. Every write is denied.
Correct for safety, awkward for operations. There you must switch to 'never' deliberately, rather than let it happen to you.
In exchange: every write was seen by a person, and you can audit who approved what.
Do this now
- Do not use a boolean for permissionAn
allowedflag cannot say who approved, what they approved, or when it expires. Use a type with a contract. - Make one approval cover one action
allowed-oncesounds annoying, but it is what keeps the prompt meaningful. - Make the no-answer case a denialNo person, no channel to ask on — the result must be
unavailable, then denial. Never default to approval. - Do not merge the denial dialectsEach backend has its own dialect. Merging them makes you match failures that never happened.
The rule
Permission is a seam with a contract, not a flag. And when nobody can answer, the answer is no.
The right default for an agent with write access is “ask”, and when nobody answers, the answer is “no”. Every other default is guessing on the user’s behalf.— tusgino
ApprovalPolicyhas exactly two values,'ask'and'never';'ask'is the default and an unanswered ask becomesunavailable, then denial.allowed-oncepermits exactly the action asked about — no session scope, no tool scope.- The three sandbox modes govern file system effects only; network and process visibility sit outside.
- Each backend has its own denial dialect —
EROFS,EACCES,EPERM— and they must not be merged into a union. sandbox.mdandapproval.mdcontain not one numeric threshold.