@namzu/sdk 26.1.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/dist/compaction/manager.d.ts +22 -0
  3. package/dist/compaction/manager.d.ts.map +1 -1
  4. package/dist/compaction/manager.js +31 -3
  5. package/dist/compaction/manager.js.map +1 -1
  6. package/dist/connector/mcp/adapter.d.ts +37 -1
  7. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  8. package/dist/connector/mcp/adapter.js +55 -2
  9. package/dist/connector/mcp/adapter.js.map +1 -1
  10. package/dist/connector/mcp/prompt-adapter.d.ts +10 -4
  11. package/dist/connector/mcp/prompt-adapter.d.ts.map +1 -1
  12. package/dist/connector/mcp/prompt-adapter.js +10 -4
  13. package/dist/connector/mcp/prompt-adapter.js.map +1 -1
  14. package/dist/eval/experiment.d.ts +0 -6
  15. package/dist/eval/experiment.d.ts.map +1 -1
  16. package/dist/eval/experiment.js +28 -0
  17. package/dist/eval/experiment.js.map +1 -1
  18. package/dist/eval/index.d.ts +2 -0
  19. package/dist/eval/index.d.ts.map +1 -1
  20. package/dist/eval/index.js +1 -0
  21. package/dist/eval/index.js.map +1 -1
  22. package/dist/eval/types.d.ts +17 -0
  23. package/dist/eval/types.d.ts.map +1 -1
  24. package/dist/eval/uncertainty.d.ts +64 -0
  25. package/dist/eval/uncertainty.d.ts.map +1 -0
  26. package/dist/eval/uncertainty.js +92 -0
  27. package/dist/eval/uncertainty.js.map +1 -0
  28. package/dist/public-runtime.d.ts +3 -1
  29. package/dist/public-runtime.d.ts.map +1 -1
  30. package/dist/public-runtime.js +6 -1
  31. package/dist/public-runtime.js.map +1 -1
  32. package/dist/registry/tool/execute.d.ts +1 -0
  33. package/dist/registry/tool/execute.d.ts.map +1 -1
  34. package/dist/registry/tool/execute.js +33 -2
  35. package/dist/registry/tool/execute.js.map +1 -1
  36. package/dist/registry/tool/screen.d.ts +33 -0
  37. package/dist/registry/tool/screen.d.ts.map +1 -0
  38. package/dist/registry/tool/screen.js +102 -0
  39. package/dist/registry/tool/screen.js.map +1 -0
  40. package/dist/runtime/query/guardrail-presets.d.ts +30 -1
  41. package/dist/runtime/query/guardrail-presets.d.ts.map +1 -1
  42. package/dist/runtime/query/guardrail-presets.js +48 -0
  43. package/dist/runtime/query/guardrail-presets.js.map +1 -1
  44. package/dist/tools/trusted-read-only.d.ts +42 -0
  45. package/dist/tools/trusted-read-only.d.ts.map +1 -0
  46. package/dist/tools/trusted-read-only.js +51 -0
  47. package/dist/tools/trusted-read-only.js.map +1 -0
  48. package/dist/tools/untrusted-envelope.d.ts +22 -2
  49. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  50. package/dist/tools/untrusted-envelope.js +22 -2
  51. package/dist/tools/untrusted-envelope.js.map +1 -1
  52. package/dist/types/guardrail/index.d.ts +73 -0
  53. package/dist/types/guardrail/index.d.ts.map +1 -1
  54. package/dist/types/tool/index.d.ts +39 -0
  55. package/dist/types/tool/index.d.ts.map +1 -1
  56. package/dist/types/tool/index.js.map +1 -1
  57. package/dist/verification/rules.d.ts.map +1 -1
  58. package/dist/verification/rules.js +5 -1
  59. package/dist/verification/rules.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/compaction/manager.ts +39 -3
  62. package/src/connector/mcp/adapter.ts +62 -1
  63. package/src/connector/mcp/prompt-adapter.ts +10 -4
  64. package/src/eval/experiment.ts +30 -0
  65. package/src/eval/index.ts +2 -0
  66. package/src/eval/types.ts +17 -0
  67. package/src/eval/uncertainty.ts +124 -0
  68. package/src/public-runtime.ts +6 -0
  69. package/src/registry/tool/execute.ts +39 -2
  70. package/src/registry/tool/screen.ts +131 -0
  71. package/src/runtime/query/guardrail-presets.ts +50 -0
  72. package/src/tools/trusted-read-only.ts +52 -0
  73. package/src/tools/untrusted-envelope.ts +22 -2
  74. package/src/types/guardrail/index.ts +71 -0
  75. package/src/types/tool/index.ts +45 -0
  76. package/src/verification/rules.ts +5 -1
