fix(scope): harden final ownership boundaries
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user