Files
deepseek-harness/packages/ui/user-approval

@deepseek-ai/dsh-user-approval

User-approval seam. Owns the ctx.approval service (ApprovalService) and the one-shot permission vocabulary the harness shares: ApprovalRequest (agent + tool identity + reason + abort signal), the closed ApprovalOutcome union (allowed-once / rejected / cancelled / unavailable), the ApprovalRequestId brand pairing the two log-only audit events (approval/asked / approval/decided), and the approval/request waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.

The contract in one line: ctx.approval.request(req) puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its decision phase always resolves to an outcome: an aborted signal yields cancelled, a throwing or missing answerer yields unavailable, and allowed-once is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service reads the request fields and agent.session binding once, requires object agent/session identities, a string toolName, optional string callId/reason, and an AbortSignal-shaped live capability, then shallow-freezes a detached request record while preserving the exact agent and signal identities. A malformed request rejects before any audit append; later caller mutation cannot redirect scope, payload, cancellation, policy lookup, or either audit event. The other precondition is an open turn on the captured session — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask also rejects before appending. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair.

The service is the mechanism, answerers are the policy. Answerers are approval/request waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling next(), or delegate an agent you don't recognize by calling next() — the chain's built-in default is unavailable, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by req.agent: a listener registered through agent.ctx receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use prepend listeners only for decide-or-delegate gates.

The seam also owns the per-session POLICY tier (the sandbox RFC § Per-session mode switching): ApprovalPolicy is 'ask' (delegate to the answerers) or 'never' (deterministically reject without prompting anyone; the strict CI/unattended stance), with effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy — the session log is the store, written only through setApprovalPolicy(session, policy), which rejects any value outside that closed vocabulary before appending. The service decides 'never' inside request() itself, before dispatching the waterfall ('never''rejected' with the audit pair still landing; no listener registration, including a later prepend, can precede it), states 'never' — and only 'never' in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced agent/pre-step notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last request/header* reads changed by the user, otherwise changed by the operator/config).

One seam serves both ask paths of the sandbox RFC: the tools/pre-execute ask decision (routed by @deepseek-ai/dsh-tools when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's sandbox_permissions gate in @deepseek-ai/dsh-tool-bashthe sandbox RFC § Escalation). The full design: the approval-seam RFC.

Answerers today: the ACP bridge (@deepseek-ai/dsh-acp) forwards to the editor's session/request_permission prompt for agents it owns. The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome.