feat(approval): the approval seam — one-shot permission decisions over a waterfall of answerers
ctx.approval (dsh-approval): request() dispatches the approval/request waterfall and always resolves a closed outcome — allowed-once / rejected / cancelled / unavailable — never rejects; zero listeners fall through to fail-closed unavailable; abort settles cancelled and discards late answers; throwing or rogue answerers are contained as unavailable; every ask lands the log-only approval/asked / approval/decided audit pair. dsh-tools routes a pre-execute ask through the seam opportunistically (ctx.get) with three distinct deny reasons, keeping the historical ask→deny degrade when the seam is absent. The per-session policy tier, the ACP bridge answerer, and the sandbox escalation asker are staged follow-ups of the approval-seam RFC.
This commit is contained in:
@@ -12,6 +12,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
|
||||
| [`approval/`](approval/README.md) | One-shot permission decisions | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
|
||||
|
||||
9
packages/approval/README.md
Normal file
9
packages/approval/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# approval/ — approval family
|
||||
|
||||
The asking half of permission handling: one seam through which the harness puts a one-shot question — "may this specific action proceed?" — to whatever answerers a deployment composes, with a closed outcome vocabulary and a fail-closed default. The full design: [the approval-seam RFC](../../docs/rfc/proposed/feature/2026-07-06-approval-seam.md). All **product** packages.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `approval/` | The `ApprovalService` mechanism (waterfall dispatch, cancellation, audit events) + the vocabulary (`ApprovalRequest`, `ApprovalOutcome`, `ApprovalRequestId`) | `ctx.approval` |
|
||||
|
||||
Answerers live with their owners, not here: tests answer with inline scripted listeners, and the ACP bridge answerer is the staged first real one. Consumer today: [`core/tools`](../core/tools/) routes `tools/pre-execute`'s `ask` through the seam (degrading to deny when it is not mounted).
|
||||
11
packages/approval/approval/README.md
Normal file
11
packages/approval/approval/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# @deepseek-ai/dsh-approval
|
||||
|
||||
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. Depends only on cordis and the core vocabulary packages (agent, session, llm brand), never on any 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. 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 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. 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.
|
||||
|
||||
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/proposed/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 that RFC stages (the asker will live in the bash tool layer). The full design: [the approval-seam RFC](../../../docs/rfc/proposed/feature/2026-07-06-approval-seam.md).
|
||||
|
||||
No answerer ships in this change — every ask fails closed to `unavailable` until one is composed (the ACP bridge answerer is the staged first one). The audit events are log-only session records — the model only ever sees the tool result the asker derives from the outcome.
|
||||
38
packages/approval/approval/package.json
Normal file
38
packages/approval/approval/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-approval",
|
||||
"description": "Approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
240
packages/approval/approval/src/index.ts
Normal file
240
packages/approval/approval/src/index.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Approval seam: `ctx.approval` answers exactly one question — "may this
|
||||
* specific action proceed?" — by dispatching the `approval/request` waterfall
|
||||
* to whatever answerers the deployment composed (an ACP editor prompt, an
|
||||
* auto-decide policy, a scripted test listener) and returning a closed
|
||||
* {@link ApprovalOutcome}. With no answerer the waterfall falls through to the
|
||||
* built-in default `'unavailable'`: absence of a UI can never grant anything.
|
||||
*
|
||||
* The service is the MECHANISM (dispatch, cancellation, audit); answerers are
|
||||
* the POLICY. It serves both ask paths the sandbox RFC names — the
|
||||
* `tools/pre-execute` `ask` decision today, and the sandbox post-denial
|
||||
* escalation when that phase lands — so every asker shares one outcome
|
||||
* vocabulary and one audit trail. Grants are one-shot by design: an
|
||||
* `'allowed-once'` outcome authorizes the single action it was asked about,
|
||||
* never a class of future actions.
|
||||
*
|
||||
* Every request lands two log-only session events on the requesting agent's
|
||||
* log (`approval/asked` / `approval/decided`, paired by
|
||||
* {@link ApprovalRequestId}) — an audit trail, deliberately NOT part of the
|
||||
* model-visible transcript: the model only ever sees the tool result the
|
||||
* caller derives from the outcome.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-approval
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
approval: ApprovalService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Waterfall asking the composed answerers to decide one approval request.
|
||||
* Dispatched only from {@link ApprovalService.request} — callers go through
|
||||
* the service (which owns cancellation and the audit events), never through
|
||||
* `ctx.waterfall` directly. A listener that can answer for this request's
|
||||
* agent returns an outcome WITHOUT calling `next()` (the decision slot is
|
||||
* single-occupancy, first listener to answer wins); a listener that does
|
||||
* not recognize the agent MUST call `next()` so another answerer — or the
|
||||
* fail-closed default `'unavailable'` — gets the question. Throwing is
|
||||
* contained by the service and yields `'unavailable'`.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'approval/request'(this: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pairs one `approval/asked` audit event with its `approval/decided`.
|
||||
* Service-issued (one fresh id per {@link ApprovalService.request} call).
|
||||
*/
|
||||
export type ApprovalRequestId = Branded<'ApprovalRequestId'>
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link ApprovalRequestId}.
|
||||
* @param id - the raw id string to brand.
|
||||
* @returns the same string carrying the brand.
|
||||
*/
|
||||
export function ApprovalRequestId(id: string): ApprovalRequestId {
|
||||
return id as ApprovalRequestId
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed outcome vocabulary of one approval request.
|
||||
*
|
||||
* - `'allowed-once'` — a one-shot grant for exactly the asked-about action;
|
||||
* consumed by proceeding, never a durable authorization.
|
||||
* - `'rejected'` — an answerer (human or policy) said no.
|
||||
* - `'cancelled'` — the question was withdrawn: the prompt was dismissed, or
|
||||
* the requesting execution aborted while the question was pending.
|
||||
* - `'unavailable'` — nobody composed could answer (no listener, none that
|
||||
* recognizes the agent, or an answerer failed). Callers MUST fail closed on
|
||||
* it, exactly like `'rejected'` — the two differ only for audit and wording.
|
||||
*/
|
||||
export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
|
||||
|
||||
/** Every {@link ApprovalOutcome}, for runtime normalization of answerer returns. */
|
||||
const OUTCOMES: readonly ApprovalOutcome[] = ['allowed-once', 'rejected', 'cancelled', 'unavailable']
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* One concrete permission question. Identifies the action precisely enough
|
||||
* for an answerer to present it and for the audit events to reconstruct what
|
||||
* 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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
agent: Agent
|
||||
/** The tool the question is about (presentation and audit). */
|
||||
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.
|
||||
*/
|
||||
callId?: CallId
|
||||
/** The asker's human-readable explanation of WHY it is asking. */
|
||||
reason?: string
|
||||
/**
|
||||
* Aborting withdraws the question: the request settles `'cancelled'`
|
||||
* immediately and a late answer from a still-pending answerer is discarded.
|
||||
*/
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ctx.approval` service: dispatches {@link ApprovalRequest}s to the
|
||||
* `approval/request` waterfall and audits every ask/outcome pair to the
|
||||
* requesting agent's session log. Stateless between requests — grants are
|
||||
* returned to the caller, never stored here.
|
||||
*/
|
||||
export class ApprovalService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'approval')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'`. Appends the
|
||||
* `approval/asked`/`approval/decided` audit pair (log-only) around the
|
||||
* decision regardless of outcome.
|
||||
* @param req - the pending decision (agent, tool identity, reason, signal).
|
||||
* @returns the closed outcome; `'allowed-once'` is the only grant.
|
||||
*/
|
||||
async request(req: ApprovalRequest): Promise<ApprovalOutcome> {
|
||||
if (!hasOpenTurn(req.agent.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())
|
||||
req.agent.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)
|
||||
req.agent.session.append('approval/decided', { id, outcome })
|
||||
return outcome
|
||||
}
|
||||
|
||||
/** Dispatch the waterfall, contained and raced against `req.signal`. */
|
||||
private async decide(req: ApprovalRequest): Promise<ApprovalOutcome> {
|
||||
if (req.signal?.aborted) return 'cancelled'
|
||||
// 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(this, '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',
|
||||
)
|
||||
const signal = req.signal
|
||||
if (signal === undefined) return answer
|
||||
return await new Promise<ApprovalOutcome>((resolve) => {
|
||||
const 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
|
||||
203
packages/approval/approval/tests/approval.spec.ts
Normal file
203
packages/approval/approval/tests/approval.spec.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import ApprovalService, { ApprovalOutcome, ApprovalRequest } from '@deepseek-ai/dsh-approval'
|
||||
|
||||
/**
|
||||
* A minimal Agent stand-in — the service only reaches `agent.session.append`
|
||||
* and folds `.events`. Seeded inside an open turn by default (request()'s
|
||||
* turn-enclosure precondition); pass `seed` to stage idle/closed logs.
|
||||
* Returns the recorded audit appends alongside the fake.
|
||||
*/
|
||||
function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record<string, unknown> }> } {
|
||||
const appended: Array<{ type: string; data: Record<string, unknown> }> = []
|
||||
const agent = {
|
||||
session: {
|
||||
events: seed,
|
||||
append: (type: string, data: Record<string, unknown>) => {
|
||||
appended.push({ type, data })
|
||||
return { type, data } as unknown as SessionEvent
|
||||
},
|
||||
},
|
||||
} as unknown as Agent
|
||||
return { agent, appended }
|
||||
}
|
||||
|
||||
async function mounted(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(ApprovalService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function requestOf(agent: Agent, overrides: Partial<ApprovalRequest> = {}): ApprovalRequest {
|
||||
return { agent, toolName: 'echo', ...overrides }
|
||||
}
|
||||
|
||||
describe('ApprovalService.request', () => {
|
||||
it('throws before appending anything when no turn has ever opened (idle ask)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent([])
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
|
||||
expect(appended).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }])
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
|
||||
expect(appended).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails closed to unavailable when nobody listens, auditing the asked/decided pair', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
|
||||
const outcome = await ctx.approval.request(requestOf(agent, { callId: CallId('call-1'), reason: 'hook says ask' }))
|
||||
|
||||
expect(outcome).toBe('unavailable')
|
||||
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
const [asked, decided] = appended
|
||||
expect(asked?.data).toMatchObject({ toolName: 'echo', callId: 'call-1', reason: 'hook says ask' })
|
||||
expect(decided?.data).toMatchObject({ outcome: 'unavailable' })
|
||||
expect(decided?.data['id']).toBe(asked?.data['id'])
|
||||
})
|
||||
|
||||
it('omits absent optional fields from the asked audit event', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
|
||||
await ctx.approval.request(requestOf(agent))
|
||||
|
||||
expect(Object.keys(appended[0]?.data ?? {}).sort()).toEqual(['id', 'toolName'])
|
||||
})
|
||||
|
||||
it('returns the first answering listener outcome (single decision slot)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
let secondRan = false
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
ctx.on('approval/request', () => {
|
||||
secondRan = true
|
||||
return Promise.resolve<ApprovalOutcome>('rejected')
|
||||
})
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
|
||||
expect(secondRan).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a non-owning listener delegate via next() down to the fail-closed default', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
ctx.on('approval/request', (_req, next) => next())
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('contains a throwing answerer as unavailable', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
ctx.on('approval/request', () => Promise.reject(new Error('transport died')))
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'unavailable' })
|
||||
})
|
||||
|
||||
it('normalizes a rogue non-vocabulary answer to unavailable', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
// A JS answerer can return anything; the seam must not leak it into
|
||||
// callers' closed-union switches.
|
||||
ctx.on('approval/request', () => Promise.resolve('yolo' as ApprovalOutcome))
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
})
|
||||
|
||||
it('settles cancelled immediately on an already-aborted signal without asking anyone', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
let asked = false
|
||||
ctx.on('approval/request', () => {
|
||||
asked = true
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
|
||||
const outcome = await ctx.approval.request(requestOf(agent, { signal: AbortSignal.abort() }))
|
||||
|
||||
expect(outcome).toBe('cancelled')
|
||||
expect(asked).toBe(false)
|
||||
expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided'])
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
|
||||
})
|
||||
|
||||
it('resolves cancelled when the signal aborts mid-question and discards the late answer', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
let settleLate: ((outcome: ApprovalOutcome) => void) | undefined
|
||||
ctx.on('approval/request', () => new Promise<ApprovalOutcome>((resolve) => { settleLate = resolve }))
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
|
||||
controller.abort()
|
||||
await expect(pending).resolves.toBe('cancelled')
|
||||
|
||||
// The answerer settles after the fact: no second decided event appears.
|
||||
settleLate?.('allowed-once')
|
||||
await Promise.resolve()
|
||||
expect(appended.filter(e => e.type === 'approval/decided')).toHaveLength(1)
|
||||
expect(appended[1]?.data).toMatchObject({ outcome: 'cancelled' })
|
||||
})
|
||||
|
||||
it('discards a late REJECTION after abort without an unhandled rejection', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
let rejectLate: ((error: Error) => void) | undefined
|
||||
ctx.on('approval/request', () => new Promise<ApprovalOutcome>((_resolve, reject) => { rejectLate = reject }))
|
||||
const controller = new AbortController()
|
||||
|
||||
const pending = ctx.approval.request(requestOf(agent, { signal: controller.signal }))
|
||||
controller.abort()
|
||||
await expect(pending).resolves.toBe('cancelled')
|
||||
|
||||
rejectLate?.(new Error('answered too late'))
|
||||
// Drain microtasks: the contained rejection must not escape the seam.
|
||||
await new Promise((resolve) => { setTimeout(resolve, 0) })
|
||||
})
|
||||
|
||||
it('resolves the answer when the signal never aborts', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(ctx.approval.request(requestOf(agent, { signal: controller.signal }))).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('issues a fresh id per request', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent, appended } = fakeAgent()
|
||||
|
||||
await ctx.approval.request(requestOf(agent))
|
||||
await ctx.approval.request(requestOf(agent))
|
||||
|
||||
const ids = appended.filter(e => e.type === 'approval/asked').map(e => e.data['id'])
|
||||
expect(ids).toHaveLength(2)
|
||||
expect(ids[0]).not.toBe(ids[1])
|
||||
})
|
||||
|
||||
it('drops a disposed plugin listener from the chain (HMR safety)', async () => {
|
||||
const ctx = await mounted()
|
||||
const { agent } = fakeAgent()
|
||||
const fiber = await ctx.plugin((inner: Context) => {
|
||||
inner.on('approval/request', () => Promise.resolve<ApprovalOutcome>('allowed-once'))
|
||||
})
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('allowed-once')
|
||||
|
||||
await fiber.dispose()
|
||||
await expect(ctx.approval.request(requestOf(agent))).resolves.toBe('unavailable')
|
||||
})
|
||||
})
|
||||
|
||||
36
packages/approval/approval/tsconfig.json
Normal file
36
packages/approval/approval/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,7 +22,7 @@ tools:
|
||||
|
||||
### Injected services
|
||||
|
||||
`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`.
|
||||
`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way.
|
||||
|
||||
### Events
|
||||
|
||||
@@ -38,14 +38,14 @@ tools:
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
|
||||
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
|
||||
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../approval/approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
|
||||
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
|
||||
|
||||
### Extension points
|
||||
|
||||
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
|
||||
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
|
||||
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch and yields an `isError` result, and an `ask` resolves through the approval seam first — only a grant dispatches (see `PreToolDecision` above). `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
### Typed tool parameter schemas
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -34,6 +35,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -19,10 +19,13 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
|
||||
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
|
||||
import type {} from '@deepseek-ai/dsh-approval'
|
||||
import type { ToolCallView, ToolResultView } from './presentation.ts'
|
||||
import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts'
|
||||
import { renderToolsSdk } from './ts-types.ts'
|
||||
@@ -83,8 +86,8 @@ declare module 'cordis' {
|
||||
* or return a {@link PreToolDecision} without calling `next()` to
|
||||
* short-circuit. A `deny` skips dispatch and yields an `isError` result; the
|
||||
* tool body never runs. Input rewrite is deliberately NOT offered here (see
|
||||
* {@link PreToolDecision}); `ask` degrades to deny until the permission
|
||||
* system lands (`FIXME(permissions)`).
|
||||
* {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam
|
||||
* when one is mounted, and degrades to deny otherwise.
|
||||
* @param exec - the pending call (name, parsed arguments, caller agent).
|
||||
* @mode waterfall
|
||||
*/
|
||||
@@ -268,8 +271,9 @@ export interface ToolExecutionResult {
|
||||
* would desync the UI from what RAN. That consistency redesign is its own
|
||||
* `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
|
||||
* - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
|
||||
* - `ask` is the permission-prompt intent; until the permission system exists it
|
||||
* degrades to `deny` (`FIXME(permissions)`).
|
||||
* - `ask` is the permission-prompt intent: serviced as a one-shot decision by
|
||||
* the `ctx.approval` seam when one is mounted (`allowed-once` proceeds to
|
||||
* dispatch; every other outcome denies), degrading to `deny` when none is.
|
||||
*/
|
||||
export type PreToolDecision =
|
||||
| { kind: 'allow' }
|
||||
@@ -486,22 +490,17 @@ export class ToolRegistry extends Service {
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
try {
|
||||
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
|
||||
// until the permission system lands) skips dispatch entirely. ---
|
||||
const decision = await this.ctx.waterfall(
|
||||
// --- Gate: tools/pre-execute. An `ask` resolves through the approval
|
||||
// seam (or degrades) to allow/deny before the shared deny path. ---
|
||||
const gate = await this.ctx.waterfall(
|
||||
this, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
|
||||
if (decision.kind !== 'allow') {
|
||||
// deny → isError. ask has no permission UI yet, so degrade to deny
|
||||
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
|
||||
// a real prompt; today it is the conservative "not allowed".
|
||||
const reason = decision.kind === 'deny'
|
||||
? decision.reason
|
||||
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${reason}` }],
|
||||
content: [{ type: 'text', text: `Error: ${decision.reason}` }],
|
||||
isError: true,
|
||||
}
|
||||
return await this.postExecute(exec, denied)
|
||||
@@ -540,6 +539,44 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an `ask` decision to allow/deny through the approval seam. The
|
||||
* seam is consumed opportunistically with `ctx.get('approval')` — a
|
||||
* deployment that composes no ApprovalService keeps the historical degrade
|
||||
* to deny, and an unmount mid-session degrades the same way on the next ask.
|
||||
* An agent-less execution also degrades: without an agent there is no
|
||||
* session to audit to and no UI to route to. Otherwise the outcome maps
|
||||
* one-to-one — `allowed-once` proceeds; the three non-grants deny with
|
||||
* distinct reasons so the model can tell a human "no" from an absent
|
||||
* approval channel.
|
||||
*/
|
||||
private async serviceAsk(
|
||||
exec: ToolExecution,
|
||||
ask: Extract<PreToolDecision, { kind: 'ask' }>,
|
||||
): Promise<Extract<PreToolDecision, { kind: 'allow' | 'deny' }>> {
|
||||
const approval = this.ctx.get('approval')
|
||||
if (approval === undefined) {
|
||||
return { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }
|
||||
}
|
||||
if (exec.agent === undefined) {
|
||||
return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }
|
||||
}
|
||||
const outcome = await approval.request({
|
||||
agent: exec.agent,
|
||||
toolName: exec.name,
|
||||
callId: exec.callId,
|
||||
...ask.reason !== undefined ? { reason: ask.reason } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
})
|
||||
switch (outcome) {
|
||||
case 'allowed-once': return { kind: 'allow' }
|
||||
case 'rejected': return { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }
|
||||
case 'cancelled': return { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }
|
||||
case 'unavailable': return { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }
|
||||
default: return assertNever(outcome, 'ApprovalOutcome')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
|
||||
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-approval'
|
||||
import ToolRegistry, {
|
||||
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
|
||||
@@ -158,7 +160,7 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
|
||||
})
|
||||
|
||||
it('an ask decision degrades to deny until the permission system lands', async () => {
|
||||
it('an ask decision degrades to deny when no approval seam is mounted', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
@@ -181,6 +183,107 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
|
||||
})
|
||||
|
||||
describe('ask routing through ctx.approval', () => {
|
||||
/**
|
||||
* A minimal Agent stand-in — the approval seam reaches
|
||||
* `agent.session.append` and folds `.events`; the seeded open turn
|
||||
* satisfies request()'s enclosure precondition.
|
||||
*/
|
||||
function fakeAgent(): Agent {
|
||||
return {
|
||||
session: { events: [{ type: 'turn/start' }], append: () => ({}) },
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
async function approvalSetup() {
|
||||
const ctx = await setup()
|
||||
await ctx.plugin(ApprovalService)
|
||||
ctx.tools.register(echoTool)
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('dispatches the tool when the answerer grants allowed-once, forwarding the ask fields', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
const agent = fakeAgent()
|
||||
const controller = new AbortController()
|
||||
const seen: ApprovalRequest[] = []
|
||||
ctx.on('approval/request', (req) => {
|
||||
seen.push(req)
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
|
||||
({ kind: 'ask', reason: 'hook wants a human' }))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' }, agent, signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ isError: false, content: [{ type: 'text', text: 'hi' }] })
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]).toMatchObject({ agent, toolName: 'echo', callId: 'c1', reason: 'hook wants a human' })
|
||||
expect(seen[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('denies with the user-rejection reason on rejected', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('rejected'))
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' })
|
||||
})
|
||||
|
||||
it('denies with the cancellation reason on cancelled', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
ctx.on('approval/request', () => Promise.resolve<ApprovalOutcome>('cancelled'))
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' })
|
||||
})
|
||||
|
||||
it('denies with the no-channel reason when the seam is mounted but nobody answers', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' })
|
||||
})
|
||||
|
||||
it('denies an agent-less execution without asking — nothing to route or audit through', async () => {
|
||||
const ctx = await approvalSetup()
|
||||
let asked = false
|
||||
ctx.on('approval/request', () => {
|
||||
asked = true
|
||||
return Promise.resolve<ApprovalOutcome>('allowed-once')
|
||||
})
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
|
||||
expect(asked).toBe(false)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' })
|
||||
})
|
||||
|
||||
it('turns a rogue outcome from a NON-conforming approval stand-in into an isError result', async () => {
|
||||
// ApprovalService normalizes rogue answers itself; this pins the
|
||||
// registry's own exhaustiveness backstop by shadowing the service with a
|
||||
// stand-in that violates the outcome contract.
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService)
|
||||
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() })
|
||||
expect(result.isError).toBe(true)
|
||||
const text = result.content[0]?.type === 'text' ? result.content[0].text : ''
|
||||
expect(text).toContain('unreachable')
|
||||
})
|
||||
})
|
||||
|
||||
it('a tools/post-execute listener can replace the result content (accept) ', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../approval/approval"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user