index.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. /**
  2. * Service Definition for the approval capability seam, covering requests, cancellation, audit, and per-session policy. Missing
  3. * answerers fail closed; grants apply only to the requested action.
  4. * @module @deepseek-ai/dsh-user-approval
  5. */
  6. import { randomUUID } from 'node:crypto'
  7. import { Context, Service } from '@deepseek-ai/cordis'
  8. import z from '@deepseek-ai/schemastery'
  9. import type { Agent } from '@deepseek-ai/dsh-agent'
  10. import { createUserMessage, type ToolCallId } from '@deepseek-ai/dsh-llm'
  11. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  12. import type { Session } from '@deepseek-ai/dsh-session'
  13. import { SessionSeq } from '@deepseek-ai/dsh-session'
  14. import type {} from '@deepseek-ai/dsh-system-prompt'
  15. declare module '@deepseek-ai/cordis' {
  16. interface Context {
  17. approval: ApprovalService
  18. }
  19. }
  20. declare module '@deepseek-ai/dsh-session/types' {
  21. interface SessionEventMap {
  22. /**
  23. * The session's approval policy was switched — log-only, durable,
  24. * replayable, never in the model transcript (the model learns the policy
  25. * from the runtime-context snapshot and live switch notices). The LAST
  26. * such event is the session's override.
  27. * `source: 'delegation'` marks an override seeded into a child; an absent
  28. * source is a runtime switch.
  29. */
  30. 'approval/policy': {
  31. policy: ApprovalPolicy
  32. /** Marks an override seeded into a child at delegation. */
  33. source?: 'delegation'
  34. }
  35. }
  36. }
  37. import { ApprovalRequestId } from './types.ts'
  38. import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts'
  39. export { ApprovalRequestId } from './types.ts'
  40. export type { ApprovalOutcome } from './types.ts'
  41. /** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
  42. const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
  43. /**
  44. * A session's approval policy — what happens to an {@link ApprovalService}
  45. * ask BEFORE any interactive answerer sees it:
  46. *
  47. * - `'ask'` (the default) — delegate to the composed answerers; with none
  48. * composed the chain falls through to the fail-closed `'unavailable'`.
  49. * - `'never'` — never prompt anyone: every ask resolves `'rejected'`
  50. * deterministically. The strict headless stance (CI, unattended runs) and
  51. * the policy whose outcome is knowable without asking.
  52. */
  53. export type ApprovalPolicy = 'ask' | 'never'
  54. /** Every {@link ApprovalPolicy}, for option advertisement and runtime validation of untrusted policy strings. */
  55. export const APPROVAL_POLICIES: readonly ApprovalPolicy[] = ['ask', 'never']
  56. /** Model-facing statement for the deterministic `'never'` policy. */
  57. 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`).'
  58. /** Model-facing statement for an interactive policy that may still fail closed. */
  59. const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
  60. /**
  61. * Whether the log currently sits inside an open turn (a `turn/start` not yet
  62. * closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
  63. * The audit pair must be turn-enclosed: the turn is the durable log's
  64. * commit/replay boundary, so a bare event appended between turns is
  65. * indistinguishable from a crash tail and silently dropped on reload.
  66. */
  67. function hasOpenTurn(session: Session): boolean {
  68. for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
  69. const type = session.eventAt(SessionSeq(seq))?.type
  70. if (type === 'turn/start') return true
  71. if (type === 'turn/end') return false
  72. }
  73. return false
  74. }
  75. /**
  76. * Append the sole durable representation of a session policy override. Invalid
  77. * values throw before the log changes; consumers fold the new value on each read.
  78. * @param session - the session the override belongs to.
  79. * @param policy - the policy in effect until the next switch.
  80. */
  81. export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): void {
  82. if (!APPROVAL_POLICIES.includes(policy)) {
  83. throw new TypeError('approval policy must be one of "ask" or "never"')
  84. }
  85. session.append('approval/policy', { policy })
  86. }
  87. /**
  88. * Readonly same-process permission question. `callId` links to an already
  89. * presented tool call, so arguments are not duplicated here.
  90. */
  91. export interface ApprovalRequest extends ApprovalRequestEvent {
  92. /**
  93. * The agent on whose behalf the question is asked. Routes the question (a
  94. * UI answerer only answers for agents it owns) and receives the audit
  95. * events on its session log.
  96. */
  97. readonly agent: Agent
  98. /** The tool the question is about (presentation and audit). */
  99. readonly toolName: string
  100. /**
  101. * The exact tool call being decided, when the asker has one — lets a UI
  102. * attach the prompt to the tool call it already streamed.
  103. */
  104. readonly callId?: ToolCallId
  105. /** The asker's human-readable explanation of WHY it is asking. */
  106. readonly reason?: string
  107. /**
  108. * Aborting withdraws the question: the request settles `'cancelled'`
  109. * immediately and a late answer from a still-pending answerer is discarded.
  110. */
  111. readonly signal?: AbortSignal
  112. }
  113. /** Plugin config. All optional — `static Config` supplies the defaults. */
  114. export interface Config {
  115. /**
  116. * The deployment's default {@link ApprovalPolicy} for sessions without an
  117. * `approval/policy` override — `'ask'` delegates to the composed answerers
  118. * (fail-closed with none); `'never'` auto-rejects every ask without
  119. * prompting (the deterministic CI/unattended stance).
  120. */
  121. readonly policy?: ApprovalPolicy
  122. }
  123. /**
  124. * Approval service that applies session policy before answerers and logs every
  125. * ask/outcome pair to the requesting session. It exposes deterministic policy
  126. * changes to the model through the runtime-context snapshot and switch notices.
  127. */
  128. export class ApprovalService extends Service {
  129. static Config: z<Config> = z.object({
  130. policy: z.union(['ask', 'never'] as const).default('ask'),
  131. })
  132. constructor(ctx: Context, public config: Config) {
  133. super(ctx, 'approval')
  134. const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
  135. // The complete current value travels after retained history, so switching
  136. // policy does not rewrite the stable system-prompt cache prefix.
  137. ctx.inject(['systemPrompt'], (scope: Context) => {
  138. scope.systemPrompt.context({
  139. name: 'approval:policy',
  140. order: scope.systemPrompt.getContextOrder('APPROVAL_POLICY'),
  141. text: (context) => {
  142. const agent = context.agent
  143. // A bare assemble() (tests, diagnostics) has no session to state.
  144. if (agent === undefined) return ''
  145. const policy = effective(agent)
  146. return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE
  147. },
  148. })
  149. })
  150. }
  151. /**
  152. * Switch one live agent's policy and queue the transition for its next model
  153. * step. Session initialization uses {@link setApprovalPolicy} directly
  154. * because there is no previously visible policy to change.
  155. * @param agent - the live agent whose policy is changing.
  156. * @param policy - the new effective policy.
  157. */
  158. setPolicy(agent: Agent, policy: ApprovalPolicy): void {
  159. const previous = this.effectivePolicy(agent.session)
  160. if (previous === policy) return
  161. setApprovalPolicy(agent.session, policy)
  162. agent.inject(createUserMessage({
  163. content: [{
  164. type: 'text',
  165. text: `The approval policy changed from "${previous}" to "${policy}" (changed by the user).`,
  166. }],
  167. source: { kind: 'plugin', plugin: 'user-approval' },
  168. }))
  169. }
  170. /**
  171. * Ask the composed answerers to decide one readonly same-process request.
  172. * The service borrows the request, agent, session, and live signal directly.
  173. * The request requires an open turn because the audit pair must be enclosed
  174. * by the durable log's commit/replay boundary; an idle ask rejects before
  175. * appending anything. The answerer phase always produces an outcome: an
  176. * aborted signal yields `'cancelled'`, a missing or throwing answerer yields
  177. * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is
  178. * normalized to `'unavailable'`. A failure that prevents either audit append
  179. * from committing still rejects because returning an unlogged decision would
  180. * violate the pair. Session contains post-commit observer failures, so an
  181. * authoritative append cannot reject the request or suppress its matching
  182. * audit event.
  183. * @param req - the pending decision (agent, tool identity, reason, signal).
  184. * @returns the closed outcome; `'allowed-once'` is the only grant.
  185. * @throws when no turn is open or either audit event fails before the session
  186. * append commit point.
  187. */
  188. async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
  189. const session = req.agent.session
  190. if (!hasOpenTurn(session)) {
  191. throw new Error(
  192. 'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
  193. + 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
  194. + 'Ask from inside the turn that needs the decision.',
  195. )
  196. }
  197. const id = ApprovalRequestId(randomUUID())
  198. session.append('approval/asked', {
  199. id,
  200. toolName: req.toolName,
  201. ...req.callId !== undefined ? { callId: req.callId } : {},
  202. ...req.reason !== undefined ? { reason: req.reason } : {},
  203. })
  204. const outcome = await this.decide(req, session)
  205. session.append('approval/decided', { id, outcome })
  206. return outcome
  207. }
  208. /**
  209. * The session's effective policy: its own `approval/policy` fold, else the
  210. * configured default (the schema already defaulted an omitted policy to
  211. * `'ask'`; the `??` only narrows the optional-input TYPE).
  212. * @param session - the exact accepted session whose policy applies.
  213. * @returns the policy every ask for this session resolves under right now.
  214. */
  215. private effectivePolicy(session: Session): ApprovalPolicy {
  216. return this.overrideOf(session) ?? this.config.policy ?? 'ask'
  217. }
  218. /**
  219. * Read the session override without applying the configured default.
  220. * @param session - session whose log supplies the override.
  221. * @returns the last logged policy, or `undefined` without one.
  222. */
  223. overrideOf(session: Session): ApprovalPolicy | undefined {
  224. for (let seq = session.seq - 1; seq >= 0; seq -= 1) {
  225. const event = session.eventAt(SessionSeq(seq))
  226. if (event?.type === 'approval/policy') return event.data.policy
  227. }
  228. return undefined
  229. }
  230. /**
  231. * Dispatch the waterfall, contained and raced against the request signal.
  232. * @param req - the borrowed public request.
  233. * @param session - the request agent's session used for policy lookup.
  234. * @returns the normalized closed outcome.
  235. */
  236. private async decide(req: ApprovalRequest, session: Session): Promise<ApprovalOutcome> {
  237. const signal = req.signal
  238. if (signal?.aborted) return 'cancelled'
  239. // The 'never' policy is decided HERE, before any dispatch: a listener
  240. // registered with `prepend: true` after this service mounts would sit
  241. // ahead of any gate LISTENER, so a listener-shaped gate cannot keep the
  242. // documented promise that 'never' rejects deterministically regardless
  243. // of registration order — only the service's own request path can.
  244. if (this.effectivePolicy(session) === 'never') return 'rejected'
  245. // Enter the promise chain BEFORE dispatching: a listener that throws
  246. // SYNCHRONOUSLY (before its first await) must land in the same rejection
  247. // path as an async one — `Promise.resolve(call())` would let it escape
  248. // the containment into the caller.
  249. const answer: Promise<ApprovalOutcome> = Promise.resolve().then(
  250. () => this.ctx.waterfall(
  251. scopeTarget(req.agent, req.agent), 'approval/request', req,
  252. () => Promise.resolve<ApprovalOutcome>('unavailable'),
  253. ),
  254. ).then(
  255. // Normalize a rogue (non-vocabulary) answerer return to the fail-closed
  256. // outcome instead of leaking it into callers' closed-union switches.
  257. outcome => OUTCOMES.includes(outcome) ? outcome : 'unavailable',
  258. // A throwing answerer must fail the QUESTION closed, not the caller's
  259. // tool call open — the seam contains its callbacks.
  260. () => 'unavailable',
  261. )
  262. if (signal === undefined) return answer
  263. return await new Promise<ApprovalOutcome>((resolve) => {
  264. const onAbort = () => {
  265. signal.removeEventListener('abort', onAbort)
  266. resolve('cancelled')
  267. }
  268. signal.addEventListener('abort', onAbort, { once: true })
  269. void answer.then((outcome) => {
  270. signal.removeEventListener('abort', onAbort)
  271. // After an abort won the race this resolve is a settled-promise no-op:
  272. // the late answer is discarded by construction.
  273. resolve(outcome)
  274. })
  275. })
  276. }
  277. }
  278. export default ApprovalService