fix(scope): harden final ownership boundaries

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

View File

@@ -22,7 +22,7 @@ tools:
- `ctx.tools.knownNames(scope?: ScopeKey): string[]` The PRE-restriction end-capability name universe `restrict` validates against: a typo fails loud while a restricted-away tool stays a normal absence. Presentation providers add reserved transport names separately when validating `toolOrder`.
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.guard(guard: ToolGuard): () => Promise<void> | void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Read each caller-owned top-level field once, snapshot the single-use call into a pipeline-owned execution, assign its opaque correlation token, materialize `arguments` through one lossless-JSON traversal, deep-freeze them, and protect identity before running `tools/pre-execute` → guards → `tools/execute``tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. After the required `callId`/`name` correlation identity is captured, the same captured optional fields build the normalized error shell if a later accessor or validation fails, so policy, dispatch, routing, and `tools/result` cannot observe different caller values. Every top-level result field is likewise captured once and the complete result or post-decision is losslessly materialized before final observation. Invalid input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. A throwing `callId` or `name` accessor rejects because no trustworthy result identity exists yet.
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Read each caller-owned top-level field once, require `callId` and `name` to yield strings, snapshot the single-use call into a pipeline-owned execution, assign its opaque correlation token, materialize `arguments` through one lossless-JSON traversal, deep-freeze them, and protect identity before running `tools/pre-execute` → guards → `tools/execute``tools/post-execute`; optional `signal` is the only operational field an around-dispatch wrapper may add, replace, or remove. After the required string correlation identity is captured, the same captured optional fields build the normalized error shell if a later accessor or validation fails, so policy, dispatch, routing, and `tools/result` cannot observe different caller values. Every top-level result field is likewise captured once and the complete result or post-decision is losslessly materialized before final observation. Invalid later input—including cloneable mutable exotics—and malformed or non-JSON listener/tool results normalize to `isError` outcomes rather than bypassing policy or failing later at the session log. A throwing accessor or non-string value in `callId` or `name` rejects before `tools/result` because no trustworthy result identity exists yet.
### Injected services
@@ -35,7 +35,7 @@ The live registry pipeline has three transformable waterfalls followed by the ow
### Key types
- `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. Registration stores a frozen snapshot with detached JSON parameters and once-bound callback identities.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; `arguments` must be losslessly JSON-serializable, and callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; `callId` and `name` must be strings, `arguments` must be losslessly JSON-serializable, and callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a frozen, property-free identity value assigned by the registry. It supports equality correlation only and exposes no live outer execution state.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry validates the complete post-policy value before final observation. 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.

View File

