fix(scope): harden final ownership boundaries

This commit is contained in:
Tianyi Cui
2026-07-12 05:13:17 +08:00
parent 36b8370027
commit a9cb70d896
52 changed files with 2839 additions and 514 deletions

View File

@@ -1010,7 +1010,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
* quiescence"): for each session settle any pending prompt `cancelled`, then
* run that session's {@link AgentHandle} `dispose()` — which stops the loop
* (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
* final `turn/end` + `session/flush` are captured while `onAppend` is still
* final `turn/end` + `session/flush` are captured while the store-owned append observer is still
* attached), unregisters the agent, and removes its session from the store.
* The per-session disposes run in parallel. Idempotent — clears the `sessions`
* map first and memoizes, so a second call (close racing dispose) is a no-op.

View File

@@ -159,8 +159,8 @@ describe('acp bridge — disposal & HMR safety', () => {
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
// through the still-attached `session.onAppend` → `session/event`), and only
// THEN detach onAppend + remove the session. If the order were inverted
// through the still-attached store observer → `session/event`), and only
// THEN detach that observer + remove the session. If the order were inverted
// (detach first), the closing events would never reach persistence. Drive a
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
// persisted log from disk and assert the closing turn/end is on disk — the
@@ -190,7 +190,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
// still open when teardown runs: the composite agent effect stops the loop,
// the loop unwinds and appends `turn/end {disposed}` + runs its final
// `session/flush` — all while `onAppend` is still attached (the session
// `session/flush` — all while the store-owned append observer is still attached (the session
// detach is the LAST disposer in the same effect's LIFO chain) — and only
// THEN is the session detached. If the order were inverted (or the session
// were a racing SIBLING effect), the abort-produced `turn/end` would never
@@ -255,7 +255,7 @@ describe('acp bridge — disposal & HMR safety', () => {
// into ONE composite effect whose disposers run as a `.then()` chain. The
// register disposer emits `agent/disposed`; if a listener throws and the
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
// disposer — stranding the session in the store with `onAppend` attached (a
// disposer — stranding the session in the store with its append observer attached (a
// leak AND a durability hole, since the new design relies on detach
// running). The emit must be contained. Register a throwing listener, drive
// a clean turn, dispose, and assert the session was STILL removed.

View File

@@ -2,11 +2,11 @@
User-approval seam. Owns the `ctx.approval` service ([`ApprovalService`](src/index.ts)) and the one-shot permission vocabulary the harness shares: `ApprovalRequest` (agent + tool identity + reason + abort signal), the closed `ApprovalOutcome` union (`allowed-once` / `rejected` / `cancelled` / `unavailable`), the `ApprovalRequestId` brand pairing the two log-only audit events (`approval/asked` / `approval/decided`), and the `approval/request` waterfall the answerers listen on. It lives in the UI group because its purpose is human permission, while remaining channel-neutral: it depends only on Cordis and core vocabulary packages, never on a concrete UI.
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and always resolves to an outcome, never rejects: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service shallow-freezes a detached request record before dispatch, preserving the exact `agent` and `AbortSignal` identities while making later caller mutation unable to redirect scope, payload, cancellation, or either audit event. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair. The one precondition: ask from inside an open turn — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask throws before appending anything.
The contract in one line: `ctx.approval.request(req)` puts exactly one question — "may this specific action proceed?" — to whatever answerers the deployment composed, and its decision phase always resolves to an outcome: an aborted signal yields `cancelled`, a throwing or missing answerer yields `unavailable`, and `allowed-once` is a grant for the single asked-about action, never a class of future ones. Acceptance is synchronous: the service reads the request fields and `agent.session` binding once, requires object agent/session identities, a string `toolName`, optional string `callId`/`reason`, and an AbortSignal-shaped live capability, then shallow-freezes a detached request record while preserving the exact `agent` and signal identities. A malformed request rejects before any audit append; later caller mutation cannot redirect scope, payload, cancellation, policy lookup, or either audit event. The other precondition is an open turn on the captured session — the audit pair is turn-enclosed by contract (the turn is the durable log's commit/replay boundary; a bare event between turns is crash-tail garbage on reload), so an idle ask also rejects before appending. Session observers run after an event enters the append-only log; if one throws, the service recognizes that the audit is already authoritative, contains the observer failure, and completes the pair.
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'``'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'``'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md).

View File

@@ -223,12 +223,16 @@ function hasOpenTurn(events: readonly SessionEvent[]): boolean {
* THE write path for a session's approval-policy override: appends exactly
* one `approval/policy` event — the switch IS its event; nothing mutates
* policy state out of band. Takes effect on the session's next ask and next
* prompt assembly (the consumers fold on every read).
* prompt assembly (the consumers fold on every read). Rejects a value outside
* {@link APPROVAL_POLICIES} before appending anything.
* @param session - the session the override belongs to.
* @param policy - the policy every subsequent ask for this session resolves
* under (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 })
}
@@ -238,8 +242,10 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi
* was asked — it deliberately does NOT carry tool arguments: a UI answerer
* attaches the prompt to the already-streamed tool call via `callId` instead
* of re-rendering the call. `request()` synchronously copies and shallow-freezes
* this record before crossing an asynchronous boundary. Scalar fields are
* detached; the `agent` and `signal` identity capabilities are preserved.
* this record before crossing an asynchronous boundary. It reads each field
* and the agent's session binding once, validates the public fixed-field
* contract before audit, and detaches the scalar values; the `agent` and live
* `signal` identity capabilities are preserved rather than cloned or frozen.
*/
export interface ApprovalRequest {
/**
@@ -264,6 +270,13 @@ export interface ApprovalRequest {
signal?: AbortSignal
}
/** Live signal capability accepted at the synchronous request boundary. */
interface AcceptedSignal {
signal: AbortSignal
addEventListener: AbortSignal['addEventListener']
removeEventListener: AbortSignal['removeEventListener']
}
/** Plugin config. All optional — `static Config` supplies the defaults. */
export interface Config {
/**
@@ -297,7 +310,7 @@ export class ApprovalService extends Service {
constructor(ctx: Context, public config: Config) {
super(ctx, 'approval')
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent)
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
// Visibility layer 1, scoped on the prompt registry so headless
// compositions mount the seam without it: state the one deterministic
@@ -345,7 +358,7 @@ export class ApprovalService extends Service {
}
// Same fold effectivePolicy performs — override is scanned here anyway
// for POSITIONAL attribution; the default lives once, in the method.
const current = this.effectivePolicy(agent)
const current = this.effectivePolicy(session)
const header = session.requestHeader()
const told = narrated.get(session) ?? toldApprovalPolicy(header?.system)
narrated.set(session, current)
@@ -361,17 +374,21 @@ export class ApprovalService extends Service {
}
/**
* Ask the composed answerers to decide one request. Requires an open turn
* on the requesting agent's session — the audit pair below is turn-enclosed
* by contract (the turn is the log's commit/replay boundary; an idle append
* would be dropped as crash tail on reload) — and throws before appending
* anything when called idle; asking outside a turn is a deferred design.
* Within that precondition it always resolves to an outcome, never rejects:
* 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'`. The caller-owned request is
* synchronously snapshotted, so later mutation cannot split routing,
* dispatch payload, cancellation, or the audit pair across agents/sessions.
* Ask the composed answerers to decide one request. Synchronously reads each
* request field and the agent's session binding once, validates the fixed
* agent/session, string, and live-signal contracts, and rejects before any
* audit append when malformed. The signal remains the caller's exact live
* identity capability; it is neither cloned nor frozen. Requires an open
* turn on the accepted session — the audit pair below is turn-enclosed by
* contract (the turn is the log's commit/replay boundary; an idle append
* would be dropped as crash tail on reload) — and likewise throws before
* appending anything when called idle; asking outside a turn is a deferred
* design. Once accepted it always resolves to an outcome, never rejects: 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'`. The caller-owned request is synchronously
* snapshotted, so later mutation cannot split routing, dispatch payload,
* cancellation, policy lookup, or the audit pair across agents/sessions.
* Appends the
* `approval/asked`/`approval/decided` audit pair (log-only) around the
* decision regardless of outcome. A synchronous session observer failure
@@ -385,20 +402,73 @@ export class ApprovalService extends Service {
// Accept one immutable request shape before the first async boundary. The
// caller retains its record and may mutate it as soon as this async method
// returns; identity capabilities stay live, but the record is never reread.
const agent = req.agent
const toolName = req.toolName
const callId = req.callId
const reason = req.reason
const signal = req.signal
const input: unknown = req
if (typeof input !== 'object' || input === null) {
throw new TypeError('approval.request() requires a request object')
}
const source = input as Record<string, unknown>
const agentInput = source['agent']
const toolName = source['toolName']
const callId = source['callId']
const reason = source['reason']
const signalInput = source['signal']
if (typeof agentInput !== 'object' || agentInput === null) {
throw new TypeError('approval request agent must be an object')
}
if (typeof toolName !== 'string') {
throw new TypeError('approval request toolName must be a string')
}
if (callId !== undefined && typeof callId !== 'string') {
throw new TypeError('approval request callId must be a string when provided')
}
if (reason !== undefined && typeof reason !== 'string') {
throw new TypeError('approval request reason must be a string when provided')
}
let acceptedSignal: AcceptedSignal | undefined
if (signalInput !== undefined) {
if (typeof signalInput !== 'object' || signalInput === null) {
throw new TypeError('approval request signal must be an AbortSignal when provided')
}
const signalRecord = signalInput as unknown as Record<string, unknown>
const aborted = signalRecord['aborted']
const addEventListener = signalRecord['addEventListener']
const removeEventListener = signalRecord['removeEventListener']
if (typeof aborted !== 'boolean'
|| typeof addEventListener !== 'function'
|| typeof removeEventListener !== 'function') {
throw new TypeError('approval request signal must be an AbortSignal when provided')
}
acceptedSignal = {
signal: signalInput as AbortSignal,
addEventListener: addEventListener as AbortSignal['addEventListener'],
removeEventListener: removeEventListener as AbortSignal['removeEventListener'],
}
}
const sessionInput = (agentInput as unknown as Record<string, unknown>)['session']
if (typeof sessionInput !== 'object' || sessionInput === null) {
throw new TypeError('approval request agent session must be an object')
}
const sessionRecord = sessionInput as unknown as Record<string, unknown>
const events = sessionRecord['events']
const append = sessionRecord['append']
if (!Array.isArray(events)) {
throw new TypeError('approval request session events must be an array')
}
if (typeof append !== 'function') {
throw new TypeError('approval request session append must be a function')
}
const agent = agentInput as Agent
const session = sessionInput as Session
const acceptedCallId = callId as CallId | undefined
const signal = signalInput as AbortSignal | undefined
const accepted: Readonly<ApprovalRequest> = Object.freeze({
agent,
toolName,
...callId !== undefined ? { callId } : {},
...acceptedCallId !== undefined ? { callId: acceptedCallId } : {},
...reason !== undefined ? { reason } : {},
...signal !== undefined ? { signal } : {},
})
const session = accepted.agent.session
if (!hasOpenTurn(session.events)) {
if (!hasOpenTurn(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). '
@@ -407,16 +477,16 @@ export class ApprovalService extends Service {
}
const id = ApprovalRequestId(randomUUID())
this.appendAudit(session, 'approval/asked', id, () => {
session.append('approval/asked', {
Reflect.apply(append, session, ['approval/asked', {
id,
toolName: accepted.toolName,
...accepted.callId !== undefined ? { callId: accepted.callId } : {},
...accepted.reason !== undefined ? { reason: accepted.reason } : {},
})
}])
})
const outcome = await this.decide(accepted)
const outcome = await this.decide(accepted, session, acceptedSignal)
this.appendAudit(session, 'approval/decided', id, () => {
session.append('approval/decided', { id, outcome })
Reflect.apply(append, session, ['approval/decided', { id, outcome }])
})
return outcome
}
@@ -451,22 +521,30 @@ export class ApprovalService extends Service {
* 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 agent - the agent whose session's policy applies.
* @returns the policy every ask for this agent resolves under right now.
* @param session - the exact accepted session whose policy applies.
* @returns the policy every ask for this session resolves under right now.
*/
private effectivePolicy(agent: Agent): ApprovalPolicy {
return effectiveApprovalPolicy(agent.session.events) ?? this.config.policy ?? 'ask'
private effectivePolicy(session: Session): ApprovalPolicy {
return effectiveApprovalPolicy(session.events) ?? this.config.policy ?? 'ask'
}
/** Dispatch the waterfall, contained and raced against the accepted signal. */
private async decide(req: Readonly<ApprovalRequest>): Promise<ApprovalOutcome> {
if (req.signal?.aborted) return 'cancelled'
/**
* Dispatch the waterfall, contained and raced against the accepted signal.
* @param req - the detached public request snapshot.
* @param session - the captured session used for policy lookup.
* @param acceptedSignal - the validated live signal capability, if supplied.
* @returns the normalized closed outcome.
*/
private async decide(
req: Readonly<ApprovalRequest>, session: Session, acceptedSignal: AcceptedSignal | undefined,
): Promise<ApprovalOutcome> {
if (acceptedSignal?.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(req.agent) === 'never') return 'rejected'
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
@@ -484,13 +562,13 @@ export class ApprovalService extends Service {
// tool call open — the seam contains its callbacks.
() => 'unavailable',
)
const signal = req.signal
if (signal === undefined) return answer
if (acceptedSignal === undefined) return answer
const { signal, addEventListener, removeEventListener } = acceptedSignal
return await new Promise<ApprovalOutcome>((resolve) => {
const onAbort = () => { resolve('cancelled') }
signal.addEventListener('abort', onAbort, { once: true })
addEventListener.call(signal, 'abort', onAbort, { once: true })
void answer.then((outcome) => {
signal.removeEventListener('abort', onAbort)
removeEventListener.call(signal, '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)

View File

@@ -39,6 +39,158 @@ function requestOf(agent: Agent, overrides: Partial<ApprovalRequest> = {}): Appr
}
describe('ApprovalService.request', () => {
it('rejects malformed fixed fields and identities before appending or dispatching', async () => {
const ctx = await mounted()
const consulted = vi.fn()
ctx.on('approval/request', () => {
consulted()
return Promise.resolve<ApprovalOutcome>('allowed-once')
})
const { agent, appended } = fakeAgent()
const badSessionAppends: Array<ReturnType<typeof vi.fn>> = []
const badSession = (events: unknown, append: unknown): Agent => ({
session: { events, append },
}) as unknown as Agent
const appendSpy = (): ReturnType<typeof vi.fn> => {
const append = vi.fn()
badSessionAppends.push(append)
return append
}
const validSignalShape = {
aborted: false,
addEventListener: () => {},
removeEventListener: () => {},
}
const cases: Array<{ request: unknown; message: string }> = [
{ request: null, message: 'requires a request object' },
{ request: 1, message: 'requires a request object' },
{ request: { agent: null, toolName: 'echo' }, message: 'agent must be an object' },
{ request: { agent: 1, toolName: 'echo' }, message: 'agent must be an object' },
{ request: { agent, toolName: 1 }, message: 'toolName must be a string' },
{ request: { agent, toolName: 'echo', callId: 1 }, message: 'callId must be a string' },
{ request: { agent, toolName: 'echo', reason: 1 }, message: 'reason must be a string' },
{ request: { agent, toolName: 'echo', signal: null }, message: 'signal must be an AbortSignal' },
{ request: { agent, toolName: 'echo', signal: 1 }, message: 'signal must be an AbortSignal' },
{
request: { agent, toolName: 'echo', signal: { ...validSignalShape, aborted: 'no' } },
message: 'signal must be an AbortSignal',
},
{
request: { agent, toolName: 'echo', signal: { ...validSignalShape, addEventListener: 1 } },
message: 'signal must be an AbortSignal',
},
{
request: { agent, toolName: 'echo', signal: { ...validSignalShape, removeEventListener: 1 } },
message: 'signal must be an AbortSignal',
},
{
request: { agent: { session: null }, toolName: 'echo' },
message: 'agent session must be an object',
},
{
request: { agent: { session: 1 }, toolName: 'echo' },
message: 'agent session must be an object',
},
{
request: { agent: badSession(null, appendSpy()), toolName: 'echo' },
message: 'session events must be an array',
},
{
request: { agent: badSession([{ type: 'turn/start' }], 1), toolName: 'echo' },
message: 'session append must be a function',
},
]
for (const { request, message } of cases) {
await expect(ctx.approval.request(request as ApprovalRequest)).rejects.toThrow(message)
}
expect(appended).toEqual([])
for (const append of badSessionAppends) expect(append).not.toHaveBeenCalled()
expect(consulted).not.toHaveBeenCalled()
})
it('reads request fields, the agent session, and the session append method once', async () => {
const ctx = await mounted()
const { agent: acceptedSessionOwner, appended: acceptedAudit } = fakeAgent()
const { agent: replacementAgent, appended: replacementAudit } = fakeAgent()
const acceptedSession = acceptedSessionOwner.session
const acceptedAppend = acceptedSession.append.bind(acceptedSession)
const signal = new AbortController().signal
const reads = {
agent: 0,
toolName: 0,
callId: 0,
reason: 0,
signal: 0,
session: 0,
append: 0,
}
const session = {
events: acceptedSession.events,
get append(): Session['append'] {
reads.append += 1
return reads.append === 1 ? acceptedAppend : undefined as unknown as Session['append']
},
} as Session
const agent = Object.defineProperty({}, 'session', {
enumerable: true,
get: () => {
reads.session += 1
return reads.session === 1 ? session : replacementAgent.session
},
}) as Agent
const request = Object.defineProperties({}, {
agent: {
enumerable: true,
get: () => (++reads.agent === 1 ? agent : null),
},
toolName: {
enumerable: true,
get: () => (++reads.toolName === 1 ? 'stable-tool' : 1),
},
callId: {
enumerable: true,
get: () => (++reads.callId === 1 ? CallId('stable-call') : {}),
},
reason: {
enumerable: true,
get: () => (++reads.reason === 1 ? 'stable reason' : {}),
},
signal: {
enumerable: true,
get: () => (++reads.signal === 1 ? signal : {}),
},
}) as ApprovalRequest
let received: ApprovalRequest | undefined
ctx.on('approval/request', (accepted) => {
received = accepted
return Promise.resolve<ApprovalOutcome>('allowed-once')
})
await expect(ctx.approval.request(request)).resolves.toBe('allowed-once')
expect(reads).toEqual({
agent: 1,
toolName: 1,
callId: 1,
reason: 1,
signal: 1,
session: 1,
append: 1,
})
expect(received).toMatchObject({
agent,
toolName: 'stable-tool',
callId: 'stable-call',
reason: 'stable reason',
signal,
})
expect(Object.isFrozen(received)).toBe(true)
expect(acceptedAudit.map(event => event.type)).toEqual(['approval/asked', 'approval/decided'])
expect(replacementAudit).toEqual([])
})
it('throws before appending anything when no turn has ever opened (idle ask)', async () => {
const ctx = await mounted()
const { agent, appended } = fakeAgent([])
@@ -420,6 +572,15 @@ describe('approval policy (the approval/policy fold)', () => {
expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
})
it('rejects a policy outside the closed vocabulary before appending', () => {
const append = vi.fn()
const session = { append } as unknown as Session
expect(() => { setApprovalPolicy(session, 'sometimes' as Parameters<typeof setApprovalPolicy>[1]) })
.toThrow('approval policy must be one of "ask" or "never"')
expect(append).not.toHaveBeenCalled()
})
it('defaults a schema-less construction to ask (the ?? narrows the optional TYPE)', async () => {
// Direct construction bypasses the plugin schema (the SystemPrompt-test
// precedent for covering a defaulted Config field's type-narrowing ??).