mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The parent implementation introduced sandboxMode and approvalPolicy as generic SessionHeader fields, then propagated those fields through both persistence backends, session-query indexes, collision checks, policy-specific seed-boundary folds, catalogs, and a broad test matrix. That storage plane is unnecessary: Session already accepts a validated constructor seed, and persistence captures that seed when the session is announced before committing its first batch. Capture each parent override synchronously at delegation, append source-tagged sandbox/mode and approval/policy records after the optional fork prefix, and create the child with that combined seed. Keeping header.seedLength at the original fork-prefix length preserves lineage while ordinary last-event-wins folds make the inherited records outrank stale parent history and remain subordinate to later child switches. Unswitched parents still stamp nothing, so children continue to follow deployment defaults. Remove the generic header fields and every persistence/query/schema branch built around them. Collapse the inheritance suite from ten leaking scenarios to four owned-context cases covering real filesystem confinement, stale fork precedence, delegation-time capture, and the no-override path. The assembled headless snapshot now asserts the persisted inheritance event directly. This keeps the security behavior while restoring policy ownership to the existing event log and deleting the speculative durability machinery that the original tests did not exercise.
396 lines
17 KiB
TypeScript
396 lines
17 KiB
TypeScript
/**
|
|
* 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 { Agent } from '@deepseek-ai/dsh-agent'
|
|
import { createUserMessage, 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<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
|
|
}
|
|
}
|
|
|
|
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}).
|
|
* `source: 'delegation'` marks an override seeded into a child; an absent
|
|
* source is a runtime switch.
|
|
*/
|
|
'approval/policy': {
|
|
policy: ApprovalPolicy
|
|
/** Marks an override seeded into a child at delegation. */
|
|
source?: 'delegation'
|
|
}
|
|
}
|
|
}
|
|
|
|
import { ApprovalRequestId } from './types.ts'
|
|
import type { ApprovalOutcome } from './types.ts'
|
|
|
|
export { ApprovalRequestId } from './types.ts'
|
|
export type { ApprovalOutcome } from './types.ts'
|
|
|
|
/** 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: '<!-- dsh-user-approval-policy:ask -->',
|
|
never: '<!-- dsh-user-approval-policy:never -->',
|
|
} as const satisfies Record<ApprovalPolicy, string>
|
|
|
|
/**
|
|
* 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<Config> = 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. agent/step runs 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<Agent['session'], ApprovalPolicy>()
|
|
ctx.on('agent/step', (agent) => {
|
|
const session = agent.session
|
|
const events = session.events
|
|
let overrideIndex = -1
|
|
let overrideSource: 'delegation' | undefined
|
|
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
|
|
overrideSource = event.data.source
|
|
} 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 = overrideSource === 'delegation'
|
|
? 'inherited from the delegating session'
|
|
: overrideIndex > headerIndex ? 'changed by the user' : 'changed by the operator/config'
|
|
agent.inject(createUserMessage({
|
|
content: [{ 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<ApprovalOutcome> {
|
|
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 this.overrideOf(session) ?? this.config.policy ?? 'ask'
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
overrideOf(session: Session): ApprovalPolicy | undefined {
|
|
return effectiveApprovalPolicy(session.events)
|
|
}
|
|
|
|
/**
|
|
* 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<ApprovalOutcome> {
|
|
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<ApprovalOutcome> = Promise.resolve().then(
|
|
() => this.ctx.waterfall(
|
|
scopeTarget(this, req.agent), 'approval/request', req,
|
|
() => Promise.resolve<ApprovalOutcome>('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<ApprovalOutcome>((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
|