DeepSeek Harness · Approvals & Sandbox

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

Course goalAfter this lesson you can explain three things: why product concepts like plan mode, auto-accept, and YOLO collapse at DSH’s bottom into two orthogonal variables — 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).
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 again — see how the same op’s fate changes.

Knob 1 · sandbox mode

sandbox/mode (file effects) read-only workspace-write danger

Knob 2 · approval policy

approval/policy (ask humans or not) ask never

Preset dropdown

permission preset (knob shortcut)
Events appended to the session log
Model starts a tool callwrite ./src/app.ts
Sandbox layerKnob 1 decides file effects
Approval layerAfter a block, escalate — Knob 2 decides whether to ask
OutcomeWaiting to run
Approval dialog: the Agent wants to temporarily widen the sandbox to danger-full-access for this call — allow? (authorizes this one op only)
Hit Play for the current combo, or scroll here to auto-play once.
Teaching simulation. Knob values, preset table, and escalation approval match 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.
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 / 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.

Knob 1 · sandbox/mode
  • 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.
Knob 2 · approval/policy
  • 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.
Presets: just naming the cells

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 end

No 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 context

approval/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-shot

After 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.

Key evidence · never blocks before the cascade

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():

packages/interaction/user-approval/src/index.tslines 304–312 excerpt
  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'
Source snapshot note:Based on the local deepseek-harness-master repo; verified against 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.

Full write path for switching presets

“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。

Side-by-side · Single mode axis vs dual knobs
Claude Code: one mode axis + rule table

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.

Classroom Exercise
01

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.)

Takeaway:At DSH’s bottom, permissions are two orthogonal variables: sandbox/mode for file effects, approval/policy for asking humans; presets are table keys naming combos; custom is outbound-only. Approval is fail-closed end to end: the only allow is allowed-once for that one op; never blocks inside the service before cascade dispatch — nobody cuts the line.