Approvals & Permissions: Two Knobs, One Dropdown
Sandbox mode and approval policy are two independent knobs; presets are just common combos. Core source:packages/interaction/user-approval and packages/interaction/permission-presets。
sandbox/mode and approval/policy; why the preset dropdown only names knob combos, and which events the log actually appends on a switch; and why approval is fail-closed at every step (no answer → treat as deny).
Here’s a permission console you can twist. Left: two knobs, each one job. Right: the preset dropdown. Pick an operation, hit Play to watch the tool call clear the gates; then twist knobs, swap presets, run again — see how the same op’s fate changes.
Knob 1 · sandbox mode
sandbox/mode (file effects)Knob 2 · approval policy
approval/policy (ask humans or not)Preset dropdown
permission preset (knob shortcut)danger-full-access for this call — allow? (authorizes this one op only)docs/subsystems/permission-presets.zh.md, docs/subsystems/approval.zh.md, and packages/sandbox/sandbox/src/escalation.ts. While the dialog is up you can really click for the user.Bottom line first: DSH splits the big word “permissions” into two questions that don’t gossip with each other. Knob 1 sandbox/mode answers how far a command’s file effects may go — read-only / workspace-write / danger-full-access — filesystem only; network and process visibility aren’t in its vocabulary (docs/subsystems/sandbox.zh.md opening definition). Knob 2 approval/policy answers whether to ask when a human decision is needed — ask or never.
In the log they’re also two independent events: sandbox/mode and approval/policy. Execution, prompts, and replay only read the folded result of those two. That’s what orthogonal means in practice: twist either and the other doesn’t budge.
- read-only: backend must refuse writes; only keep sinks shells need, like
/dev/null. - workspace-write: workspace root and backend-promised temp areas are writable; everything outside is blocked.
- danger-full-access: bypass isolation. Consumers spawn raw commands and never call
ctx.sandbox.
- ask (default): hand to the responder chain. No responders? Chain ends with unavailable — still treated as deny.
- never: deterministically return rejected; dispatch no responders. The standard posture for CI and unattended runs.
- The only allow value is allowed-once, and it authorizes only the asked operation. rejected / cancelled / unavailable — callers treat all three as deny.
Then the dropdown. ctx.permissionPresets keeps a table: name → knob combo. Default table has two rows: workspace-write → workspace-write + ask; danger-full-access → danger-full-access + never (permission-presets/src/index.ts lines 167–176). It’s optional — not on the agent-loop trunk, and it owns no enforcement.
What happens on a preset switch? Three events, fixed order: first append a log-only permission/preset recording intent; then write sandbox/mode and approval/policy via each knob’s canonical setter — only when the effective value actually changes. Re-select the current preset? Append nothing. On replay the execution layer only honors knob events; the preset event’s sole job is remembering which name you picked when two presets share one combo.
Then custom. It’s reserved — the table mustn’t contain that name (plugin load throws). Only when you twist knobs to a combo missing from the table does current() derive custom for the client. Outbound only: it can be current state, never a switch target, never in an event payload. That’s why the demo dropdown greys it out — matching the source.
fail-closed end to endNo responders, responder throws, out-of-vocab return, user closes the UI mid-dialog — all normalize to unavailable or cancelled; callers treat as deny. Miss a clear allowed-once anywhere → no pass.
Approvals stay out of model contextapproval/asked and approval/decided pair into the session log for audit only. The model sees tool results and runtime context snapshots. ApprovalRequest deliberately omits tool args, pointing via callId at the already-streamed call so you don’t render a drifting copy.
Escalation is one-shotAfter the sandbox blocks a write, the model may retry with sandbox_permissions escalation; approval yields allowed-once; that retry resolves policy once under an explicitly wider mode, then discards it. Session knob settings don’t change.
A boundary worth pressing: if a plugin mounts after the approval service and prepends an always-allow responder at the front of the chain, can it bypass never? No — because never never runs in the chain. Look at the top of decide():
private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
const signal = req.signal
if (signal?.aborted) return 'cancelled'
// The 'never' policy is decided HERE, before any dispatch: a listener
// registered with `prepend: true` after this 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.
if (this.effectivePolicy(session) === 'never') return 'rejected'
packages/interaction/user-approval/src/index.ts, verified on 2026-08-13. Code blocks keep the original source text.The comment finishes the design intent: never’s verdict sits on the service’s own request path — no listener-shaped gate can keep the “registration-order-independent” promise. Further down, lines 317–329, ask’s fallback is equally airtight: empty-chain default is unavailable; throwing responders fold to unavailable; weird returns normalize to unavailable.
“Presets are just shortcuts” is implemented as a fourteen-line private apply(). Three steps, fixed order. 1) resolve(name) table lookup — unknown name throws, listing known keys. 2) Compare to current()’s derived result: only if your name differs from the effective preset do we append permission/preset; re-selecting current writes nothing. 3) Check each knob: if the preset’s sandbox mode differs from the folded effective value (deployment default if missing), call setSandboxMode(); same for approval via the injected setApproval. Skip whichever effective value didn’t change.
So switching presets has no third execution path and no hidden state: one intent log plus at most two knob writes through each canonical setter — what the log appends is what execution folds. Derived custom lives in the same file’s derive(): keep the last-chosen preset if it still matches current knobs; else first matching table row in declaration order; else custom.
Source:packages/interaction/permission-presets/src/index.ts lines 379–392 (apply()), lines 309–321 (derive()), verified on 2026-08-13。
Claude Code turns the same territory into a single permission-mode axis: default, plan, acceptEdits, bypassPermissions, dontAsk (restored restored-src/src/utils/permissions/PermissionMode.ts lines 44–91, study ch. 7), plus an allow/deny/ask rule table that can go as fine as Bash(git commit:*) prefixes.
Side by side, the cost of collapsing shows. dontAsk ≈ DSH never; bypassPermissions ≈ danger-full-access + never — but five notches slide on one axis, so sandbox tightness and “ask humans?” are sold glued together. DSH’s dual knobs can say read-only + ask: sandbox read-only floor, popup once when a write is truly needed. CC’s axis has no direct slot for that. Conversely CC has what dual knobs can’t: a rule table at tool/command-prefix granularity; DSH’s knobs are session-global, so tool-grain gates need the hook layer. Each side parks complexity elsewhere. Grok Build’s auth chain is yet another path (tool request → constrained execution, layer by layer) — see Grok’s full authorization chain.
Walk two nasty scenarios
Scenario 1: approval dialog up, user kills the browser; UI responder is disposed away. What outcome settles the request — allow or deny the tool call? Scenario 2: policy is never; a plugin prepends a responder that always returns allowed-once. Trace from request() to the return value and explain why that responder is never called. (Hint: both answers sit in this page’s source panel and the paragraph after it.)