1
/**2
* Service Definition for the approval capability seam, covering requests, cancellation, audit, and per-session policy. Missing3
* answerers fail closed; grants apply only to the requested action.4
* @module @deepseek-ai/dsh-user-approval5
*/7
import { randomUUID } from 'node:crypto'8
import { Context, Service } from '@deepseek-ai/cordis'9
import z from '@deepseek-ai/schemastery'10
import type { Agent } from '@deepseek-ai/dsh-agent'11
import { createUserMessage, type ToolCallId } from '@deepseek-ai/dsh-llm'12
import type { ContextFormed } from '@deepseek-ai/dsh-llm'13
declare module '@deepseek-ai/dsh-llm' {14
interface MessageSourceMap {15
'user-approval': { kind: 'user-approval' } & ContextFormed16
}17
}19
import { scopeTarget } from '@deepseek-ai/dsh-scope'20
import type { Session } from '@deepseek-ai/dsh-session'21
import { SessionSeq } from '@deepseek-ai/dsh-session'22
import type {} from '@deepseek-ai/dsh-system-prompt'24
declare module '@deepseek-ai/cordis' {25
interface Context {26
approval: ApprovalService27
}28
}30
declare module '@deepseek-ai/dsh-session/types' {31
interface SessionEventMap {32
/**33
* The session's approval policy was switched — log-only, durable,34
* replayable, never in the model transcript (the model learns the policy35
* from the runtime-context snapshot and live switch notices). The LAST36
* such event is the session's override.37
* `source: 'delegation'` marks an override seeded into a child; an absent38
* source is a runtime switch.39
*/40
'approval/policy': {41
policy: ApprovalPolicy42
/** Marks an override seeded into a child at delegation. */43
source?: 'delegation'44
}45
}46
}48
import { ApprovalRequestId } from './types.ts'49
import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts'51
export { ApprovalRequestId } from './types.ts'52
export type { ApprovalOutcome } from './types.ts'54
/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */55
const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']57
/**58
* A session's approval policy — what happens to an {@link ApprovalService}59
* ask BEFORE any interactive answerer sees it:60
*61
* - `'ask'` (the default) — delegate to the composed answerers; with none62
* composed the chain falls through to the fail-closed `'unavailable'`.63
* - `'never'` — never prompt anyone: every ask resolves `'rejected'`64
* deterministically. The strict headless stance (CI, unattended runs) and65
* the policy whose outcome is knowable without asking.66
*/67
export type ApprovalPolicy = 'ask' | 'never'69
/** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */70
export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']72
/** Model-facing statement for the deterministic `'never'` policy. */73
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`).'74
/** Model-facing statement for an interactive policy that may still fail closed. */75
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'77
/**78
* Whether the log currently sits inside an open turn (a `turn/start` not yet79
* closed by a `turn/end`) — the {@link ApprovalService.request} precondition.80
* The audit pair must be turn-enclosed: the turn is the durable log's81
* commit/replay boundary, so a bare event appended between turns is82
* indistinguishable from a crash tail and silently dropped on reload.83
*/84
function hasOpenTurn(session: Session): boolean {85
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {86
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.87
const type = session.eventAt(SessionSeq(seq))?.type88
if (type === 'turn/start') return true89
if (type === 'turn/end') return false90
}91
return false92
}94
/**95
* Append the sole durable representation of a session policy override. Invalid96
* values throw before the log changes; consumers fold the new value on each read.97
* @param session - the session the override belongs to.98
* @param policy - the policy in effect until the next switch.99
*/100
export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {101
if (!APPROVAL_POLICIES.includes(policy)) {102
throw new TypeError('approval policy must be one of "ask" or "never"')103
}104
session.append('approval/policy', { policy })105
}107
/**108
* Readonly same-process permission question. `callId` links to an already109
* presented tool call, so arguments are not duplicated here.110
*/111
export interface ApprovalRequest extends ApprovalRequestEvent {112
/**113
* The agent on whose behalf the question is asked. Routes the question (a114
* UI answerer only answers for agents it owns) and receives the audit115
* events on its session log.116
*/117
readonly agent: Agent118
/** The tool the question is about (presentation and audit). */119
readonly toolName: string120
/**121
* The exact tool call being decided, when the asker has one — lets a UI122
* attach the prompt to the tool call it already streamed.123
*/124
readonly callId?: ToolCallId125
/** The asker's human-readable explanation of WHY it is asking. */126
readonly reason?: string127
/**128
* Aborting withdraws the question: the request settles `'cancelled'`129
* immediately and a late answer from a still-pending answerer is discarded.130
*/131
readonly signal?: AbortSignal132
}134
/** Plugin config. All optional — `static Config` supplies the defaults. */135
export interface Config {136
/**137
* The deployment's default {@link ApprovalPolicy} for sessions without an138
* `approval/policy` override — `'ask'` delegates to the composed answerers139
* (fail-closed with none); `'never'` auto-rejects every ask without140
* prompting (the deterministic CI/unattended stance).141
*/142
readonly policy?: ApprovalPolicy143
}145
/**146
* Approval service that applies session policy before answerers and logs every147
* ask/outcome pair to the requesting session. It exposes deterministic policy148
* changes to the model through the runtime-context snapshot and switch notices.149
*/150
export class ApprovalService extends Service {151
static Config: z<Config> = z.object({152
policy: z.union(['ask', 'never'] as const).default('ask'),153
})155
constructor(ctx: Context, public config: Config) {156
super(ctx, 'approval')158
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)160
// The complete current value travels after retained history, so switching161
// policy does not rewrite the stable system-prompt cache prefix.162
ctx.inject(['systemPrompt'], (scope: Context) => {163
scope.systemPrompt.context({164
name: 'approval:policy',165
order: scope.systemPrompt.getContextOrder('APPROVAL_POLICY'),166
text: (context) => {167
const agent = context.agent168
// A bare assemble() (tests, diagnostics) has no session to state.169
if (agent === undefined) return ''170
const policy = effective(agent)171
return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE172
},173
})174
})175
}177
/**178
* Switch one live agent's policy and queue the transition for its next model179
* step. Session initialization uses {@link setApprovalPolicy} directly180
* because there is no previously visible policy to change.181
* @param agent - the live agent whose policy is changing.182
* @param policy - the new effective policy.183
*/184
setPolicy(agent: Agent, policy: ApprovalPolicy): void {185
const previous = this.effectivePolicy(agent.session)186
if (previous === policy) return187
setApprovalPolicy(agent.session, policy)188
agent.inject(createUserMessage({189
content: [{190
type: 'text',191
text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).`,192
}],193
source: { kind: 'user-approval' },194
}))195
}197
/**198
* Ask the composed answerers to decide one readonly same-process request.199
* The service borrows the request, agent, session, and live signal directly.200
* The request requires an open turn because the audit pair must be enclosed201
* by the durable log's commit/replay boundary; an idle ask rejects before202
* appending anything. The answerer phase always produces an outcome: an203
* aborted signal yields `'cancelled'`, a missing or throwing answerer yields204
* `'unavailable'` (fail closed), and a rogue non-vocabulary return value is205
* normalized to `'unavailable'`. A failure that prevents either audit append206
* from committing still rejects because returning an unlogged decision would207
* violate the pair. Session contains post-commit observer failures, so an208
* authoritative append cannot reject the request or suppress its matching209
* audit event.210
* @param req - the pending decision (agent, tool identity, reason, signal).211
* @returns the closed outcome; `'allowed-once'` is the only grant.212
* @throws when no turn is open or either audit event fails before the session213
* append commit point.214
*/215
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {216
const session = req.agent.session217
if (!hasOpenTurn(session)) {218
throw new Error(219
'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '220
+ 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '221
+ 'Ask from inside the turn that needs the decision.',222
)223
}224
const id = ApprovalRequestId(randomUUID())225
session.append('approval/asked', {226
id,227
toolName: req.toolName,228
...req.callId !== undefined ? { callId: req.callId } : {},229
...req.reason !== undefined ? { reason: req.reason } : {},230
})231
const outcome = await this.decide(req, session)232
session.append('approval/decided', { id, outcome })233
return outcome234
}236
/**237
* The session's effective policy: its own `approval/policy` fold, else the238
* configured default (the schema already defaulted an omitted policy to239
* `'ask'`; the `??` only narrows the optional-input TYPE).240
* @param session - the exact accepted session whose policy applies.241
* @returns the policy every ask for this session resolves under right now.242
*/243
private effectivePolicy(session: Session): ApprovalPolicy {244
return this.overrideOf(session) ?? this.config.policy ?? 'ask'245
}247
/**248
* Read the session override without applying the configured default.249
* @param session - session whose log supplies the override.250
* @returns the last logged policy, or `undefined` without one.251
*/252
overrideOf(session: Session): ApprovalPolicy | undefined {253
for (let seq = session.seq - 1; seq >= 0; seq -= 1) {254
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.255
const event = session.eventAt(SessionSeq(seq))256
if (event?.type === 'approval/policy') return event.data.policy257
}258
return undefined259
}261
/**262
* Dispatch the waterfall, contained and raced against the request signal.263
* @param req - the borrowed public request.264
* @param session - the request agent's session used for policy lookup.265
* @returns the normalized closed outcome.266
*/267
private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {268
const signal = req.signal269
if (signal?.aborted) return 'cancelled'270
// The 'never' policy is decided HERE, before any dispatch: a listener271
// registered with `prepend: true` after this service mounts would sit272
// ahead of any gate LISTENER, so a listener-shaped gate cannot keep the273
// documented promise that 'never' rejects deterministically regardless274
// of registration order — only the service's own request path can.275
if (this.effectivePolicy(session) === 'never') return 'rejected'276
// Enter the promise chain BEFORE dispatching: a listener that throws277
// SYNCHRONOUSLY (before its first await) must land in the same rejection278
// path as an async one — `Promise.resolve(call())` would let it escape279
// the containment into the caller.280
const answer: Promise<ApprovalOutcome> = Promise.resolve().then(281
() => this.ctx.waterfall(282
scopeTarget(req.agent, req.agent), 'approval/request', req,283
() => Promise.resolve<ApprovalOutcome>('unavailable'),284
),285
).then(286
// Normalize a rogue (non-vocabulary) answerer return to the fail-closed287
// outcome instead of leaking it into callers' closed-union switches.288
outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',289
// A throwing answerer must fail the QUESTION closed, not the caller's290
// tool call open — the seam contains its callbacks.291
() => 'unavailable',292
)293
if (signal === undefined) return answer294
return await new Promise<ApprovalOutcome>((resolve) => {295
const onAbort = () => {296
signal.removeEventListener('abort', onAbort)297
resolve('cancelled')298
}299
signal.addEventListener('abort', onAbort, { once: true })300
void answer.then((outcome) => {301
signal.removeEventListener('abort', onAbort)302
// After an abort won the race this resolve is a settled-promise no-op:303
// the late answer is discarded by construction.304
resolve(outcome)305
})306
})307
}308
}310
export default ApprovalService