Skip to content

Permissions in dsh are two orthogonal knobs that a preset bundles together: the sandbox mode (read-only / workspace-write / danger-full-access, from Sandbox architecture) and the approval policy (ask / never). What "requires approval" is decided by composing these — not by a flat allow-list of tool names.

PackageRole
packages/interaction/permission-presetsctx.permissionPresets: preset table, /permission command, permissions projection
packages/interaction/user-approvalctx.approval: request/cancel/decide, per-session policy, audit events
packages/core/toolsctx.tools: the pre-execute/execute/post-execute pipeline where approval ask is resolved
packages/client/ui-permission-presetsWeb surface: new-session Settings row + current-session command picker
packages/guard/repeat-tool-reminderAdvisory nudge reminding the model about repeated calls

What requires approval

Approval is built around ApprovalPolicy (packages/interaction/user-approval/src/index.ts):

  • 'ask' (default) — delegate to the composed answerers; with none composed, the chain fails closed to 'unavailable'.
  • 'never' — never prompt anyone; every ask resolves 'rejected' deterministically (the strict headless/CI stance).

So there is no fixed list of "dangerous tools." Instead, a request needs approval when (a) the tool's gate returns ask, or (b) the sandbox escalation path (sandbox_permissions + justification) is invoked — both funnel through the same ctx.approval. Under workspace-write, a wider write or a shell command outside the workspace must escalate, which prompts the user; under danger-full-access + never, nothing prompts. The presets are how a deployment points at one of these combinations.

Preset names and bundles

The preset table (packages/interaction/permission-presets/src/index.ts) maps a name to a PresetSpec — a sandbox mode plus an approval policy (and optional display name/description). The built-in defaults:

Preset namesandboxapproval
workspace-writeworkspace-writeask
danger-full-accessdanger-full-accessnever

The reserved name custom (CUSTOM_PRESET) is never a switch target — it is the derived state when effective knob values match no table entry. The table is configurable via the plugin Config.presets; the service constructor requires a confining ctx.shell (one that advertises a sandboxMode) and ctx.approval — composing presets over an unconfined executor is a load-time error.

Switching a preset records durable, log-only user intent and then writes each changed knob through its canonical setter:

  • permission/preset event with the selected name (session.append);
  • setSandboxMode(session, spec.sandbox) — emits sandbox/mode (@deepseek-ai/dsh-sandbox-policy);
  • setApprovalPolicy / ctx.approval.setPolicy(agent, policy) — emits approval/policy.

New sessions are pinned (session/createdpinInitialPermission) to the current user default preset (defaultPreset), where genuinely fresh sessions gain the full bundle from a defaultSettings source registered with the settings seam (permission namespace).

The permission model in the tool pipeline

The decision point lives in the tools/pre-execute waterfall of ctx.tools (packages/core/tools/src/index.ts); the canonical ordering is documented in the tool-execution pipeline chapter of the official docs (source: docs/tool-execution-pipeline.md). Cross-referencing that pipeline:

text
tools/pre-execute waterfall   (hooks, permission, sandbox)
   → gate: allow | deny | ask
   → ask → ctx.approval one-shot prompt (absent/unanswerable → deny)
monotonic guards
tools/execute waterfall       (timeout, retry, metrics — around dispatch)
tool body
tools/post-execute waterfall

The relevant gate type is { kind: 'ask'; reason?: string }. A gate that returns ask is resolved by the tools service's private serviceAsk (packages/core/tools/src/index.ts), which consumes ctx.get('approval') opportunistically:

  • no approval service mounted → ask degenerates to deny (tool "…" requires approval (not yet supported));
  • no agent on the call → deny (… no agent to route it through);
  • otherwise it calls approval.request({ agent, toolName, callId, reason, signal }) and maps the outcome: allowed-once → allow; rejected → deny; cancelled → deny (with approvalCancelled); unavailable → deny.

Denied calls skip the tool body and flow into tools/post-execute, so a listener ordering cannot turn a denial back into permission.

The sandbox escalation path

The sandbox tool families (tool-fs, tool-bash) use the same approval seam for escalation. approveEscalation (packages/sandbox/sandbox/src/escalation.ts) is the shared choreography:

  1. strictly-wider check against the call's effective mode (WIDER_MODES: read-only → workspace-write → danger-full-access);
  2. require a mounted approval service and a caller agent (else fail closed with verbatim error text);
  3. call approval.request({ agent, toolName, callId, reason: 'escalate sandbox to <mode>: <justification>', signal });
  4. map the closed outcome to a granted mode for exactly this call.

The model-facing vocabulary is sandboxDenialMarker(mode) ([sandbox: file access denied under <mode> mode]) and escalationHintMarker(subject), plus validateEscalationArgs enforcing that sandbox_permissions and justification travel together.

The approval service itself

ApprovalService (packages/interaction/user-approval/src/index.ts) applies session policy before answerers and logs every ask/outcome pair:

  • Events: approval/request (waterfall, scope-filtered), plus log-only audit events approval/asked (id, toolName, callId, reason) and approval/decided (id, outcome).
  • Outcomes (ApprovalOutcome, types.ts): 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'. 'allowed-once' is the only grant — grants are one-shot, applying only to the requested action, never a standing session-wide allow.
  • Turn-enclosed audit: request() requires an open turn in the session log (turn/start not yet turn/end) because a bare event between turns is crash-tail garbage on reload. It appends approval/asked, resolves decide, then appends approval/decided.
  • 'never' muting is decided inside the service's request path, not by a listener, so a prepended listener can never bypass the documented "never → rejected deterministically."
  • Containment: a throwing answerer fails the question closed (resolves unavailable), never the calling tool; the AbortSignal races the answer, producing cancelled.

Session/global scope: the policy is per-session (the approval/policy event fold, or the configured default), while each grant is per-request (allowed-once). The audit events live on the requesting agent's session log, so resume replays the same ask/decide pair.

Approval & sandbox mode session events

EventPayloadMeaning
approval/asked{ id, toolName, callId?, reason? }A question was put to the answerer chain (audit)
approval/decided{ id, outcome }Exactly one per ask: decision, cancellation, or fail-closed
approval/policy{ policy, source? }Session approval-policy override (durable, replayable; source: 'delegation' seeds a child)
sandbox/mode{ mode, source? }Session sandbox-mode override (folded by sandbox-policy)
permission/preset{ preset }Selected preset (durable log-only intent)

The web surfaces

ui-permission-presets (packages/client/ui-permission-presets) is a surface plugin: its host apply() is a no-op; the browser half ships a new-session Settings row and a current-session command picker (the /permission command contributed by the service). It renders each preset via displayPermissionPreset(value, name), reserving a product label — "Full access" — for the danger-full-access preset (FULL_ACCESS_PRESET). The machine value the client submits is exactly the preset name the service resolves.

Further reading

  • Sandbox architecture: overview — the file-effect modes and the escalation ladder this approval model governs.
  • Filesystem observation & sandbox policy — how a granted wider mode is honored by the fs fence on the next call.
  • Guards: timeout & repeat reminders — the tools/execute/tools/post-execute pipes beside the pre-execute gate.
  • packages/interaction/user-approval/src/index.tsApprovalService, the outcomes, and the audit event pair.
  • packages/interaction/permission-presets/src/index.tsPermissionPresetService, the preset table, and the /permission command.
  • packages/core/tools/src/index.ts (search serviceAsk / PreToolDecision) — where the ask gate is turned into a decision.