2026-07-09 15:25:18 +08:00
/**
2026-07-13 16:24:32 +08:00
* Approval request, cancellation, audit, and per-session policy seam. Missing
* answerers fail closed; grants apply only to the requested action.
2026-07-11 21:37:38 +08:00
* @module @deepseek-ai/dsh-user-approval
2026-07-09 15:25:18 +08:00
*/
import { randomUUID } from 'node:crypto'
import { Context , Service } from 'cordis'
2026-07-09 16:41:03 +08:00
import z from 'schemastery'
2026-08-02 00:49:32 +08:00
import type { Agent } from '@deepseek-ai/dsh-agent'
2026-07-28 13:55:59 +08:00
import { createUserMessage , type CallId } from '@deepseek-ai/dsh-llm'
2026-07-11 23:14:09 +08:00
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
2026-07-09 16:41:03 +08:00
import type { Session , SessionEvent } from '@deepseek-ai/dsh-session'
import type { } from '@deepseek-ai/dsh-system-prompt'
2026-07-09 15:25:18 +08:00
declare module 'cordis' {
interface Context {
approval : ApprovalService
}
interface Events {
/**
2026-07-13 16:24:32 +08:00
* 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.
2026-07-12 22:39:01 +08:00
* @param req - the pending decision (agent, tool identity, reason, signal).
2026-07-09 15:25:18 +08:00
* @mode waterfall
*/
2026-07-11 23:14:09 +08:00
'approval/request' ( this : Scoped < ApprovalService > , req : ApprovalRequest , next : ( ) = > Promise < ApprovalOutcome > ) : Promise < ApprovalOutcome >
2026-07-09 15:25:18 +08:00
}
}
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
}
2026-07-09 16:41:03 +08:00
/**
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
2026-08-02 00:49:32 +08:00
* from the runtime-context snapshot and live switch notices). The LAST
* such event is the session's override ({@link effectiveApprovalPolicy}).
2026-07-28 21:31:17 +08:00
* `source: 'delegation'` marks an override seeded into a child; an absent
* source is a runtime switch.
2026-07-09 16:41:03 +08:00
*/
2026-07-28 21:31:17 +08:00
'approval/policy' : {
policy : ApprovalPolicy
/** Marks an override seeded into a child at delegation. */
source ? : 'delegation'
}
2026-07-09 15:25:18 +08:00
}
}
2026-07-19 21:17:57 +08:00
import { ApprovalRequestId } from './types.ts'
import type { ApprovalOutcome } from './types.ts'
2026-07-09 15:25:18 +08:00
2026-07-19 21:17:57 +08:00
export { ApprovalRequestId } from './types.ts'
export type { ApprovalOutcome } from './types.ts'
2026-07-09 15:25:18 +08:00
/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
const OUTCOMES : readonly ApprovalOutcome [ ] = [ 'allowed-once' , 'rejected' , 'cancelled' , 'unavailable' ]
2026-07-09 16:41:03 +08:00
/**
* 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
2026-07-30 22:09:15 +08:00
* the policy whose outcome is knowable without asking.
2026-07-09 16:41:03 +08:00
*/
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' ]
2026-07-30 22:09:15 +08:00
/** Model-facing statement for the deterministic `'never'` policy. */
2026-07-09 16:41:03 +08:00
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`).'
2026-07-30 22:09:15 +08:00
/** Model-facing statement for an interactive policy that may still fail closed. */
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
2026-07-11 21:37:38 +08:00
2026-07-09 16:41:03 +08:00
/**
2026-07-28 21:31:17 +08:00
* 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.
2026-07-09 16:41:03 +08:00
* @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
}
2026-07-09 15:25:18 +08:00
/**
* 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
}
2026-07-09 16:41:03 +08:00
/**
2026-07-13 23:27:00 +08:00
* 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.
2026-07-09 16:41:03 +08:00
* @param session - the session the override belongs to.
2026-07-13 16:24:32 +08:00
* @param policy - the policy in effect until the next switch.
2026-07-09 16:41:03 +08:00
*/
export function setApprovalPolicy ( session : Session , policy : ApprovalPolicy ) : void {
2026-07-12 05:13:17 +08:00
if ( ! APPROVAL_POLICIES . includes ( policy ) ) {
throw new TypeError ( 'approval policy must be one of "ask" or "never"' )
}
2026-07-09 16:41:03 +08:00
session . append ( 'approval/policy' , { policy } )
}
2026-07-09 15:25:18 +08:00
/**
2026-07-13 16:24:32 +08:00
* Readonly same-process permission question. `callId` links to an already
* presented tool call, so arguments are not duplicated here.
2026-07-09 15:25:18 +08:00
*/
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.
*/
2026-07-12 22:39:01 +08:00
readonly agent : Agent
2026-07-09 15:25:18 +08:00
/** The tool the question is about (presentation and audit). */
2026-07-12 22:39:01 +08:00
readonly toolName : string
2026-07-09 15:25:18 +08:00
/**
* The exact tool call being decided, when the asker has one — lets a UI
* attach the prompt to the tool call it already streamed.
*/
2026-07-12 22:39:01 +08:00
readonly callId? : CallId
2026-07-09 15:25:18 +08:00
/** The asker's human-readable explanation of WHY it is asking. */
2026-07-12 22:39:01 +08:00
readonly reason? : string
2026-07-09 15:25:18 +08:00
/**
* Aborting withdraws the question: the request settles `'cancelled'`
* immediately and a late answer from a still-pending answerer is discarded.
*/
2026-07-12 22:39:01 +08:00
readonly signal? : AbortSignal
2026-07-12 05:13:17 +08:00
}
2026-07-09 16:41:03 +08:00
/** 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).
*/
2026-07-12 22:39:01 +08:00
readonly policy? : ApprovalPolicy
2026-07-09 16:41:03 +08:00
}
2026-07-09 15:25:18 +08:00
/**
2026-07-14 16:21:41 +08:00
* Approval service that applies session policy before answerers and logs every
* ask/outcome pair to the requesting session. It exposes deterministic policy
2026-08-02 00:49:32 +08:00
* changes to the model through the runtime-context snapshot and switch notices.
2026-07-09 15:25:18 +08:00
*/
export class ApprovalService extends Service {
2026-07-09 16:41:03 +08:00
static Config : z < Config > = z . object ( {
policy : z.union ( [ 'ask' , 'never' ] as const ) . default ( 'ask' ) ,
} )
constructor ( ctx : Context , public config : Config ) {
2026-07-09 15:25:18 +08:00
super ( ctx , 'approval' )
2026-07-09 16:41:03 +08:00
2026-07-12 05:13:17 +08:00
const effective = ( agent : Agent ) : ApprovalPolicy = > this . effectivePolicy ( agent . session )
2026-07-09 16:41:03 +08:00
2026-07-30 22:09:15 +08:00
// The complete current value travels after retained history, so switching
// policy does not rewrite the stable system-prompt cache prefix.
2026-07-09 16:41:03 +08:00
ctx . inject ( [ 'systemPrompt' ] , ( scope : Context ) = > {
2026-07-30 22:09:15 +08:00
scope . systemPrompt . context ( {
2026-07-09 16:41:03 +08:00
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 ''
2026-07-11 21:37:38 +08:00
const policy = effective ( agent )
2026-07-30 22:09:15 +08:00
return policy === 'never' ? NEVER_SENTENCE : ASK_SENTENCE
2026-07-09 16:41:03 +08:00
} ,
} )
} )
2026-08-02 00:49:32 +08:00
}
2026-07-09 16:41:03 +08:00
2026-08-02 00:49:32 +08:00
/**
* 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 {
const previous = this . effectivePolicy ( agent . session )
if ( previous === policy ) return
setApprovalPolicy ( agent . session , policy )
agent . inject ( createUserMessage ( {
content : [ {
type : 'text' ,
text : ` The approval policy changed from " ${ previous } " to " ${ policy } " (changed by the user). ` ,
} ] ,
source : { kind : 'plugin' , plugin : 'user-approval' } ,
} ) )
2026-07-09 15:25:18 +08:00
}
/**
2026-07-12 22:39:01 +08:00
* 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.
2026-07-09 15:25:18 +08:00
* @param req - the pending decision (agent, tool identity, reason, signal).
* @returns the closed outcome; `'allowed-once'` is the only grant.
2026-07-12 22:39:01 +08:00
* @throws when no turn is open or either audit event fails before the session
* append commit point.
2026-07-09 15:25:18 +08:00
*/
async request ( req : ApprovalRequest ) : Promise < ApprovalOutcome > {
2026-07-12 22:39:01 +08:00
const session = req . agent . session
if ( ! hasOpenTurn ( session . events ) ) {
2026-07-09 15:25:18 +08:00
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 ( ) )
2026-07-12 22:39:01 +08:00
session . append ( 'approval/asked' , {
2026-07-12 18:57:42 +08:00
id ,
2026-07-12 22:39:01 +08:00
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 } )
2026-07-09 15:25:18 +08:00
return outcome
}
2026-07-09 16:41:03 +08:00
/**
2026-07-28 21:31:17 +08:00
* 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).
2026-07-12 05:13:17 +08:00
* @param session - the exact accepted session whose policy applies.
* @returns the policy every ask for this session resolves under right now.
2026-07-09 16:41:03 +08:00
*/
2026-07-12 05:13:17 +08:00
private effectivePolicy ( session : Session ) : ApprovalPolicy {
2026-07-26 18:16:45 +08:00
return this . overrideOf ( session ) ? ? this . config . policy ? ? 'ask'
2026-07-09 16:41:03 +08:00
}
2026-07-25 04:06:19 +08:00
/**
2026-07-28 21:31:17 +08:00
* 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.
2026-07-25 10:51:25 +08:00
*/
overrideOf ( session : Session ) : ApprovalPolicy | undefined {
2026-07-28 21:31:17 +08:00
return effectiveApprovalPolicy ( session . events )
2026-07-25 04:06:19 +08:00
}
2026-07-12 05:13:17 +08:00
/**
2026-07-12 22:39:01 +08:00
* 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.
2026-07-12 05:13:17 +08:00
* @returns the normalized closed outcome.
*/
2026-07-12 22:39:01 +08:00
private async decide ( req : ApprovalRequest , session : Session ) : Promise < ApprovalOutcome > {
const signal = req . signal
if ( signal ? . aborted ) return 'cancelled'
2026-07-09 16:41:03 +08:00
// 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.
2026-07-12 05:13:17 +08:00
if ( this . effectivePolicy ( session ) === 'never' ) return 'rejected'
2026-07-09 15:25:18 +08:00
// 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 < ApprovalOutcome > = Promise . resolve ( ) . then (
2026-07-11 23:14:09 +08:00
( ) = > this . ctx . waterfall (
scopeTarget ( this , req . agent ) , 'approval/request' , req ,
( ) = > Promise . resolve < ApprovalOutcome > ( 'unavailable' ) ,
) ,
2026-07-09 15:25:18 +08:00
) . 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' ,
)
2026-07-12 22:39:01 +08:00
if ( signal === undefined ) return answer
2026-07-09 15:25:18 +08:00
return await new Promise < ApprovalOutcome > ( ( resolve ) = > {
2026-07-12 22:39:01 +08:00
const onAbort = ( ) = > {
signal . removeEventListener ( 'abort' , onAbort )
resolve ( 'cancelled' )
}
signal . addEventListener ( 'abort' , onAbort , { once : true } )
2026-07-09 15:25:18 +08:00
void answer . then ( ( outcome ) = > {
2026-07-12 22:39:01 +08:00
signal . removeEventListener ( 'abort' , onAbort )
2026-07-09 15:25:18 +08:00
// 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