Certified Agent Harness Engineer
Course sections

M5.3

Approval flows without training users to click "yes"

1,264 words · about 8 min

Concept

This is the central UX–security tension in the discipline, and getting it wrong inverts the sign of your control.

A permission prompt requests human attention, which is a depleting resource. A prompt firing 200 times per session does not produce 200 careful decisions; it produces one decision — "this thing asks constantly, I approve everything" — applied 200 times. At that point the prompt is worse than no prompt: it costs real time, it manufactures a false audit trail in which every dangerous action is recorded as human-approved, and it displaces the design work that would actually have bounded the damage.

The mitigation is not a better dialog. It is asking less.

Mechanism: the three real mitigations

Mechanism

1. Presets — move the decision up a level. Ask once about a posture and let it answer the individual questions. The user makes one considered decision with real context ("scratch repo, write freely" vs. "production, ask me") instead of 200 context-free ones.

2. Scoping — make one answer cover a class. "Allow this pattern" turns N prompts into 1. Highest-leverage design in the area, and the most dangerous, because the pattern's width determines how much authority one click grants. The rule: the offered scope must be the narrowest one that actually eliminates the repetition, and the user must see the pattern's literal text. "Always allow git status" is safe; "always allow git" includes git push --force. Offering the wide option because it is easier to implement turns a fatigue mitigation into privilege escalation.

3. Structural elimination — make the action safe so it needs no prompt. If writes are confined by a kernel sandbox (M5.5), a workspace write needs no approval because the boundary is enforced rather than asserted. Sandboxing is what makes a low-prompt configuration defensible.

A weaker fourth is prompt quality: show the exact action, never re-render a mutable value that could differ from what executes. It reduces the error rate of decisions users do make; it does not reduce fatigue.

Worked example — the approval seam

Worked example

packages/interaction/user-approval/src/index.ts is worth reading end to end:

type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
type ApprovalPolicy = 'ask' | 'never'
  • allowed-once is the only positive outcome. There is no allowed-always in this seam; persistent policy is a different mechanism. The naming makes the scope error impossible to write by accident.
  • Four outcomes, all failing closed. A throwing answerer normalizes to unavailable; a rogue answerer returning something outside the vocabulary normalizes to unavailable; an abort produces cancelled and late answers are discarded by construction. No path lets a broken UI produce an allow.
  • The request carries no tool arguments. ApprovalRequest has agent, toolName, optional callId, optional reason, and a signal. The answerer attaches to the already-streamed call via callId rather than rendering a second copy. This closes a real vulnerability: a prompt that re-renders arguments can drift from what executes, and a user who approved the rendering has not approved the action.
  • never is decided before dispatch. Inside decide(), 'never' short-circuits ahead of the answerer waterfall. The source comment explains why a listener-shaped gate would not do: one registered with prepend: true after the service mounts "would sit ahead of any gate LISTENER, so a listener-shaped gate cannot keep the documented promise that 'never' rejects deterministically regardless of registration order — only the service's own request path can." A security promise that depends on plugin load order is not a promise.
  • Requests must be inside an open turn, because the approval/asked + approval/decided audit pair must be turn-enclosed — a bare event between turns is crash-tail garbage on reload. The audit trail is engineered to survive a crash.
  • Policy is derived from the log. effectiveApprovalPolicy(events) is a pure reverse fold, so the effective policy at any point is reconstructible rather than an in-memory flag nobody can inspect afterward.
  • Approval events are log-only — never in the model transcript. The model learns the outcome but cannot read the deliberation or condition its next attempt on the prompt's shape.

Worked example — presets as the fatigue mitigation

Worked example

docs/subsystems/permission-presets.md bundles two independent knobs — sandbox mode and approval policy — behind one named choice: workspace-write (workspace-write + ask, the default) and danger-full-access (full access + never).

The dangerous preset is the one that turns prompts off and removes confinement. That pairing prevents the worst configuration — "no sandbox but still prompting" — which looks safe, generates fatigue, and enforces nothing.

CUSTOM_PRESET = 'custom' is reserved and derived-only: what the system reports when the knobs match no named row, and "never a switch target or an event payload." Users cannot select it, only arrive there. A config table defining an entry named custom throws at load, and composing the presets plugin over a non-confining bash executor also throws — the harness refuses to offer a sandbox choice it cannot enforce. set() appends a permission/preset event, then writes each knob through its own canonical setter only when that knob changes, because the service "owns no enforcement... a preset switch only records intent." Presets are a UI over the real controls, never a second path into them.

Tradeoffs

Every mitigation trades per-action review for usability, and no setting is right for all users. Presets set permissively once and forgotten govern months of sessions silently, which is why current posture belongs somewhere persistently visible. Pattern grants are only as good as the narrowest offered pattern. Structural elimination is the only mitigation that trades away no safety, and the most expensive to build.

How `dsh` does it

How dsh does it

dsh renders the current posture into the model's own system prompt (context 'approval:policy', order 115), so the model can batch or avoid actions that would prompt. The sandbox layer gives the model a structured escalation path instead of a blocked retry loop: a denied command appends [sandbox: file access denied under <mode> mode], and when escalation is possible, [sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]. The model names the narrowest sufficient mode and supplies a justification, and the user still decides — converting a retry storm, the classic fatigue generator, into one well-framed question.

The escalation path (packages/sandbox/sandbox/src/escalation.ts) keeps that convenience from becoming a hole. A WIDER_MODES table states which escalations are expressible, and a request not strictly wider than the current mode is rejected — the channel cannot move sideways or re-request what it already has. Justification is validated non-empty, and a mode without one (or a justification without a mode) is an error, so no permission request reaches the user with an empty rationale. The reason is composed as escalate sandbox to <mode>: <justification> — the mechanical consequence first in the harness's own words, then the model's argument. A prompt showing only the model's justification would let the model frame the question it is asking; putting the enforced fact first means it can argue but cannot mislabel the request. Every failure there throws rather than returning a soft denial, so no value can be accidentally read as success.

How others differ

Claude Code offers persistent allow-rules with project scope, so "don't ask again" survives restarts — better fatigue relief, larger accumulated-grant surface to audit. Codex CLI's full-auto mode disables prompting entirely inside a sandbox: structural elimination taken to its conclusion. dsh's stricter line — one-shot approval only, persistence living in a separate event-logged preset mechanism — trades convenience for the guarantee that no single click widens standing policy.


Check yourself

3 questions on M5.3

Start drill