---
title: "Ask before writing, and refuse when nobody answers"
description: "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."
pubDate: 2026-09-28
author: tusgino
tags: [ai-engineering, architecture, security, agents]
series:
  id: tu-llm-den-harness
  title: "From LLM to Harness"
  order: 10
cover: /images/covers/hoi-truoc-khi-ghi.svg
coverAlt: "An arrow entering a vertical gate, with two short blocked arrows on the far side"
lang: en
faq:
  - q: "Should the default be ask, or never ask?"
    a: "Ask. For an agent with write access, silence is not consent — and when nobody can answer, the answer is no."
  - q: "What does the sandbox actually protect?"
    a: "Only file system effects. Network and process visibility sit outside that vocabulary, and the documentation says so outright."
  - q: "Why not merge every backend's denial strings into one list?"
    a: "Because each backend denies in its own dialect. A merged list claims denials a given backend never produces."
  - q: "How far does one approval reach?"
    a: "Exactly the action that was asked about. It does not open access for a session or for every call of the same kind."
---

<TLDR>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.</TLDR>

## 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:

```ts title="docs/subsystems/approval.md"
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`.

<Quote cite="dsh, docs/subsystems/approval.md">The action runs only after an approval service returns allowed-once and otherwise denies.</Quote>

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:

<Quote cite="dsh, docs/subsystems/sandbox.md">Network and process visibility are outside this vocabulary.</Quote>

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

<Quote cite="dsh, docs/subsystems/sandbox.md">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.</Quote>

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.

```diagram
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.

<Callout type="warn" title="The right default is to ask">
For an agent with write access, silence is not consent. The default must be `'ask'`, and when nobody can answer the result is `'rejected'`. A system that treats silence as consent will write things nobody meant to write.
</Callout>

## Do this now

<Steps>
<Step title="Do not use a boolean for permission">An `allowed` flag cannot say who approved, what they approved, or when it expires. Use a type with a contract.</Step>
<Step title="Make one approval cover one action">`allowed-once` sounds annoying, but it is what keeps the prompt meaningful.</Step>
<Step title="Make the no-answer case a denial">No person, no channel to ask on — the result must be `unavailable`, then denial. Never default to approval.</Step>
<Step title="Do not merge the denial dialects">Each backend has its own dialect. Merging them makes you match failures that never happened.</Step>
</Steps>

## The rule

Permission is a seam with a contract, not a flag. And when nobody can answer, the answer is no.

<Quote cite="tusgino">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.</Quote>

<KeyTakeaways>
- `ApprovalPolicy` has exactly two values, `'ask'` and `'never'`; `'ask'` is the default and an unanswered ask becomes `unavailable`, then denial.
- `allowed-once` permits 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.md` and `approval.md` contain not one numeric threshold.
</KeyTakeaways>
