User Approval

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'`.
 * - `'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.

Cordis API

Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — this section is byte-identical in both language sides of the page. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.

ctx.approvalApprovalService

Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through the runtime-context snapshot and switch notices.

/**
 * Switch one live agent's policy and queue the transition for its next model
 * step. Session initialization uses {@link setApprovalPolicy} directly
 * because there is no previously visible policy to change.
 * @param agent - the live agent whose policy is changing.
 * @param policy - the new effective policy.
 */
setPolicy(agent: Agent, policy: ApprovalPolicy): void

/**
 * Ask the composed answerers to decide one readonly same-process request.
 * The service borrows the request, agent, session, and live signal directly.
 * The request requires an open turn because the audit pair must be enclosed
 * by the durable log's commit/replay boundary; an idle ask rejects before
 * appending anything. The answerer phase always produces an outcome: an
 * aborted signal yields `'cancelled'`, a missing or throwing answerer yields
 * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
 * normalized to `'unavailable'`. A failure that prevents either audit append
 * from committing still rejects because returning an unlogged decision would
 * violate the pair. Session contains post-commit observer failures, so an
 * authoritative append cannot reject the request or suppress its matching
 * audit event.
 * @param req - the pending decision (agent, tool identity, reason, signal).
 * @returns the closed outcome; `'allowed-once'` is the only grant.
 * @throws when no turn is open or either audit event fails before the session
 *   append commit point.
 */
async request(req: ApprovalRequest): Promise<ApprovalOutcome>

/**
 * Read the session override without applying the configured default.
 * @param session - session whose log supplies the override.
 * @returns the last logged policy, or `undefined` without one.
 */
overrideOf(session: Session): ApprovalPolicy | undefined

Types: Agent · Session

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

approval/* events

approval/request — waterfall

Ask composed answerers for one decision. Return an outcome to claim the request or call next(); failure yields the fail-closed default. Scope-filtered dispatch (@deepseek-ai/dsh-scope): agent-scoped listeners receive only that agent.

/**
 * Ask composed answerers for one decision. Return an outcome to claim the
 * request or call `next()`; failure yields the fail-closed default.
 * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
 * @param req - the pending decision (agent, tool identity, reason, signal).
 * @mode waterfall
 */
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>

Types: Scoped

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

用户审批

dsh-user-approval 的用户审批 seam 回答一个问题:这个具体操作是否可以继续?它拥有共享的请求/结果词汇、ctx.approval 分发服务、approval/request 应答者 waterfall(瀑布式事件)、仅记录日志的审计事件对,以及按会话的 ask/never 策略。UI 通道可以提供人类应答者;ACP(Agent Client Protocol)自动化桥接层为其拥有的 agent(智能体)提供一次性机器决策。调用方如 dsh-toolsdsh-tool-bash 消费闭合的结果,除非结果为 allowed-once,否则一律拒绝。

源码:packages/interaction/user-approval/src/index.ts

标识与结果

每个请求都会获得一个全新的 ApprovalRequestId。该品牌类型将 approval/askedapproval/decided 审计事件配对,同时不会让审批 id 与工具调用 id 或 agent/会话 id 互换。

/**
 * 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 是闭合的,且失败时拒绝。allowed-once 仅授权所询问的那一个操作;调用方对 rejectedcancelledunavailable 均执行拒绝。缺失、不负责该请求、抛异常或不合规的应答者会产生 unavailable,而非放行。

/**
 * 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'

按会话策略

ApprovalPolicy 决定在交互式应答者运行之前发生什么。ask 委托给组合的应答者链,链的无应答默认值为 unavailablenever 确定性地返回 rejected,不分发任何应答者。生效值为会话日志中最后一条 approval/policy 事件,回退到服务配置。setApprovalPolicy(session, policy) 是唯一的写入路径,因此回放能重建覆盖值。

/**
 * 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'`.
 * - `'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'

两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。带来源的 user/message 是持久化且模型可见的输入;审批状态变化时,会在保留的历史后追加一份新的完整快照,而不改写请求头中的系统提示词。

审批请求

ApprovalRequest 以足够精确的方式标识 agent 和工具操作,以便路由和审计该问题。它有意省略工具参数:应答者通过 callId 将提示附加到已流式输出的工具调用上,而非渲染另一份可能漂移的副本。

/**
 * 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
}

分发与审计

ctx.approval.request(req) 要求发起请求的会话处于一个尚未结束的轮次内。它追加 approval/asked,获取一个结果,追加对应的 approval/decided,然后以该结果完成。never 策略在服务内部、waterfall 分发之前强制执行,因此即使后来以 prepend 注册的应答者也无法绕过它。应答者在负责处理该请求时返回结果,否则调用 next() 委托;第一个应答占据唯一的决策槽位。

审计事件仅写入日志,不进入模型 transcript(文本记录)。模型可见的行为是调用方派生的工具结果与当前运行时上下文快照。服务 dispose(资源释放)时会移除其上下文贡献;应答者监听器独立地通过 effect 绑定到其所属插件。

Cordis API

Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — this section is byte-identical in both language sides of the page. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.

ctx.approvalApprovalService

Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through the runtime-context snapshot and switch notices.

/**
 * Switch one live agent's policy and queue the transition for its next model
 * step. Session initialization uses {@link setApprovalPolicy} directly
 * because there is no previously visible policy to change.
 * @param agent - the live agent whose policy is changing.
 * @param policy - the new effective policy.
 */
setPolicy(agent: Agent, policy: ApprovalPolicy): void

/**
 * Ask the composed answerers to decide one readonly same-process request.
 * The service borrows the request, agent, session, and live signal directly.
 * The request requires an open turn because the audit pair must be enclosed
 * by the durable log's commit/replay boundary; an idle ask rejects before
 * appending anything. The answerer phase always produces an outcome: an
 * aborted signal yields `'cancelled'`, a missing or throwing answerer yields
 * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
 * normalized to `'unavailable'`. A failure that prevents either audit append
 * from committing still rejects because returning an unlogged decision would
 * violate the pair. Session contains post-commit observer failures, so an
 * authoritative append cannot reject the request or suppress its matching
 * audit event.
 * @param req - the pending decision (agent, tool identity, reason, signal).
 * @returns the closed outcome; `'allowed-once'` is the only grant.
 * @throws when no turn is open or either audit event fails before the session
 *   append commit point.
 */
async request(req: ApprovalRequest): Promise<ApprovalOutcome>

/**
 * Read the session override without applying the configured default.
 * @param session - session whose log supplies the override.
 * @returns the last logged policy, or `undefined` without one.
 */
overrideOf(session: Session): ApprovalPolicy | undefined

Types: Agent · Session

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

approval/* events

approval/request — waterfall

Ask composed answerers for one decision. Return an outcome to claim the request or call next(); failure yields the fail-closed default. Scope-filtered dispatch (@deepseek-ai/dsh-scope): agent-scoped listeners receive only that agent.

/**
 * Ask composed answerers for one decision. Return an outcome to claim the
 * request or call `next()`; failure yields the fail-closed default.
 * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
 * @param req - the pending decision (agent, tool identity, reason, signal).
 * @mode waterfall
 */
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>

Types: Scoped

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