Files
deepseek-harness/docs/core-data-structures/approval.md
T
Tianyi Cui 3fc35c91ff refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and
tui join packages/interaction/, app-boot becomes packages/boot/, and
jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half
beside client/protocol/create-sdk/helper/scripts/telemetry, whose
folders drop the legacy sdk- prefix. Three new group README triplets
replace the ui/ and sdk/ ones; tsconfig references/paths/globs,
knip keys, vitest globs, gate scripts, catalogs, docs, and the
lockfile follow. Adds the four settled FIXME rename markers
(dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts).

The scaffold folders diverge from their npm names until those renames
land, so tsconfig.base.json maps the three affected names explicitly
beside the group wildcard. Also repairs two pre-existing stale-path
classes the strengthened sweep surfaced: docs/web-styling.md's retired
web-ui host package and type-model spec fixture-literal joins.

app-boot's three Loader-composition specs time out at the default 5s
under full-suite parallel load on this filesystem (pre-existing;
pass isolated with --testTimeout=30000); interaction/scaffold/boot
suites otherwise green (687 passed).
2026-08-09 01:21:12 +08:00

5.2 KiB

User Approval

English | 中文

The user-approval seam of dsh-user-approval answers one question: may this specific action proceed? It owns the shared request/outcome vocabulary, the ctx.approval dispatch service, the approval/request answerer waterfall, the log-only audit pair, and the per-session ask/never policy. UI channels may provide human answerers; the ACP automation bridge provides one-shot machine decisions for its own agents. Callers such as dsh-tools and dsh-tool-bash consume the closed outcome and fail closed unless it is allowed-once.

Source: packages/interaction/user-approval/src/index.ts

Identity and outcome

Every request receives a fresh ApprovalRequestId. The brand pairs the approval/asked and approval/decided audit events without making approval ids interchangeable with tool-call or agent/session ids.

/**
 * Pairs one `approval/asked` audit event with its `approval/decided`.
 * Service-issued (one fresh id per {@link ApprovalService.request} call).
 */
type ApprovalRequestId = Branded<'ApprovalRequestId'>

ApprovalOutcome is closed and fail-closed. allowed-once grants only the asked-about action; callers deny on rejected, cancelled, and unavailable. A missing, non-owning, throwing, or non-conforming answerer becomes unavailable rather than opening the gate.

/**
 * Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn
 * request, or unavailable answerer. Callers fail closed on `unavailable`.
 */
type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'

Per-session policy

ApprovalPolicy determines what happens before interactive answerers run. ask delegates to the composed answerer chain, whose no-answer default is unavailable; never deterministically returns rejected without dispatching any answerer. The effective value is the last approval/policy event in the session log, falling back to the service config. setApprovalPolicy(session, policy) is the single write path, so replay reconstructs the override.

/**
 * A session's approval policy — what happens to an {@link ApprovalService}
 * ask BEFORE any interactive answerer sees it:
 *
 * - `'ask'` (the default) — delegate to the composed answerers; with none
 *   composed the chain falls through to the fail-closed `'unavailable'`
 *   (exactly today's behavior).
 * - `'never'` — never prompt anyone: every ask resolves `'rejected'`
 *   deterministically. The strict headless stance (CI, unattended runs) and
 *   the policy whose outcome is knowable without asking.
 */
type ApprovalPolicy = 'ask' | 'never'

Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot. The sourced user/message is the durable model-visible input; changing approval state appends a new full snapshot after retained history without rewriting the request header's system prompt.

Approval request

ApprovalRequest identifies the agent and tool action closely enough to route and audit the question. It deliberately omits tool arguments: an answerer attaches the prompt to the already-streamed tool call through callId instead of rendering a second copy that could drift.

/**
 * Readonly same-process permission question. `callId` links to an already
 * presented tool call, so arguments are not duplicated here.
 */
interface ApprovalRequest {
  /**
   * The agent on whose behalf the question is asked. Routes the question (a
   * UI answerer only answers for agents it owns) and receives the audit
   * events on its session log.
   */
  readonly agent: Agent
  /** The tool the question is about (presentation and audit). */
  readonly toolName: string
  /**
   * The exact tool call being decided, when the asker has one — lets a UI
   * attach the prompt to the tool call it already streamed.
   */
  readonly callId?: CallId
  /** The asker's human-readable explanation of WHY it is asking. */
  readonly reason?: string
  /**
   * Aborting withdraws the question: the request settles `'cancelled'`
   * immediately and a late answer from a still-pending answerer is discarded.
   */
  readonly signal?: AbortSignal
}

Dispatch and audit

ctx.approval.request(req) requires the requesting session to be inside an open turn. It appends approval/asked, obtains one outcome, appends the matching approval/decided, and resolves with that outcome. The never policy is enforced inside the service before waterfall dispatch, so even an answerer registered later with prepend cannot bypass it. Answerers return an outcome when they own the request or call next() to delegate; the first answer occupies the single decision slot.

The audit events are log-only and do not enter the model transcript. Model-visible behavior is the caller's derived tool result plus the current runtime-context snapshot. Service disposal removes its context contribution; answerer listeners are independently effect-bound to their owning plugins.