/** * Approval request, cancellation, audit, and per-session policy seam. Missing * answerers fail closed; grants apply only to the requested action. * @module @deepseek-ai/dsh-user-approval */ import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import z from 'schemastery' import type { Branded } from '@deepseek-ai/dsh-brand' import type { Agent } from '@deepseek-ai/dsh-agent' import type { CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' declare module 'cordis' { interface Context { approval: ApprovalService } interface Events { /** * 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, req: ApprovalRequest, next: () => Promise): Promise } } declare module '@deepseek-ai/dsh-session' { interface SessionEventMap { /** * An approval question was put to the answerer chain — log-only audit * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs * it with the `approval/decided` that always follows; `toolName` is the * tool the question is about, `callId` the exact tool call when the asker * had one, `reason` the asker's human-readable explanation (e.g. a hook's * permission-decision reason). */ 'approval/asked': { id: ApprovalRequestId toolName: string callId?: CallId reason?: string } /** * The outcome of a prior `approval/asked` (same `id`) — log-only audit. * Exactly one per ask, appended when the outcome is known: a decision, a * cancellation, or the fail-closed `'unavailable'`. */ 'approval/decided': { id: ApprovalRequestId outcome: ApprovalOutcome } /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy * from the prompt section and the narrator's notices). The LAST such * event is the session's override ({@link effectiveApprovalPolicy}); * who asked for it is derivable from position (an event after the log's * last `request/header` was a runtime switch by the user). */ 'approval/policy': { policy: ApprovalPolicy } } } /** * Pairs one `approval/asked` audit event with its `approval/decided`. * Service-issued (one fresh id per {@link ApprovalService.request} call). */ export type ApprovalRequestId = Branded<'ApprovalRequestId'> /** * Brand a string as an {@link ApprovalRequestId}. * @param id - the raw id string to brand. * @returns the same string carrying the brand. */ export function ApprovalRequestId(id: string): ApprovalRequestId { return id as ApprovalRequestId } /** * Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn * request, or unavailable answerer. Callers fail closed on `unavailable`. */ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' /** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */ const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable'] /** * 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 only policy value stated in the system prompt — unlike `'ask'`, its * outcome is knowable without asking, so stating it cannot overclaim. */ export type ApprovalPolicy = 'ask' | 'never' /** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */ export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never'] /** * The prompt sentence stating a `'never'` policy — visibility for the one * deterministic policy (see {@link ApprovalPolicy}). Narrator persistence * does NOT parse this prose: deployments can quote it in a persona or another * section, so the section also emits a source-owned marker. */ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).' /** Source-owned prompt markers used to reconstruct the policy in a logged header. */ const POLICY_MARKERS = { ask: '', never: '', } as const satisfies Record /** * Read the policy fact emitted by this service from a logged system prompt. * The section is ordered after deployment persona text, and the last marker * wins so a persona quoting an earlier marker cannot shadow the service's own * contribution. Ordinary policy prose is deliberately ignored. */ function toldApprovalPolicy(system: string | undefined): ApprovalPolicy | undefined { if (system === undefined) return undefined const ask = system.lastIndexOf(POLICY_MARKERS.ask) const never = system.lastIndexOf(POLICY_MARKERS.never) if (ask < 0 && never < 0) return undefined return never > ask ? 'never' : 'ask' } /** * The session's approval-policy override: the last `approval/policy` event in * the log, or undefined when the session never switched (callers apply the * plugin's configured default). The pure fold — resume needs no catch-up * machinery because replaying the log IS the state. * @param events - session events in log order (other event types are skipped). * @returns the policy of the last switch event, or undefined without one. */ export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined { for (let index = events.length - 1; index >= 0; index -= 1) { const event = events[index] as SessionEvent if (event.type === 'approval/policy') return event.data.policy } return undefined } /** * Whether the log currently sits inside an open turn (a `turn/start` not yet * closed by a `turn/end`) — the {@link ApprovalService.request} precondition. * The audit pair must be turn-enclosed: the turn is the durable log's * commit/replay boundary, so a bare event appended between turns is * indistinguishable from a crash tail and silently dropped on reload. */ function hasOpenTurn(events: readonly SessionEvent[]): boolean { for (let index = events.length - 1; index >= 0; index -= 1) { const type = (events[index] as SessionEvent).type if (type === 'turn/start') return true if (type === 'turn/end') return false } return false } /** * Append the sole durable representation of a session policy override. Invalid * values throw before the log changes; consumers fold the new value on each read. * @param session - the session the override belongs to. * @param policy - the policy in effect until the next switch. */ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void { if (!APPROVAL_POLICIES.includes(policy)) { throw new TypeError('approval policy must be one of "ask" or "never"') } session.append('approval/policy', { policy }) } /** * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ export 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 } /** Plugin config. All optional — `static Config` supplies the defaults. */ export interface Config { /** * The deployment's default {@link ApprovalPolicy} for sessions without an * `approval/policy` override — `'ask'` delegates to the composed answerers * (fail-closed with none); `'never'` auto-rejects every ask without * prompting (the deterministic CI/unattended stance). */ readonly policy?: ApprovalPolicy } /** * 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 prompt and pre-step notices. */ export class ApprovalService extends Service { static Config: z = z.object({ policy: z.union(['ask', 'never'] as const).default('ask'), }) constructor(ctx: Context, public config: Config) { super(ctx, 'approval') const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session) // State only deterministic policy; a marker records the otherwise silent state. ctx.inject(['systemPrompt'], (scope: Context) => { scope.systemPrompt.section({ name: 'approval:policy', order: 115, text: (context) => { const agent = context.agent // A bare assemble() (tests, diagnostics) has no session to state. if (agent === undefined) return '' const policy = effective(agent) return policy === 'never' ? `${NEVER_SENTENCE}\n${POLICY_MARKERS.never}` : POLICY_MARKERS.ask }, }) }) // Visibility layer 2: the boundary narrator. pre-step runs after prompt // assembly but before the request history is derived, so the notice is // seen by THIS step's request: idle-time flip-flops coalesce at the // turn's first step (net-zero → nothing), and a mid-turn switch is // narrated no later than the next step. What each session was last told // is in-memory with a log-derived fallback (the folded header's system // text), so restarts lose nothing. Attribution is positional: an // override event after the log's last `request/header` was a runtime // switch by the user; otherwise the configured default moved under the // session (operator/config). const narrated = new WeakMap() ctx.on('agent/pre-step', (agent) => { const session = agent.session const events = session.events let overrideIndex = -1 let headerIndex = -1 for (let index = events.length - 1; index >= 0 && (overrideIndex < 0 || headerIndex < 0); index -= 1) { const event = events[index] as (typeof events)[number] if (overrideIndex < 0 && event.type === 'approval/policy') { overrideIndex = index } else if (headerIndex < 0 && event.type === 'request/header') { headerIndex = index } } // Same fold effectivePolicy performs — override is scanned here anyway // for POSITIONAL attribution; the default lives once, in the method. const current = this.effectivePolicy(session) const header = session.requestHeader() const told = narrated.get(session) ?? toldApprovalPolicy(header?.system) narrated.set(session, current) // Cold start (nothing ever told) narrates nothing — the section about // to go out states the truth, and there is no delta to explain. if (told === undefined || told === current) return const cause = overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config' agent.inject( [{ type: 'text', text: `The approval policy changed from "${told}" to "${current}" (${cause}).` }], { source: { kind: 'plugin', plugin: 'user-approval' } }, ) }) } /** * 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 { const session = req.agent.session if (!hasOpenTurn(session.events)) { throw new Error( 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair ' + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). ' + 'Ask from inside the turn that needs the decision.', ) } const id = ApprovalRequestId(randomUUID()) session.append('approval/asked', { id, toolName: req.toolName, ...req.callId !== undefined ? { callId: req.callId } : {}, ...req.reason !== undefined ? { reason: req.reason } : {}, }) const outcome = await this.decide(req, session) session.append('approval/decided', { id, outcome }) return outcome } /** * The session's effective policy: its own `approval/policy` fold, else the * configured default (the schema already defaulted an omitted policy to * `'ask'`; the `??` only narrows the optional-input TYPE). * @param session - the exact accepted session whose policy applies. * @returns the policy every ask for this session resolves under right now. */ private effectivePolicy(session: Session): ApprovalPolicy { return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask' } /** * Dispatch the waterfall, contained and raced against the request signal. * @param req - the borrowed public request. * @param session - the request agent's session used for policy lookup. * @returns the normalized closed outcome. */ private async decide(req: ApprovalRequest, session: Session): Promise { const signal = req.signal if (signal?.aborted) return 'cancelled' // The 'never' policy is decided HERE, before any dispatch: a listener // registered with `prepend: true` after this service mounts would sit // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the // documented promise that 'never' rejects deterministically regardless // of registration order — only the service's own request path can. if (this.effectivePolicy(session) === 'never') return 'rejected' // Enter the promise chain BEFORE dispatching: a listener that throws // SYNCHRONOUSLY (before its first await) must land in the same rejection // path as an async one — `Promise.resolve(call())` would let it escape // the containment into the caller. const answer: Promise = Promise.resolve().then( () => this.ctx.waterfall( scopeTarget(this, req.agent), 'approval/request', req, () => Promise.resolve('unavailable'), ), ).then( // Normalize a rogue (non-vocabulary) answerer return to the fail-closed // outcome instead of leaking it into callers' closed-union switches. outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable', // A throwing answerer must fail the QUESTION closed, not the caller's // tool call open — the seam contains its callbacks. () => 'unavailable', ) if (signal === undefined) return answer return await new Promise((resolve) => { const onAbort = () => { signal.removeEventListener('abort', onAbort) resolve('cancelled') } signal.addEventListener('abort', onAbort, { once: true }) void answer.then((outcome) => { signal.removeEventListener('abort', onAbort) // After an abort won the race this resolve is a settled-promise no-op: // the late answer is discarded by construction. resolve(outcome) }) }) } } export default ApprovalService