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.
| Package | Role |
|---|---|
packages/interaction/permission-presets | ctx.permissionPresets: preset table, /permission command, permissions projection |
packages/interaction/user-approval | ctx.approval: request/cancel/decide, per-session policy, audit events |
packages/core/tools | ctx.tools: the pre-execute/execute/post-execute pipeline where approval ask is resolved |
packages/client/ui-permission-presets | Web surface: new-session Settings row + current-session command picker |
packages/guard/repeat-tool-reminder | Advisory 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 name | sandbox | approval |
|---|---|---|
workspace-write | workspace-write | ask |
danger-full-access | danger-full-access | never |
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/presetevent with the selected name (session.append);setSandboxMode(session, spec.sandbox)— emitssandbox/mode(@deepseek-ai/dsh-sandbox-policy);setApprovalPolicy/ctx.approval.setPolicy(agent, policy)— emitsapproval/policy.
New sessions are pinned (session/created → pinInitialPermission) 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:
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 waterfallThe 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 →
askdegenerates todeny(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(withapprovalCancelled);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:
- strictly-wider check against the call's effective mode (
WIDER_MODES:read-only → workspace-write → danger-full-access); - require a mounted approval service and a caller agent (else fail closed with verbatim error text);
- call
approval.request({ agent, toolName, callId, reason: 'escalate sandbox to <mode>: <justification>', signal }); - 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 eventsapproval/asked(id, toolName, callId, reason) andapproval/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/startnot yetturn/end) because a bare event between turns is crash-tail garbage on reload. It appendsapproval/asked, resolvesdecide, then appendsapproval/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; theAbortSignalraces the answer, producingcancelled.
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
| Event | Payload | Meaning |
|---|---|---|
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-executepipes beside the pre-execute gate. packages/interaction/user-approval/src/index.ts—ApprovalService, the outcomes, and the audit event pair.packages/interaction/permission-presets/src/index.ts—PermissionPresetService, the preset table, and the/permissioncommand.packages/core/tools/src/index.ts(searchserviceAsk/PreToolDecision) — where theaskgate is turned into a decision.