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:
kingwl
2026-07-09 15:25:18 +08:00
parent 7afdc6e3b9
commit ef35007d75
27 changed files with 830 additions and 42 deletions

View File

@@ -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 |

View 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).

View 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.

View 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"
}
}

View 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

View 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')
})
})

View 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"
}
]
}

View File

@@ -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

View File

@@ -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:^",

View File

@@ -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

View File

@@ -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)

View File

@@ -28,6 +28,9 @@
},
{
"path": "../../core/agent"
},
{
"path": "../../approval/approval"
}
]
}