@namzu/sdk 27.0.0 → 27.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/dist/connector/mcp/adapter.d.ts +30 -0
- package/dist/connector/mcp/adapter.d.ts.map +1 -1
- package/dist/connector/mcp/adapter.js +43 -1
- package/dist/connector/mcp/adapter.js.map +1 -1
- package/dist/public-runtime.d.ts +2 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +5 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/registry/tool/execute.d.ts +1 -0
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +31 -1
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/tool/screen.d.ts +33 -0
- package/dist/registry/tool/screen.d.ts.map +1 -0
- package/dist/registry/tool/screen.js +102 -0
- package/dist/registry/tool/screen.js.map +1 -0
- package/dist/runtime/query/guardrail-presets.d.ts +30 -1
- package/dist/runtime/query/guardrail-presets.d.ts.map +1 -1
- package/dist/runtime/query/guardrail-presets.js +48 -0
- package/dist/runtime/query/guardrail-presets.js.map +1 -1
- package/dist/types/guardrail/index.d.ts +73 -0
- package/dist/types/guardrail/index.d.ts.map +1 -1
- package/dist/types/tool/index.d.ts +11 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +1 -1
- package/src/connector/mcp/adapter.ts +51 -1
- package/src/public-runtime.ts +5 -0
- package/src/registry/tool/execute.ts +37 -1
- package/src/registry/tool/screen.ts +131 -0
- package/src/runtime/query/guardrail-presets.ts +50 -0
- package/src/types/guardrail/index.ts +71 -0
- package/src/types/tool/index.ts +15 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ToolResultGuardrailContext,
|
|
3
|
+
ToolResultGuardrailSpec,
|
|
4
|
+
ToolResultVerdict,
|
|
5
|
+
} from '../../types/guardrail/index.js'
|
|
6
|
+
import type { ToolResult } from '../../types/tool/index.js'
|
|
7
|
+
import { toErrorMessage } from '../../utils/error.js'
|
|
8
|
+
import type { Logger } from '../../utils/logger.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A tool result was refused terminally.
|
|
12
|
+
*
|
|
13
|
+
* Thrown rather than returned because the caller's failure path turns every
|
|
14
|
+
* exception into an ordinary tool failure the model then reads and works
|
|
15
|
+
* around — which is exactly what a terminal refusal must not become. The
|
|
16
|
+
* distinct type is what lets that path re-throw this one and convert the
|
|
17
|
+
* rest. A `halt` reported as a failed tool call would be a `refuse` with
|
|
18
|
+
* extra steps.
|
|
19
|
+
*/
|
|
20
|
+
export class ToolResultHalted extends Error {
|
|
21
|
+
readonly guardrail: string
|
|
22
|
+
|
|
23
|
+
constructor(guardrail: string, reason: string) {
|
|
24
|
+
super(`Tool result halted by guardrail "${guardrail}": ${reason}`)
|
|
25
|
+
this.name = 'ToolResultHalted'
|
|
26
|
+
this.guardrail = guardrail
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nameOf(spec: { name?: string }, index: number): string {
|
|
31
|
+
return spec.name ?? `tool-result-guardrail[${index}]`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalize<T>(spec: T | { name: string; check: T }): { name?: string; check: T } {
|
|
35
|
+
return typeof spec === 'function' ? { check: spec as T } : (spec as { name: string; check: T })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A guardrail that throws FAILS CLOSED, as the run-level ones do.
|
|
40
|
+
*
|
|
41
|
+
* `refuse` rather than `halt` for the same reason the tool boundary has a
|
|
42
|
+
* recoverable refusal at all: a broken screen means this result's safety is
|
|
43
|
+
* unknown, not that the run is unsalvageable. The model is told and can
|
|
44
|
+
* choose differently.
|
|
45
|
+
*/
|
|
46
|
+
async function safely(
|
|
47
|
+
run: () => ToolResultVerdict | Promise<ToolResultVerdict>,
|
|
48
|
+
name: string,
|
|
49
|
+
log: Logger,
|
|
50
|
+
): Promise<ToolResultVerdict> {
|
|
51
|
+
try {
|
|
52
|
+
return await run()
|
|
53
|
+
} catch (err) {
|
|
54
|
+
const reason = `guardrail "${name}" threw: ${toErrorMessage(err)}`
|
|
55
|
+
log.error('Tool-result guardrail threw — failing closed', {
|
|
56
|
+
guardrail: name,
|
|
57
|
+
error: toErrorMessage(err),
|
|
58
|
+
})
|
|
59
|
+
return { action: 'refuse', reason }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Screen a tool's result before anything downstream reads it.
|
|
65
|
+
*
|
|
66
|
+
* Runs every guardrail in order and stops at the first refusal. Rewrites
|
|
67
|
+
* compose — each guardrail sees what the previous one produced — matching
|
|
68
|
+
* the output-guardrail path, so a redaction chain behaves the same at both
|
|
69
|
+
* boundaries.
|
|
70
|
+
*
|
|
71
|
+
* Returns the result to use. A refusal comes back as a failed `ToolResult`
|
|
72
|
+
* carrying the reason, because that is the shape the model already knows
|
|
73
|
+
* how to read: it is the same thing a tool that could not do its job
|
|
74
|
+
* returns, and the alternative — a blank result — tells the model the tool
|
|
75
|
+
* found nothing, which is a different claim and a false one.
|
|
76
|
+
*/
|
|
77
|
+
export async function screenToolResult(
|
|
78
|
+
guardrails: readonly ToolResultGuardrailSpec[] | undefined,
|
|
79
|
+
result: ToolResult,
|
|
80
|
+
ctx: Omit<ToolResultGuardrailContext, 'output' | 'success'>,
|
|
81
|
+
log: Logger,
|
|
82
|
+
): Promise<ToolResult> {
|
|
83
|
+
if (!guardrails || guardrails.length === 0) return result
|
|
84
|
+
|
|
85
|
+
let current = result.output
|
|
86
|
+
let rewritten = false
|
|
87
|
+
|
|
88
|
+
for (const [index, spec] of guardrails.entries()) {
|
|
89
|
+
const { name, check } = normalize(spec)
|
|
90
|
+
const label = nameOf({ name }, index)
|
|
91
|
+
const verdict = await safely(
|
|
92
|
+
() => check({ ...ctx, output: current, success: result.success }),
|
|
93
|
+
label,
|
|
94
|
+
log,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if (verdict.action === 'halt') {
|
|
98
|
+
log.error('Tool-result guardrail halted the run', {
|
|
99
|
+
tool: ctx.toolName,
|
|
100
|
+
guardrail: label,
|
|
101
|
+
reason: verdict.reason,
|
|
102
|
+
})
|
|
103
|
+
throw new ToolResultHalted(label, verdict.reason)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (verdict.action === 'refuse') {
|
|
107
|
+
log.warn('Tool-result guardrail refused the result', {
|
|
108
|
+
tool: ctx.toolName,
|
|
109
|
+
guardrail: label,
|
|
110
|
+
reason: verdict.reason,
|
|
111
|
+
})
|
|
112
|
+
return {
|
|
113
|
+
success: false,
|
|
114
|
+
output: '',
|
|
115
|
+
error: `Tool "${ctx.toolName}" produced a result that was refused by guardrail "${label}": ${verdict.reason}`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (verdict.action === 'rewrite') {
|
|
120
|
+
log.info('Tool-result guardrail rewrote the result', {
|
|
121
|
+
tool: ctx.toolName,
|
|
122
|
+
guardrail: label,
|
|
123
|
+
reason: verdict.reason,
|
|
124
|
+
})
|
|
125
|
+
current = verdict.output
|
|
126
|
+
rewritten = true
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return rewritten ? { ...result, output: current } : result
|
|
131
|
+
}
|
|
@@ -2,6 +2,8 @@ import type {
|
|
|
2
2
|
GuardrailVerdict,
|
|
3
3
|
NamedGuardrail,
|
|
4
4
|
OutputGuardrail,
|
|
5
|
+
ToolResultGuardrail,
|
|
6
|
+
ToolResultVerdict,
|
|
5
7
|
} from '../../types/guardrail/index.js'
|
|
6
8
|
|
|
7
9
|
/**
|
|
@@ -100,6 +102,12 @@ const INJECTION_PATTERNS: readonly RegExp[] = [
|
|
|
100
102
|
* Input-side because it is cheapest there — nothing has been spent — and
|
|
101
103
|
* because the same text reaching the model is the thing you are trying to
|
|
102
104
|
* prevent.
|
|
105
|
+
*
|
|
106
|
+
* It cannot see an INDIRECT injection, and that is not a limitation of the
|
|
107
|
+
* patterns: an injection carried in a web page or a connected server's
|
|
108
|
+
* answer never appears in the run's input at all. See
|
|
109
|
+
* {@link toolResultInjectionGuardrail}, which is the same list at the other
|
|
110
|
+
* boundary.
|
|
103
111
|
*/
|
|
104
112
|
export function promptInjectionGuardrail(): NamedGuardrail<
|
|
105
113
|
(ctx: { messages: readonly { readonly content: unknown }[] }) => GuardrailVerdict
|
|
@@ -122,3 +130,45 @@ export function promptInjectionGuardrail(): NamedGuardrail<
|
|
|
122
130
|
},
|
|
123
131
|
}
|
|
124
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Flag likely instruction-override attempts in what a TOOL returned.
|
|
136
|
+
*
|
|
137
|
+
* The case the input-side screen structurally cannot reach. An indirect
|
|
138
|
+
* injection arrives in a fetched page or a connected server's answer, so it
|
|
139
|
+
* is never in the run's input — by the time it matters the run is
|
|
140
|
+
* legitimate and the payload is riding on a result the model asked for.
|
|
141
|
+
*
|
|
142
|
+
* `refuse`, not `halt`: a hostile result is a reason to abandon that call,
|
|
143
|
+
* not the run. The model is told the answer was refused and can choose
|
|
144
|
+
* something else, which is the behaviour that keeps the control switched on
|
|
145
|
+
* — a screen that ends a run on a false positive gets removed, and then it
|
|
146
|
+
* protects nothing.
|
|
147
|
+
*
|
|
148
|
+
* **Detection is partial and this says so rather than implying coverage.**
|
|
149
|
+
* The pattern list is shared with the input-side screen, so the same caveat
|
|
150
|
+
* holds: an injection phrased as ordinary prose, or written in a language
|
|
151
|
+
* the list does not cover, passes. Pattern-matching and delimiting both
|
|
152
|
+
* measure poorly against an attacker who adapts. This raises the cost of
|
|
153
|
+
* the lazy attack; it is not a boundary, and nothing here should be
|
|
154
|
+
* described as one.
|
|
155
|
+
*/
|
|
156
|
+
export function toolResultInjectionGuardrail(): NamedGuardrail<ToolResultGuardrail> {
|
|
157
|
+
return {
|
|
158
|
+
name: 'tool-result-injection',
|
|
159
|
+
check: ({ output, provenance }): ToolResultVerdict => {
|
|
160
|
+
for (const pattern of INJECTION_PATTERNS) {
|
|
161
|
+
if (!pattern.test(output)) continue
|
|
162
|
+
// Naming the source is most of what the model needs in order
|
|
163
|
+
// to act. "A result was refused" is not something it can route
|
|
164
|
+
// around; "the answer from weather-co was refused" is.
|
|
165
|
+
const source = provenance ? `the connected server "${provenance.server}"` : 'this tool'
|
|
166
|
+
return {
|
|
167
|
+
action: 'refuse',
|
|
168
|
+
reason: `the result from ${source} matched a known instruction-override pattern`,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { action: 'pass' }
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RunId } from '../ids/index.js'
|
|
2
2
|
import type { Message } from '../message/index.js'
|
|
3
|
+
import type { ToolProvenance } from '../tool/index.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Guardrails inspect what goes INTO a run and what comes OUT of it.
|
|
@@ -58,5 +59,75 @@ export interface NamedGuardrail<T> {
|
|
|
58
59
|
readonly check: T
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* What a guardrail sees when a tool has produced a result.
|
|
64
|
+
*
|
|
65
|
+
* The two above bracket the RUN. This one sits at the tool boundary, which
|
|
66
|
+
* is the only place a result can be examined before the model reads it:
|
|
67
|
+
* the registry returns to the executor, the executor applies the output
|
|
68
|
+
* budget and spills what is over it, and compaction summarises later still.
|
|
69
|
+
* So screening here is upstream of both by construction rather than by
|
|
70
|
+
* ordering — a summariser does not distinguish trusted from untrusted text,
|
|
71
|
+
* and content carried into a summary outlives the result it came from.
|
|
72
|
+
*
|
|
73
|
+
* `provenance` is the point. A connector's result is framed with the
|
|
74
|
+
* server's name (see `wrapUntrusted`), and a screen that can only read the
|
|
75
|
+
* value cannot tell a remote server's words from a first-party tool's.
|
|
76
|
+
*/
|
|
77
|
+
export interface ToolResultGuardrailContext {
|
|
78
|
+
/** The tool as the registry knows it. */
|
|
79
|
+
readonly toolName: string
|
|
80
|
+
/** Validated input the tool was called with. */
|
|
81
|
+
readonly input: unknown
|
|
82
|
+
/** The text the model would read. */
|
|
83
|
+
readonly output: string
|
|
84
|
+
/** Whether the tool itself reported success. */
|
|
85
|
+
readonly success: boolean
|
|
86
|
+
/**
|
|
87
|
+
* Who produced the tool, when it was not this process. Absent means
|
|
88
|
+
* host-defined; present names the connected server.
|
|
89
|
+
*/
|
|
90
|
+
readonly provenance?: ToolProvenance
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What a guardrail decided about a tool result.
|
|
95
|
+
*
|
|
96
|
+
* Deliberately NOT {@link GuardrailVerdict}. There, `block` ends the run —
|
|
97
|
+
* it is the only thing it can mean when the subject is the run's input or
|
|
98
|
+
* its final answer. At a tool boundary the useful refusal is usually the
|
|
99
|
+
* other one: fail this call, tell the model why, and let it choose
|
|
100
|
+
* something else. Reusing the word would give one spelling two meanings
|
|
101
|
+
* across boundaries, and a host that shared a function between them would
|
|
102
|
+
* get the wrong one silently.
|
|
103
|
+
*
|
|
104
|
+
* Hence two refusals rather than one:
|
|
105
|
+
*
|
|
106
|
+
* - `refuse` — recoverable. The `tool_use` fails with the reason in place
|
|
107
|
+
* of the output. Not blank and not dropped: a model shown an empty
|
|
108
|
+
* result concludes the tool found nothing, which is a different fact and
|
|
109
|
+
* invites the retry loop the refusal was meant to prevent.
|
|
110
|
+
* - `halt` — terminal, for what must not be survived.
|
|
111
|
+
*
|
|
112
|
+
* `rewrite` is for REDACTION — a credential or an account number that
|
|
113
|
+
* should not enter context — and this is the last boundary where it can be
|
|
114
|
+
* removed before it does. It is **not** for neutralising an injection:
|
|
115
|
+
* editing an attack presumes you understood the payload well enough to
|
|
116
|
+
* defang it, and the systems that screen for attacks block instead. The two
|
|
117
|
+
* are the same mechanism and only the discipline separates them, which is
|
|
118
|
+
* why it is written here rather than left to be inferred.
|
|
119
|
+
*/
|
|
120
|
+
export type ToolResultVerdict =
|
|
121
|
+
| { readonly action: 'pass' }
|
|
122
|
+
| { readonly action: 'refuse'; readonly reason: string }
|
|
123
|
+
| { readonly action: 'halt'; readonly reason: string }
|
|
124
|
+
| { readonly action: 'rewrite'; readonly output: string; readonly reason?: string }
|
|
125
|
+
|
|
126
|
+
export type ToolResultGuardrail = (
|
|
127
|
+
ctx: ToolResultGuardrailContext,
|
|
128
|
+
) => ToolResultVerdict | Promise<ToolResultVerdict>
|
|
129
|
+
|
|
130
|
+
export type ToolResultGuardrailSpec = ToolResultGuardrail | NamedGuardrail<ToolResultGuardrail>
|
|
131
|
+
|
|
61
132
|
export type InputGuardrailSpec = InputGuardrail | NamedGuardrail<InputGuardrail>
|
|
62
133
|
export type OutputGuardrailSpec = OutputGuardrail | NamedGuardrail<OutputGuardrail>
|
package/src/types/tool/index.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { z } from 'zod'
|
|
2
2
|
import type { Logger } from '../../utils/logger.js'
|
|
3
|
+
// Type-only, and circular by design: a tool-result guardrail is described in
|
|
4
|
+
// terms of the tool that produced the result, and the registry that holds
|
|
5
|
+
// the guardrails is described here. Erased at compile time, so neither
|
|
6
|
+
// module exists at runtime to depend on the other.
|
|
7
|
+
import type { ToolResultGuardrailSpec } from '../guardrail/index.js'
|
|
3
8
|
import type { RunId } from '../ids/index.js'
|
|
4
9
|
import type { InvocationState } from '../invocation/index.js'
|
|
5
10
|
import type { PermissionMode } from '../permission/index.js'
|
|
@@ -390,6 +395,16 @@ export interface ToolTierConfig {
|
|
|
390
395
|
export interface ToolRegistryConfig {
|
|
391
396
|
logger?: Logger
|
|
392
397
|
tierConfig?: ToolTierConfig
|
|
398
|
+
/**
|
|
399
|
+
* Screens run against every tool result before anything downstream
|
|
400
|
+
* reads it — the output budget, compaction, and the model itself are
|
|
401
|
+
* all past this point.
|
|
402
|
+
*
|
|
403
|
+
* Absent means no screening, which is what shipped before this existed:
|
|
404
|
+
* a connected server's text reached the model unexamined. See
|
|
405
|
+
* {@link ToolResultGuardrailSpec}.
|
|
406
|
+
*/
|
|
407
|
+
resultGuardrails?: readonly ToolResultGuardrailSpec[]
|
|
393
408
|
}
|
|
394
409
|
|
|
395
410
|
export interface ToolExecutionResult extends ToolResult {
|