index.ts 14 KB

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