Approvals & Permissions: Two Knobs, One Dropdown
Sandbox mode and approval policy are two independent knobs; presets are just common combos
THE QUESTION THIS PAGE ANSWERS
ANSWER FIRSTWhat is the key idea behind “Approvals & Permissions: Two Knobs, One Dropdown”?
Sandbox mode and approval policy are two independent knobs; presets are just common combos
Make the claim earn its place. Use this page as a decision aid, not a definition to memorize. Connect the idea to one real task, one observable result, and one failure that would change your mind.
Write one question you could answer with evidence after trying this idea.
A conclusion that sounds complete but leaves the key assumption untested.
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.)
The handoffs inside “Interactive demo · Dual-knob console”
“Here’s a permission console you can twist.” shows that an Agent is not defined by the model alone. Each handoff between model, context, tools, state, permissions, and people affects both progress and recovery.
Write the state before adding capability
Starting from “Bottom line first: DSH splits the big word “permissions” into two questions that don’t gossip with each other.”, split the workflow into starting state, next action, tool result, state update, and stop condition. Debugging then means finding the first lost piece of information or authority instead of saying vaguely that the model “got worse”.
- 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
A happy path is not reliability
Use “Scenario 1: approval dialog up, user kills the browser;” to replay one successful and one failed run. Record the context, tool result, and owner at each turn; the workflow is maintainable when a second person can follow it without the original builder.
From “Interactive demo · Dual-knob console” to “First: what each knob owns”
“Interactive demo · Dual-knob console” grounds the problem in “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 agai…”. “First: what each knob owns” then moves it toward “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 /…”. Together, they show that the lesson is not just a conclusion to remember, but a claim with conditions.
Carry the judgment into the next situation
When analyzing an Agent, trace state, action, tool result, and next step in order. Each handoff should explain where information came from, who confirmed it, and where failure stops.
- “Interactive demo · Dual-knob console”: 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 agai…
- “First: what each knob owns”: 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 /…
- “The closing point”: never: deterministically return rejected; dispatch no responders. The standard posture for CI and unattended runs
The final “The closing point” brings the discussion to “never: deterministically return rejected; dispatch no responders. The standard posture for CI and unattended runs”. The useful thing to carry forward is knowing which judgments must be revisited when input, scale, or risk changes.
I turned one judgment from this article into a small experiment I could run today. Knowing what to observe next is more useful than simply remembering the conclusion.
After reading this, I first looked for the conditions behind the idea instead of copying the method into a project. That order made the later trade-offs much clearer.
When this judgment reaches real work, which constraint should be added first? I am curious which step matters most between reading and the first practical attempt.
No discussion on this article yet.