@@ -3,6 +3,8 @@ import { assertStrictSchema } from '../../provider/strict-schema.js'
3
3
  import { GENAI, NAMZU, toolSpanName } from '../../telemetry/attributes.js'
4
4
  import { recordToolCall } from '../../telemetry/metrics.js'
5
5
  import { getTracer } from '../../telemetry/runtime-accessors.js'
6
+ import { isTrustedReadOnly } from '../../tools/trusted-read-only.js'
7
+ import type { ToolResultGuardrailSpec } from '../../types/guardrail/index.js'
6
8
  import type {
7
9
  LLMToolSchema,
8
10
  ToolAvailability,
@@ -15,6 +17,7 @@ import type {
15
17
  import { toErrorMessage } from '../../utils/error.js'
16
18
  import { ManagedRegistry } from '../ManagedRegistry.js'
17
19
  import { renderToolSchema } from './schema.js'
20
+ import { ToolResultHalted, screenToolResult } from './screen.js'
18
21
 
19
22
  export type { ToolExecutionResult }
20
23
 
@@ -132,10 +135,12 @@ export function describeWithOutput(
132
135
  export class ToolRegistry extends ManagedRegistry<ToolDefinition> {
133
136
  private availability: Map<string, ToolAvailability> = new Map()
134
137
  private tierConfig?: ToolTierConfig
138
+ private resultGuardrails?: readonly ToolResultGuardrailSpec[]
135
139
 
136
140
  constructor(config?: ToolRegistryConfig) {
137
141
  super({ componentName: 'ToolRegistry', idField: 'name', logger: config?.logger })
138
142
  this.tierConfig = config?.tierConfig
143
+ this.resultGuardrails = config?.resultGuardrails
139
144
  }
140
145
 
141
146
  override register(id: string, tool: ToolDefinition): void
@@ -487,7 +492,7 @@ Executable tool names, descriptions, and JSON input schemas are attached through
487
492
 
488
493
  const mode = context.permissionContext?.mode ?? 'auto'
489
494
  if (mode === 'plan') {
490
- const isReadOnly = tool.isReadOnly ? tool.isReadOnly(rawInput) : false
495
+ const isReadOnly = isTrustedReadOnly(tool, rawInput)
491
496
  if (!isReadOnly) {
492
497
  const msg = `plan mode: non-read-only tool "${toolName}" blocked`
493
498
  span.setAttributes({
@@ -564,7 +569,25 @@ Executable tool names, descriptions, and JSON input schemas are attached through
564
569
  try {
565
570
  this.log.debug(`Executing tool: ${toolName}`)
566
571
  const startedAt = Date.now()
567
- const result = await tool.execute(finalInput, context)
572
+ const produced = await tool.execute(finalInput, context)
573
+ // Screened here, which is the only place a result can be
574
+ // examined before anything acts on it: the executor applies
575
+ // the output budget to what this returns, and compaction
576
+ // summarises later still. `provenance` is carried in so a
577
+ // screen can tell a connected server's words from a
578
+ // first-party tool's — a connector's result is framed with
579
+ // the server's name, and a screen reading only the value
580
+ // cannot use that.
581
+ const result = await screenToolResult(
582
+ this.resultGuardrails,
583
+ produced,
584
+ {
585
+ toolName,
586
+ input: finalInput,
587
+ ...(tool.provenance ? { provenance: tool.provenance } : {}),
588
+ },
589
+ this.log,
590
+ )
568
591
  const durationMs = Date.now() - startedAt
569
592
  this.log.debug(`Tool completed: ${toolName}`, {
570
593
  success: result.success,
@@ -586,6 +609,20 @@ Executable tool names, descriptions, and JSON input schemas are attached through
586
609
 
587
610
  return result
588
611
  } catch (err) {
612
+ // A terminal refusal must not be converted into a failed
613
+ // tool call. Everything below turns an exception into a
614
+ // result the model reads and works around, which is what
615
+ // `refuse` is for — doing it to a `halt` would silently
616
+ // demote the one verdict that says the run must not
617
+ // continue.
618
+ if (err instanceof ToolResultHalted) {
619
+ span.setAttributes({
620
+ [NAMZU.TOOL_SUCCESS]: false,
621
+ [NAMZU.TOOL_ERROR]: err.message,
622
+ })
623
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message })
624
+ throw err
625
+ }
589
626
  const errorMessage = toErrorMessage(err)
590
627
  this.log.error(`Tool execution error: ${toolName}`, {
591
628
  error: errorMessage,
@@ -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
+ }
@@ -0,0 +1,52 @@
1
+ import type { ToolDefinition } from '../types/tool/index.js'
2
+
3
+ /**
4
+ * May this tool's read-only claim settle a gate on its own?
5
+ *
6
+ * A connected server declares whether its own tools are read-only, and
7
+ * that declaration decided whether a call was approved without asking. The
8
+ * thing being gated supplied the input to the gate.
9
+ *
10
+ * The wire itself calls these fields HINTS. Three separate consumers read
11
+ * them as facts, so a server setting `readOnlyHint: true` and
12
+ * `destructiveHint: false` controlled the whole predicate for its own
13
+ * tools — the kernel's `allow_read_only` rule, the operator prompt
14
+ * exemption, and the plan-mode pass.
15
+ *
16
+ * The estate floor this repository inherits already decided this: least
17
+ * privilege, default deny, and — on tool results and fetched content —
18
+ * data is not instructions, and untrusted content cannot escalate
19
+ * capabilities. A server's declaration about its own tools is untrusted
20
+ * content by that definition. This is that rule applied, not a new policy.
21
+ *
22
+ * **The asymmetry is the design.** A self-declaration may RAISE the
23
+ * requirement and never LOWER it:
24
+ *
25
+ * - `destructiveHint: true` from a server is believed. A server
26
+ * volunteering that its tool is dangerous moves toward caution, and
27
+ * disbelieving it buys nothing.
28
+ * - `readOnlyHint: true` from a server does not, on its own, settle a
29
+ * call as allowed or skip a prompt. That is the untrusted party
30
+ * opening its own gate.
31
+ *
32
+ * Trust for the second case comes from the operator, per server, and is
33
+ * recorded on the tool as `provenance.readOnlyHintTrusted`. Never a global
34
+ * switch: one flag meaning "trust annotations" hands every connected
35
+ * server the same reach, which is the hole restated.
36
+ *
37
+ * `isReadOnly` itself is left reporting faithfully what the server said.
38
+ * Provenance and policy are different questions, and collapsing them would
39
+ * corrupt the outbound re-export and the prompt's own destructive label in
40
+ * order to fix a gate.
41
+ */
42
+ export function isTrustedReadOnly(tool: ToolDefinition | undefined, input: unknown): boolean {
43
+ if (!tool?.isReadOnly) return false
44
+
45
+ // No provenance means the tool is host-defined: it came from this
46
+ // process, from code the operator installed, and there is no untrusted
47
+ // party in the chain. Requiring an opt-in for a builtin would break
48
+ // every read-only exemption for no gain in trust.
49
+ if (tool.provenance && !tool.provenance.readOnlyHintTrusted) return false
50
+
51
+ return tool.isReadOnly(input)
52
+ }
@@ -2,12 +2,32 @@
2
2
  * Framing for content the agent did not author and must not obey.
3
3
  *
4
4
  * An unlabelled block of text in a tool result reads exactly like the agent's
5
- * own instructions. The mitigation that survives contact with a real model is
6
- * not filtering — it is saying plainly whose words these are and that they are
5
+ * own instructions. This says plainly whose words these are and that they are
7
6
  * material rather than direction. That is the floor this estate already
8
7
  * states: data is not instructions, and a tool result cannot escalate what an
9
8
  * agent may do.
10
9
  *
10
+ * **It marks provenance. It refuses nothing, and it does not stop an
11
+ * attacker who is trying.** This paragraph used to claim the framing was
12
+ * "the mitigation that survives contact with a real model", and that is
13
+ * measurably wrong. Nasr et al., "The Attacker Moves Second"
14
+ * (arXiv:2510.09023), broke twelve published defences at above 90% attack
15
+ * success once the attacker adapts; the majority had originally reported
16
+ * near-zero success. Delimiting specifically goes from as low as 1% under
17
+ * a static benchmark to above 95% under adaptive attack.
18
+ *
19
+ * So read every number for a prompt-level defence as static unless it says
20
+ * otherwise, and treat this envelope as raising cost rather than as a
21
+ * boundary. What survives an adapting attacker in the same literature is
22
+ * architectural: AgentDojo (arXiv:2406.13352) found tool isolation and
23
+ * tool filtering the effective mitigations, and this repository's real
24
+ * boundaries are of that kind — the permission gate, the sandbox, the
25
+ * egress proxy deciding by resolved address.
26
+ *
27
+ * The two details below still matter. They are what stops the framing
28
+ * being trivially removable by the content itself, which is a lower bar
29
+ * than stopping an attacker and worth clearing anyway.
30
+ *
11
31
  * Two details make the difference between a boundary and a decoration, and
12
32
  * both were missing from this repo's first envelope:
13
33
  *
@@ -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>
@@ -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'
@@ -322,6 +327,36 @@ export interface ToolDefinition<TInput = unknown> {
322
327
  isReadOnly?(input: TInput): boolean
323
328
  isDestructive?(input: TInput): boolean
324
329
  isConcurrencySafe?(input: TInput): boolean
330
+
331
+ /**
332
+ * Where this tool came from, when it did not come from here.
333
+ *
334
+ * Absent means host-defined: this process, code the operator installed,
335
+ * no untrusted party in the chain. Present means a connected server
336
+ * supplied both the tool and its own description of what the tool does
337
+ * — including whether it is read-only, which three separate gates were
338
+ * treating as a fact rather than as the hint the wire calls it.
339
+ *
340
+ * See {@link isTrustedReadOnly}. This field exists so a gate can tell
341
+ * the two apart; `isReadOnly` keeps reporting faithfully what the
342
+ * server said, because the outbound re-export and the destructive
343
+ * label shown to a human both need the server's own answer.
344
+ */
345
+ provenance?: ToolProvenance
346
+ }
347
+
348
+ export interface ToolProvenance {
349
+ /** The connected server this tool came from, named as configured. */
350
+ readonly server: string
351
+ /**
352
+ * The operator marked this server's read-only claims as trustworthy.
353
+ *
354
+ * Per server, never global: one switch meaning "trust annotations"
355
+ * hands every connected server the same reach, which is the hole it
356
+ * would be closing. Default false — an unmarked server's claim raises
357
+ * the requirement and never lowers it.
358
+ */
359
+ readonly readOnlyHintTrusted: boolean
325
360
  }
326
361
 
327
362
  export type ToolPermission =
@@ -360,6 +395,16 @@ export interface ToolTierConfig {
360
395
  export interface ToolRegistryConfig {
361
396
  logger?: Logger
362
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[]
363
408
  }
364
409
 
365
410
  export interface ToolExecutionResult extends ToolResult {
@@ -1,4 +1,5 @@
1
1
  import { DANGEROUS_PATTERNS } from '../constants/tools/index.js'
2
+ import { isTrustedReadOnly } from '../tools/trusted-read-only.js'
2
3
  import type { ToolDefinition } from '../types/tool/index.js'
3
4
  import type { GateDecision, VerificationRule } from '../types/verification/index.js'
4
5
 
@@ -12,7 +13,10 @@ export function evaluateRule(
12
13
  ): GateDecision | null {
13
14
  switch (rule.type) {
14
15
  case 'allow_read_only': {
15
- return toolDef?.isReadOnly?.(toolInput) ? 'allow' : null
16
+ // A server's own claim about its own tool cannot settle this. See
17
+ // `isTrustedReadOnly`: a self-declaration may raise the requirement
18
+ // and never lower it.
19
+ return isTrustedReadOnly(toolDef, toolInput) ? 'allow' : null
16
20
  }
17
21
 
18
22
  case 'deny_dangerous_patterns': {