Skip to content

Commit 8491155

Browse files
author
Zoo (VP)
committed
feat(error): define error contracts and classification types
1 parent 992585f commit 8491155

5 files changed

Lines changed: 2342 additions & 0 deletions

File tree

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { ERROR_PATTERNS } from "./errorPatterns"
2+
import type { ClassifyOptions, ErrorClassification, ErrorPattern, InterceptionSignal } from "./types"
3+
4+
// ---------------------------------------------------------------------------
5+
// Safe-identifier validation (prompt-injection prevention)
6+
// ---------------------------------------------------------------------------
7+
8+
const SAFE_IDENTIFIER_RE = /^[a-zA-Z_][\w.]*$/
9+
const MAX_PARAM_NAME_LENGTH = 128
10+
11+
/**
12+
* Returns `true` only when `name` is a safe identifier suitable for
13+
* interpolation into model-facing guidance text.
14+
*
15+
* Accepts plain identifiers (`path`, `file_pattern`) and dotted member
16+
* access chains (`options.timeout`). Rejects anything that could carry
17+
* prompt-injection payloads: newlines, quotes, angle brackets, brackets,
18+
* shell metacharacters, backslashes, and overlength strings.
19+
*/
20+
export function isValidIdentifier(name: string | undefined): boolean {
21+
if (typeof name !== "string") return false
22+
if (name.length === 0 || name.length > MAX_PARAM_NAME_LENGTH) return false
23+
if (!SAFE_IDENTIFIER_RE.test(name)) return false
24+
// Reject instruction-like patterns.
25+
if (/[\n\r"'><\[\]{}()|;`\\]/.test(name)) return false
26+
return true
27+
}
28+
29+
const SAFE_FACT_KEYS = new Set<string>([
30+
"category",
31+
"code",
32+
"commandSubmitted",
33+
"contextLengthExceeded",
34+
"contextOverflow",
35+
"contextWindowExceeded",
36+
"errorCode",
37+
"errorName",
38+
"errorSource",
39+
"errorStage",
40+
"errorType",
41+
"emptyArguments",
42+
"fileNotFound",
43+
"fileRestriction",
44+
"invalidProtocol",
45+
"missingNativeArgs",
46+
"missingParameter",
47+
"missingRequiredParameters",
48+
"modeRestriction",
49+
"parameterName",
50+
"parseFailureKind",
51+
"pathEmpty",
52+
"repetitionCount",
53+
"retryDisposition",
54+
"server",
55+
"shellIntegrationError",
56+
"status",
57+
"tool",
58+
"toolName",
59+
"type",
60+
"typeMismatch",
61+
"unknownTool",
62+
"validSiblingPresent",
63+
"xmlToolCall",
64+
])
65+
66+
const SENSITIVE_KEYS = new Set<string>([
67+
"command",
68+
"commandText",
69+
"cwd",
70+
"env",
71+
"environmentVariable",
72+
"path",
73+
"absolutePath",
74+
"homePath",
75+
"apiKey",
76+
"api_key",
77+
"token",
78+
"secret",
79+
"password",
80+
"prompt",
81+
"response",
82+
"resultText",
83+
"mcpArguments",
84+
"arguments",
85+
"args",
86+
])
87+
88+
function isSafeFactKey(key: string): boolean {
89+
if (!SAFE_FACT_KEYS.has(key)) return false
90+
return !SENSITIVE_KEYS.has(key)
91+
}
92+
93+
function hasToolContext(signal: InterceptionSignal): boolean {
94+
return signal.toolName !== undefined || signal.toolCallId !== undefined
95+
}
96+
97+
/**
98+
* Extract a parameter name from an error message or result text.
99+
*
100+
* Common patterns from tool execution errors:
101+
* - "Required parameter 'path' is missing"
102+
* - "The 'path' parameter must be a string"
103+
* - "Missing required parameter: command"
104+
* - "parameter 'path' is required"
105+
*/
106+
function extractParameterName(signal: InterceptionSignal): string | undefined {
107+
// Check metadata first (explicitly provided by the caller).
108+
const metaName = signal.metadata["parameterName"]
109+
if (typeof metaName === "string" && metaName.length > 0) return metaName
110+
111+
// Try to extract from error.message.
112+
if (signal.error !== null && typeof signal.error === "object") {
113+
const message = (signal.error as { message?: unknown }).message
114+
if (typeof message === "string") {
115+
const name = tryExtractParamNameFromText(message)
116+
if (name) return name
117+
}
118+
}
119+
120+
// Try to extract from result.text.
121+
if (typeof signal.result === "object" && signal.result !== null) {
122+
const text = (signal.result as { text?: unknown }).text
123+
if (typeof text === "string") {
124+
const name = tryExtractParamNameFromText(text)
125+
if (name) return name
126+
}
127+
}
128+
129+
return undefined
130+
}
131+
132+
function tryExtractParamNameFromText(text: string): string | undefined {
133+
// Pattern: "parameter 'name'" or "parameter \"name\"" or "parameter: name"
134+
const paramQuoteMatch = text.match(/parameter\s*['"']([^'"']+)['"']/i)
135+
if (paramQuoteMatch) return paramQuoteMatch[1]
136+
137+
// Pattern: "Required parameter 'name'" — already covered above, but also
138+
// try "Missing required parameter: name" (colon-separated, no quotes).
139+
const colonMatch = text.match(/(?:missing|required)\s+parameter\s*[:\s]+(\w+)/i)
140+
if (colonMatch) return colonMatch[1]
141+
142+
// Pattern: "The 'name' parameter must be..." — extract the quoted name
143+
// before the word "parameter".
144+
const theParamMatch = text.match(/the\s+['"']([^'"']+)['"']\s+parameter/i)
145+
if (theParamMatch) return theParamMatch[1]
146+
147+
return undefined
148+
}
149+
150+
function isEligible(pattern: ErrorPattern, signal: InterceptionSignal): boolean {
151+
if (pattern.category === "UNCLASSIFIED") return false
152+
return !pattern.requiresToolContext || hasToolContext(signal)
153+
}
154+
155+
function sanitizeFacts(signal: InterceptionSignal, pattern: ErrorPattern): Readonly<Record<string, unknown>> {
156+
const facts: Record<string, unknown> = {}
157+
158+
for (const key of Object.keys(signal.metadata)) {
159+
if (!isSafeFactKey(key)) continue
160+
161+
const value = signal.metadata[key]
162+
if (value === undefined || value === null) continue
163+
164+
if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
165+
facts[key] = value
166+
continue
167+
}
168+
169+
// Arrays of primitive tool/server identifiers only.
170+
if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
171+
facts[key] = value
172+
}
173+
}
174+
175+
// Validate metadata-provided parameterName through the same
176+
// safe-identifier check. The loop above copies metadata values
177+
// verbatim, so an unsafe parameterName from metadata would bypass
178+
// the extraction-path validation below.
179+
if (typeof facts.parameterName === "string" && !isValidIdentifier(facts.parameterName)) {
180+
delete facts.parameterName
181+
}
182+
183+
facts.pattern = pattern.id
184+
facts.category = pattern.category
185+
facts.errorSource = signal.source
186+
187+
// Inject extracted parameter name for PARAM_MISSING and generic
188+
// PARAM_TYPE_MISMATCH patterns so the transformer can include it in
189+
// guidance messages. Skip the CWD_OBJECT_MISUSE and NESTED_PARAM_OVERFLOW
190+
// variants — they have their own specific guidance.
191+
if (
192+
pattern.category === "PARAM_MISSING" ||
193+
(pattern.category === "PARAM_TYPE_MISMATCH" && pattern.id === "EI/PARAM_TYPE_MISMATCH/001")
194+
) {
195+
if (facts.parameterName === undefined) {
196+
const paramName = extractParameterName(signal)
197+
// Only store the parameter name if it passes the safe-identifier
198+
// check. Untrusted content (file contents, shell/MCP output) can
199+
// flow through error messages and result text, so we must reject
200+
// anything that looks like a prompt-injection payload.
201+
if (paramName !== undefined && isValidIdentifier(paramName)) {
202+
facts.parameterName = paramName
203+
}
204+
}
205+
}
206+
207+
return Object.freeze(facts)
208+
}
209+
210+
export function classifyError(signal: InterceptionSignal, _options?: ClassifyOptions): ErrorClassification {
211+
// First pass: exact/structural matchers only.
212+
for (const pattern of ERROR_PATTERNS) {
213+
if (!isEligible(pattern, signal)) continue
214+
if (pattern.matches(signal)) {
215+
return {
216+
category: pattern.category,
217+
patternId: pattern.id,
218+
confidence: "exact",
219+
retryPolicy: pattern.retryPolicy,
220+
facts: sanitizeFacts(signal, pattern),
221+
}
222+
}
223+
}
224+
225+
// Second pass: heuristic fallback matchers, excluding the UNCLASSIFIED
226+
// catch-all at the end of the list.
227+
for (const pattern of ERROR_PATTERNS) {
228+
if (!isEligible(pattern, signal)) continue
229+
if (pattern.fallback?.(signal)) {
230+
return {
231+
category: pattern.category,
232+
patternId: pattern.id,
233+
confidence: "heuristic",
234+
retryPolicy: pattern.retryPolicy,
235+
facts: sanitizeFacts(signal, pattern),
236+
}
237+
}
238+
}
239+
240+
// UNCLASSIFIED catch-all.
241+
const fallback = ERROR_PATTERNS[ERROR_PATTERNS.length - 1]
242+
return {
243+
category: fallback.category,
244+
patternId: fallback.id,
245+
confidence: "heuristic",
246+
retryPolicy: fallback.retryPolicy,
247+
facts: sanitizeFacts(signal, fallback),
248+
}
249+
}
250+
251+
/** Convenience helper to classify a structured tool result directly. */
252+
export function classifyToolResult(
253+
result: InterceptionSignal["result"],
254+
taskId: string,
255+
toolCallId?: string,
256+
): ErrorClassification {
257+
const metadata: Record<string, unknown> = {}
258+
if (result && typeof result === "object") {
259+
if (result.status) metadata.status = result.status
260+
if (result.type) metadata.type = result.type
261+
}
262+
263+
const signal: InterceptionSignal = {
264+
source: "tool_result",
265+
stage: "result",
266+
taskId,
267+
toolCallId,
268+
result: result ?? undefined,
269+
metadata,
270+
}
271+
return classifyError(signal)
272+
}

0 commit comments

Comments
 (0)