/ utils / settings / validation.ts
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  }