@@ -951,22 +951,33 @@ export class ToolRegistry extends Service {
* Caller-owned arguments are validated and detached in one recursive
* lossless-JSON traversal; a violation normalizes to an error before policy
* or dispatch.
* @param exec - the single-use call input; every top-level field is read once
* and that identity snapshot is protected before policy runs (and reused by
* the normalized error shell if validation fails).
* @returns the final result after every waterfall. Once the required
* `callId` and `name` correlation identity has been captured, later
* accessor, validation, listener, and tool failures resolve as `isError`
* results rather than rejections. A throwing `callId` or `name` accessor
* rejects because no trustworthy result identity exists yet.
* @param exec - the single-use call input; every top-level field is read once.
* `callId` and `name` must each yield a string before that identity snapshot
* is protected and policy begins.
* @returns the final result after every waterfall. Once the required string
* `callId` and `name` correlation identity has been captured, later accessor,
* validation, listener, and tool failures resolve as `isError` results rather
* than rejections. A throwing accessor or non-string value in either identity
* field rejects because no trustworthy result correlation exists yet.
*/
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
// callId/name are the minimum correlation identity needed to construct a
// result at all. Every other caller-controlled accessor is read once
// INSIDE the normalization boundary; if one throws, the error shell uses
// the fields captured before it and never rereads the hostile record.
// result at all. Capture each once, then validate the captured scalar before
// anything can treat it as a trustworthy identity. A JavaScript/casted
// caller that supplies another type rejects at this outer boundary: an error
// result carrying the same malformed value would not satisfy the correlation
// contract and might itself fail lossless-JSON materialization. Every other
// caller-controlled accessor is read once INSIDE the normalization boundary;
// if one throws, the error shell uses the fields captured before it and never
// rereads the hostile record.
const callId = exec.callId
const name = exec.name
if (typeof callId !== 'string') {
throw new TypeError('tool execution callId must be a string')
}
if (typeof name !== 'string') {
throw new TypeError('tool execution name must be a string')
}
let agent: Agent | undefined
let parent: ToolExecutionToken | undefined
let signal: AbortSignal | undefined

View File

@@ -7,7 +7,7 @@ import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@de
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type DefineToolOptions, type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolDefinition, type ToolExecution, type ToolExecutionResult, type ToolGuard,
type ToolDefinition, type ToolExecution, type ToolExecutionInput, type ToolExecutionResult, type ToolGuard,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -83,6 +83,95 @@ describe('ToolRegistry', () => {
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
})
it.each([
{ field: 'callId', value: 1n },
{ field: 'callId', value: 123 },
{ field: 'name', value: 1n },
{ field: 'name', value: 123 },
] as const)('rejects a non-string $field before final observation', async ({ field, value }) => {
const ctx = await setup()
let observed = 0
ctx.on('tools/result', () => { observed += 1 })
const input: Record<string, unknown> = {
callId: CallId('valid-call'),
name: 'missing',
arguments: {},
}
input[field] = value
await expect(ctx.tools.execute(input as unknown as ToolExecutionInput))
.rejects.toThrow(`tool execution ${field} must be a string`)
expect(observed).toBe(0)
})
it('reads correlation accessors once and normalizes a later hostile accessor', async () => {
const ctx = await setup()
const reads = { callId: 0, name: 0, arguments: 0 }
let observed: { callId: unknown; name: unknown; isError: boolean } | undefined
ctx.on('tools/result', (exec, result) => {
observed = { callId: exec.callId, name: exec.name, isError: result.isError }
})
const input = Object.defineProperties({}, {
callId: {
enumerable: true,
get: () => {
reads.callId += 1
if (reads.callId > 1) throw new Error('callId reread')
return CallId('one-read-call')
},
},
name: {
enumerable: true,
get: () => {
reads.name += 1
if (reads.name > 1) throw new Error('name reread')
return 'missing'
},
},
arguments: {
enumerable: true,
get: () => {
reads.arguments += 1
throw new Error('arguments accessor broke')
},
},
}) as unknown as ToolExecutionInput
const result = await ctx.tools.execute(input)
expect(reads).toEqual({ callId: 1, name: 1, arguments: 1 })
expect(result).toMatchObject({ callId: CallId('one-read-call'), isError: true })
expect(result.content[0]).toMatchObject({ text: 'Error: arguments accessor broke' })
expect(observed).toEqual({ callId: CallId('one-read-call'), name: 'missing', isError: true })
})
it('reads callId once before a hostile name accessor rejects correlation', async () => {
const ctx = await setup()
const reads = { callId: 0, name: 0 }
let observed = 0
ctx.on('tools/result', () => { observed += 1 })
const input = Object.defineProperties({ arguments: {} }, {
callId: {
enumerable: true,
get: () => {
reads.callId += 1
return CallId('hostile-name')
},
},
name: {
enumerable: true,
get: () => {
reads.name += 1
throw new Error('name accessor broke')
},
},
}) as unknown as ToolExecutionInput
await expect(ctx.tools.execute(input)).rejects.toThrow('name accessor broke')
expect(reads).toEqual({ callId: 1, name: 1 })
expect(observed).toBe(0)
})
it('threads a tool-attached meta (object return form) onto the result', async () => {
const ctx = await setup()
ctx.tools.register({