mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(scope): harden merged tool and skill boundaries
This commit is contained in:
@@ -39,7 +39,7 @@ The live registry pipeline has three transformable waterfalls followed by the ow
|
||||
- `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.
|
||||
- `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`](../../ui/user-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.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. The registry validates this exact union at runtime: a JavaScript/casted value with an unknown kind, a malformed reason, or extra fields fails closed as an `isError`; the tool body does not run and final observers still receive one result. 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`](../../ui/user-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.
|
||||
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
|
||||
- `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").
|
||||
|
||||
@@ -91,6 +91,9 @@ declare module 'cordis' {
|
||||
* tool body never runs. Input rewrite is deliberately NOT offered here (see
|
||||
* {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam
|
||||
* when one is mounted, and degrades to deny otherwise.
|
||||
* The returned union is validated as an exact runtime shape before approval
|
||||
* or guards run; a malformed JavaScript/casted decision fails closed as an
|
||||
* `isError` result and the tool body never runs.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a
|
||||
* listener registered through `agent.ctx` fires only for that agent's
|
||||
* calls, while a plain plugin listener fires for every call (including
|
||||
@@ -908,6 +911,8 @@ export class ToolRegistry extends Service {
|
||||
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
|
||||
* the final observe-only notification, the authoritative outcome must survive
|
||||
* a lossless JSON round trip; an invalid outcome is normalized to an error.
|
||||
* A malformed runtime/casted `tools/pre-execute` decision likewise normalizes
|
||||
* to an error before approval, guards, or the tool body.
|
||||
* Caller-owned arguments must survive lossless-JSON validation before and
|
||||
* after cloning; a violation normalizes to an error before policy or dispatch.
|
||||
* @param exec - the single-use call input; its identity is snapshotted and
|
||||
@@ -1002,10 +1007,10 @@ export class ToolRegistry extends Service {
|
||||
// carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only
|
||||
// its own agent's calls (agent-less calls are subject-less).
|
||||
const carrier = scopeTarget(this, exec.agent)
|
||||
const gate = await this.ctx.waterfall(
|
||||
const gate = this.snapshotPreDecision(await this.ctx.waterfall(
|
||||
carrier, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
))
|
||||
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
|
||||
const denialReason = decision.kind === 'allow'
|
||||
? this.guardReason(exec)
|
||||
@@ -1055,6 +1060,41 @@ export class ToolRegistry extends Service {
|
||||
return await this.postExecute(exec, result)
|
||||
}
|
||||
|
||||
/** Validate and detach the extensible gate's decision before any grant can dispatch. */
|
||||
private snapshotPreDecision(value: unknown): PreToolDecision {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
throw new TypeError('tools/pre-execute must return a PreToolDecision object')
|
||||
}
|
||||
const decision = value as { kind?: unknown; reason?: unknown }
|
||||
const keys = Reflect.ownKeys(decision)
|
||||
const hasExactKeys = (...expected: string[]): boolean =>
|
||||
keys.length === expected.length && expected.every(key => Object.hasOwn(decision, key))
|
||||
switch (decision.kind) {
|
||||
case 'allow':
|
||||
if (!hasExactKeys('kind')) {
|
||||
throw new TypeError('tools/pre-execute allow decision must contain only kind')
|
||||
}
|
||||
return { kind: 'allow' }
|
||||
case 'deny': {
|
||||
const reason = decision.reason
|
||||
if (!hasExactKeys('kind', 'reason') || typeof reason !== 'string') {
|
||||
throw new TypeError('tools/pre-execute deny decision must contain only kind and a string reason')
|
||||
}
|
||||
return { kind: 'deny', reason }
|
||||
}
|
||||
case 'ask': {
|
||||
const reason = decision.reason
|
||||
if (!(hasExactKeys('kind') || hasExactKeys('kind', 'reason'))
|
||||
|| (reason !== undefined && typeof reason !== 'string')) {
|
||||
throw new TypeError('tools/pre-execute ask decision must contain only kind and an optional string reason')
|
||||
}
|
||||
return { kind: 'ask', ...reason !== undefined ? { reason } : {} }
|
||||
}
|
||||
default:
|
||||
throw new TypeError('tools/pre-execute must return an allow, deny, or ask decision')
|
||||
}
|
||||
}
|
||||
|
||||
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
|
||||
private async notifyResult(exec: ToolExecution, result: ToolExecutionResult): Promise<void> {
|
||||
// The pipeline is over: freeze the remaining mutable signal slot so every
|
||||
|
||||
@@ -229,6 +229,66 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'non-object decision',
|
||||
replacement: null,
|
||||
message: 'tools/pre-execute must return a PreToolDecision object',
|
||||
},
|
||||
{
|
||||
name: 'unknown decision kind',
|
||||
replacement: { kind: 'permit' },
|
||||
message: 'tools/pre-execute must return an allow, deny, or ask decision',
|
||||
},
|
||||
{
|
||||
name: 'allow decision carrying extra fields',
|
||||
replacement: { kind: 'allow', reason: 'smuggled' },
|
||||
message: 'tools/pre-execute allow decision must contain only kind',
|
||||
},
|
||||
{
|
||||
name: 'deny decision without a reason',
|
||||
replacement: { kind: 'deny' },
|
||||
message: 'tools/pre-execute deny decision must contain only kind and a string reason',
|
||||
},
|
||||
{
|
||||
name: 'deny decision with a non-string reason',
|
||||
replacement: { kind: 'deny', reason: 42 },
|
||||
message: 'tools/pre-execute deny decision must contain only kind and a string reason',
|
||||
},
|
||||
{
|
||||
name: 'ask decision with a non-string reason',
|
||||
replacement: { kind: 'ask', reason: true },
|
||||
message: 'tools/pre-execute ask decision must contain only kind and an optional string reason',
|
||||
},
|
||||
{
|
||||
name: 'ask decision carrying extra fields',
|
||||
replacement: { kind: 'ask', cache: true },
|
||||
message: 'tools/pre-execute ask decision must contain only kind and an optional string reason',
|
||||
},
|
||||
])('fails closed on a malformed tools/pre-execute $name', async ({ replacement, message }) => {
|
||||
const ctx = await setup()
|
||||
let bodyCalls = 0
|
||||
const observed: ToolExecutionResult[] = []
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
async execute() {
|
||||
bodyCalls += 1
|
||||
return []
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', async () => replacement as unknown as PreToolDecision)
|
||||
ctx.on('tools/result', (_exec, result) => { observed.push(result) })
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('malformed-pre'), name: 'echo', arguments: {},
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({ text: `Error: ${message}` })
|
||||
expect(bodyCalls).toBe(0)
|
||||
expect(observed).toEqual([result])
|
||||
})
|
||||
|
||||
it('rejects a JavaScript guard that returns an async/non-string decision', async () => {
|
||||
const ctx = await setup()
|
||||
let bodyCalls = 0
|
||||
|
||||
Reference in New Issue
Block a user