validation.ts
1 import type { ConfigScope } from 'src/services/mcp/types.js' 2 import type { ZodError, ZodIssue } from 'zod/v4' 3 import { jsonParse } from '../slowOperations.js' 4 import { plural } from '../stringUtils.js' 5 import { validatePermissionRule } from './permissionValidation.js' 6 import { generateSettingsJSONSchema } from './schemaOutput.js' 7 import type { SettingsJson } from './types.js' 8 import { SettingsSchema } from './types.js' 9 import { getValidationTip } from './validationTips.js' 10 11 /** 12 * Helper type guards for specific Zod v4 issue types 13 * In v4, issue types have different structures than v3 14 */ 15 function isInvalidTypeIssue(issue: ZodIssue): issue is ZodIssue & { 16 code: 'invalid_type' 17 expected: string 18 input: unknown 19 } { 20 return issue.code === 'invalid_type' 21 } 22 23 function isInvalidValueIssue(issue: ZodIssue): issue is ZodIssue & { 24 code: 'invalid_value' 25 values: unknown[] 26 input: unknown 27 } { 28 return issue.code === 'invalid_value' 29 } 30 31 function isUnrecognizedKeysIssue( 32 issue: ZodIssue, 33 ): issue is ZodIssue & { code: 'unrecognized_keys'; keys: string[] } { 34 return issue.code === 'unrecognized_keys' 35 } 36 37 function isTooSmallIssue(issue: ZodIssue): issue is ZodIssue & { 38 code: 'too_small' 39 minimum: number | bigint 40 origin: string 41 } { 42 return issue.code === 'too_small' 43 } 44 45 /** Field path in dot notation (e.g., "permissions.defaultMode", "env.DEBUG") */ 46 export type FieldPath = string 47 48 export type ValidationError = { 49 /** Relative file path */ 50 file?: string 51 /** Field path in dot notation */ 52 path: FieldPath 53 /** Human-readable error message */ 54 message: string 55 /** Expected value or type */ 56 expected?: string 57 /** The actual invalid value that was provided */ 58 invalidValue?: unknown 59 /** Suggestion for fixing the error */ 60 suggestion?: string 61 /** Link to relevant documentation */ 62 docLink?: string 63 /** MCP-specific metadata - only present for MCP configuration errors */ 64 mcpErrorMetadata?: { 65 /** Which configuration scope this error came from */ 66 scope: ConfigScope 67 /** The server name if error is specific to a server */ 68 serverName?: string 69 /** Severity of the error */ 70 severity?: 'fatal' | 'warning' 71 } 72 } 73 74 export type SettingsWithErrors = { 75 settings: SettingsJson 76 errors: ValidationError[] 77 } 78 79 /** 80 * Format a Zod validation error into human-readable validation errors 81 */ 82 /** 83 * Get the type string for an unknown value (for error messages) 84 */ 85 function getReceivedType(value: unknown): string { 86 if (value === null) return 'null' 87 if (value === undefined) return 'undefined' 88 if (Array.isArray(value)) return 'array' 89 return typeof value 90 } 91 92 function extractReceivedFromMessage(msg: string): string | undefined { 93 const match = msg.match(/received (\w+)/) 94 return match ? match[1] : undefined 95 } 96 97 export function formatZodError( 98 error: ZodError, 99 filePath: string, 100 ): ValidationError[] { 101 return error.issues.map((issue): ValidationError => { 102 const path = issue.path.map(String).join('.') 103 let message = issue.message 104 let expected: string | undefined 105 106 let enumValues: string[] | undefined 107 let expectedValue: string | undefined 108 let receivedValue: unknown 109 let invalidValue: unknown 110 111 if (isInvalidValueIssue(issue)) { 112 enumValues = issue.values.map(v => String(v)) 113 expectedValue = enumValues.join(' | ') 114 receivedValue = undefined 115 invalidValue = undefined 116 } else if (isInvalidTypeIssue(issue)) { 117 expectedValue = issue.expected 118 const receivedType = extractReceivedFromMessage(issue.message) 119 receivedValue = receivedType ?? getReceivedType(issue.input) 120 invalidValue = receivedType ?? getReceivedType(issue.input) 121 } else if (isTooSmallIssue(issue)) { 122 expectedValue = String(issue.minimum) 123 } else if (issue.code === 'custom' && 'params' in issue) { 124 const params = issue.params as { received?: unknown } 125 receivedValue = params.received 126 invalidValue = receivedValue 127 } 128 129 const tip = getValidationTip({ 130 path, 131 code: issue.code, 132 expected: expectedValue, 133 received: receivedValue, 134 enumValues, 135 message: issue.message, 136 value: receivedValue, 137 }) 138 139 if (isInvalidValueIssue(issue)) { 140 expected = enumValues?.map(v => `"${v}"`).join(', ') 141 message = `Invalid value. Expected one of: ${expected}` 142 } else if (isInvalidTypeIssue(issue)) { 143 const receivedType = 144 extractReceivedFromMessage(issue.message) ?? 145 getReceivedType(issue.input) 146 if ( 147 issue.expected === 'object' && 148 receivedType === 'null' && 149 path === '' 150 ) { 151 message = 'Invalid or malformed JSON' 152 } else { 153 message = `Expected ${issue.expected}, but received ${receivedType}` 154 } 155 } else if (isUnrecognizedKeysIssue(issue)) { 156 const keys = issue.keys.join(', ') 157 message = `Unrecognized ${plural(issue.keys.length, 'field')}: ${keys}` 158 } else if (isTooSmallIssue(issue)) { 159 message = `Number must be greater than or equal to ${issue.minimum}` 160 expected = String(issue.minimum) 161 } 162 163 return { 164 file: filePath, 165 path, 166 message, 167 expected, 168 invalidValue, 169 suggestion: tip?.suggestion, 170 docLink: tip?.docLink, 171 } 172 }) 173 } 174 175 /** 176 * Validates that settings file content conforms to the SettingsSchema. 177 * This is used during file edits to ensure the resulting file is valid. 178 */ 179 export function validateSettingsFileContent(content: string): 180 | { 181 isValid: true 182 } 183 | { 184 isValid: false 185 error: string 186 fullSchema: string 187 } { 188 try { 189 // Parse the JSON first 190 const jsonData = jsonParse(content) 191 192 // Validate against SettingsSchema in strict mode 193 const result = SettingsSchema().strict().safeParse(jsonData) 194 195 if (result.success) { 196 return { isValid: true } 197 } 198 199 // Format the validation error in a helpful way 200 const errors = formatZodError(result.error, 'settings') 201 const errorMessage = 202 'Settings validation failed:\n' + 203 errors.map(err => `- ${err.path}: ${err.message}`).join('\n') 204 205 return { 206 isValid: false, 207 error: errorMessage, 208 fullSchema: generateSettingsJSONSchema(), 209 } 210 } catch (parseError) { 211 return { 212 isValid: false, 213 error: `Invalid JSON: ${parseError instanceof Error ? parseError.message : 'Unknown parsing error'}`, 214 fullSchema: generateSettingsJSONSchema(), 215 } 216 } 217 } 218 219 /** 220 * Filters invalid permission rules from raw parsed JSON data before schema validation. 221 * This prevents one bad rule from poisoning the entire settings file. 222 * Returns warnings for each filtered rule. 223 */ 224 export function filterInvalidPermissionRules( 225 data: unknown, 226 filePath: string, 227 ): ValidationError[] { 228 if (!data || typeof data !== 'object') return [] 229 const obj = data as Record<string, unknown> 230 if (!obj.permissions || typeof obj.permissions !== 'object') return [] 231 const perms = obj.permissions as Record<string, unknown> 232 233 const warnings: ValidationError[] = [] 234 for (const key of ['allow', 'deny', 'ask']) { 235 const rules = perms[key] 236 if (!Array.isArray(rules)) continue 237 238 perms[key] = rules.filter(rule => { 239 if (typeof rule !== 'string') { 240 warnings.push({ 241 file: filePath, 242 path: `permissions.${key}`, 243 message: `Non-string value in ${key} array was removed`, 244 invalidValue: rule, 245 }) 246 return false 247 } 248 const result = validatePermissionRule(rule) 249 if (!result.valid) { 250 let message = `Invalid permission rule "${rule}" was skipped` 251 if (result.error) message += `: ${result.error}` 252 if (result.suggestion) message += `. ${result.suggestion}` 253 warnings.push({ 254 file: filePath, 255 path: `permissions.${key}`, 256 message, 257 invalidValue: rule, 258 }) 259 return false 260 } 261 return true 262 }) 263 } 264 return warnings 265 }