|
| 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