Skip to content

Commit 14ad8eb

Browse files
author
Zoo (VP)
committed
feat(error): add error transformation and interception runtime
1 parent 8491155 commit 14ad8eb

9 files changed

Lines changed: 3663 additions & 1 deletion

src/core/tools/error-interception/MessageTransformer.ts

Lines changed: 483 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import type { InterceptionSignal } from "./types"
2+
3+
/**
4+
* Pure structural validators for native tool arguments.
5+
*
6+
* These validators run after the native parser has produced final arguments
7+
* and before tool approval/execution. They never mutate input, never push
8+
* results, and never read Task state. Each function returns either an
9+
* InterceptionSignal describing a sanitized structural issue, or null when
10+
* the input is structurally acceptable.
11+
*
12+
* Sanitization contract: signals carry only structural identifiers (variant
13+
* name, parameter key, expected/actual type, nested tool signature). Raw
14+
* argument values, command bodies, absolute paths, and file contents are
15+
* never copied into signal metadata.
16+
*/
17+
18+
/** Variant emitted when execute_command.cwd is present but not a string. */
19+
export const VARIANT_CWD_OBJECT_MISUSE = "CWD_OBJECT_MISUSE"
20+
21+
/** Variant emitted when a scalar parameter contains a nested tool input object. */
22+
export const VARIANT_NESTED_PARAM_OVERFLOW = "NESTED_PARAM_OVERFLOW"
23+
24+
/** Maximum recursion depth for nested-tool detection. */
25+
export const NESTED_DETECTION_MAX_DEPTH = 4
26+
27+
/** Maximum number of nodes visited during nested-tool detection. */
28+
export const NESTED_DETECTION_MAX_NODES = 64
29+
30+
/**
31+
* Parameters that legitimately accept non-string/object values and are
32+
* excluded from nested-tool detection. These are the known structural
33+
* exceptions where an object value is part of the declared schema.
34+
*/
35+
const OBJECT_ALLOWED_PARAMETERS: Readonly<Record<string, ReadonlySet<string>>> = {
36+
read_file: new Set(["indentation"]),
37+
use_mcp_tool: new Set(["arguments"]),
38+
}
39+
40+
/**
41+
* Known tool-shaped signatures. A nested object is treated as a tool input
42+
* only when it contains at least one of these key sets. Matching requires
43+
* all listed keys to be present in the same object.
44+
*/
45+
const TOOL_SIGNATURE_KEY_SETS: ReadonlyArray<ReadonlyArray<string>> = [
46+
["command"],
47+
["path", "regex"],
48+
["query", "path"],
49+
["server_name", "tool_name"],
50+
["path", "content"],
51+
["pattern", "file_pattern"],
52+
]
53+
54+
/**
55+
* Recognized parameter keys used for the "multiple known keys from a
56+
* different invocation" heuristic. Two or more of these keys appearing
57+
* together inside a nested object is treated as a tool input signature.
58+
*/
59+
const KNOWN_PARAMETER_KEYS: ReadonlySet<string> = new Set([
60+
"command",
61+
"cwd",
62+
"path",
63+
"regex",
64+
"file_pattern",
65+
"query",
66+
"content",
67+
"diff",
68+
"pattern",
69+
"server_name",
70+
"tool_name",
71+
"arguments",
72+
"uri",
73+
"line_number",
74+
"offset",
75+
"limit",
76+
"mode",
77+
"prompt",
78+
"slug",
79+
"name",
80+
"message",
81+
"todos",
82+
])
83+
84+
interface CwdValidationFacts {
85+
parameter: "cwd"
86+
expectedType: "string"
87+
actualType: "array" | "object" | "number" | "boolean" | "null"
88+
}
89+
90+
function classifyActualType(
91+
value: unknown,
92+
): CwdValidationFacts["actualType"] | "string" | "undefined" | "function" | "symbol" | "bigint" {
93+
if (value === null) return "null"
94+
if (Array.isArray(value)) return "array"
95+
const t = typeof value
96+
if (
97+
t === "object" ||
98+
t === "number" ||
99+
t === "boolean" ||
100+
t === "string" ||
101+
t === "undefined" ||
102+
t === "function" ||
103+
t === "symbol" ||
104+
t === "bigint"
105+
) {
106+
return t
107+
}
108+
return "object"
109+
}
110+
111+
function buildSignal(
112+
source: InterceptionSignal["source"],
113+
stage: InterceptionSignal["stage"],
114+
toolName: string | undefined,
115+
metadata: Readonly<Record<string, unknown>>,
116+
): InterceptionSignal {
117+
return {
118+
source,
119+
stage,
120+
taskId: "",
121+
toolName,
122+
metadata,
123+
}
124+
}
125+
126+
/**
127+
* Validates the `cwd` parameter of an `execute_command` invocation.
128+
*
129+
* Returns a signal with variant CWD_OBJECT_MISUSE when `cwd` is present and
130+
* is not a string. Empty strings and missing values are accepted (the
131+
* downstream tool treats them as "use workspace default").
132+
*
133+
* The validator is tool-agnostic: callers should only invoke it for
134+
* `execute_command`. It does not check the tool name itself.
135+
*/
136+
export function validateCwdParameter(args: Record<string, unknown>, toolName?: string): InterceptionSignal | null {
137+
if (!("cwd" in args)) {
138+
return null
139+
}
140+
const cwd = args.cwd
141+
if (cwd === undefined || typeof cwd === "string") {
142+
return null
143+
}
144+
const actualType = classifyActualType(cwd)
145+
const metadata: Readonly<Record<string, unknown>> = {
146+
variant: VARIANT_CWD_OBJECT_MISUSE,
147+
parameter: "cwd",
148+
expectedType: "string",
149+
actualType,
150+
}
151+
return buildSignal("validation", "preflight", toolName, metadata)
152+
}
153+
154+
/**
155+
* Detects the shape of a nested tool invocation inside an object.
156+
* Returns the matched signature label (for example "command" or
157+
* "path+regex") or undefined when the object does not look like a tool
158+
* input.
159+
*/
160+
function detectToolSignature(value: Record<string, unknown>): string | undefined {
161+
for (const keySet of TOOL_SIGNATURE_KEY_SETS) {
162+
let allPresent = true
163+
for (const key of keySet) {
164+
if (!(key in value)) {
165+
allPresent = false
166+
break
167+
}
168+
}
169+
if (allPresent) {
170+
return keySet.join("+")
171+
}
172+
}
173+
let knownKeyCount = 0
174+
for (const key of Object.keys(value)) {
175+
if (KNOWN_PARAMETER_KEYS.has(key)) {
176+
knownKeyCount += 1
177+
if (knownKeyCount >= 2) {
178+
return "multi-known-keys"
179+
}
180+
}
181+
}
182+
return undefined
183+
}
184+
185+
interface NestedSearchResult {
186+
found: boolean
187+
parameter?: string
188+
signature?: string
189+
depthExceeded?: boolean
190+
nodeLimitExceeded?: boolean
191+
cycleDetected?: boolean
192+
}
193+
194+
function visitNested(
195+
value: unknown,
196+
topParameter: string,
197+
depth: number,
198+
state: { visited: number; seen: Set<unknown> },
199+
): NestedSearchResult {
200+
if (value === null || typeof value !== "object") {
201+
return { found: false }
202+
}
203+
if (state.seen.has(value)) {
204+
return { found: false, cycleDetected: true }
205+
}
206+
state.seen.add(value)
207+
state.visited += 1
208+
if (state.visited > NESTED_DETECTION_MAX_NODES) {
209+
return { found: false, nodeLimitExceeded: true }
210+
}
211+
if (depth > NESTED_DETECTION_MAX_DEPTH) {
212+
return { found: false, depthExceeded: true }
213+
}
214+
215+
if (Array.isArray(value)) {
216+
for (const item of value) {
217+
const nested = visitNested(item, topParameter, depth + 1, state)
218+
if (nested.found || nested.cycleDetected || nested.depthExceeded || nested.nodeLimitExceeded) {
219+
return nested
220+
}
221+
}
222+
state.seen.delete(value)
223+
return { found: false }
224+
}
225+
226+
const record = value as Record<string, unknown>
227+
const signature = detectToolSignature(record)
228+
if (signature !== undefined) {
229+
return { found: true, parameter: topParameter, signature }
230+
}
231+
for (const child of Object.values(record)) {
232+
const nested = visitNested(child, topParameter, depth + 1, state)
233+
if (nested.found || nested.cycleDetected || nested.depthExceeded || nested.nodeLimitExceeded) {
234+
return nested
235+
}
236+
}
237+
state.seen.delete(value)
238+
return { found: false }
239+
}
240+
241+
/**
242+
* Validates that no scalar tool parameter contains a nested tool input
243+
* object. Detection is bounded (depth 4, 64 visited nodes) and cycle-safe.
244+
* Parameters explicitly allowed to carry object values (such as
245+
* `read_file.indentation` and `use_mcp_tool.arguments`) are skipped.
246+
*
247+
* Returns a signal with variant NESTED_PARAM_OVERFLOW on detection, or null
248+
* when every parameter is structurally clean.
249+
*/
250+
export function validateNestedParams(args: Record<string, unknown>, toolName: string): InterceptionSignal | null {
251+
const allowList = OBJECT_ALLOWED_PARAMETERS[toolName]
252+
for (const [key, value] of Object.entries(args)) {
253+
if (allowList && allowList.has(key)) {
254+
continue
255+
}
256+
if (value === null || typeof value !== "object") {
257+
continue
258+
}
259+
const state = { visited: 0, seen: new Set<unknown>() }
260+
const result = visitNested(value, key, 1, state)
261+
if (result.found) {
262+
const metadata: Readonly<Record<string, unknown>> = {
263+
variant: VARIANT_NESTED_PARAM_OVERFLOW,
264+
parameter: result.parameter,
265+
structuralReason: `nested-tool-input:${result.signature}`,
266+
}
267+
return buildSignal("validation", "preflight", toolName, metadata)
268+
}
269+
if (result.cycleDetected) {
270+
const metadata: Readonly<Record<string, unknown>> = {
271+
variant: VARIANT_NESTED_PARAM_OVERFLOW,
272+
parameter: key,
273+
structuralReason: "cyclic-structure",
274+
}
275+
return buildSignal("validation", "preflight", toolName, metadata)
276+
}
277+
}
278+
return null
279+
}

0 commit comments

Comments
 (0)