/** * JSON Schema validation for tool inputs. * Ported from Clawd-Code's schema_validation.py. */ export interface ValidationIssue { path: string message: string } function typeName(value: unknown): string { if (value === null) return "null" if (typeof value === "boolean") return "boolean" if (typeof value === "number") { if (Number.isInteger(value)) return "integer" return "number" } if (typeof value === "string") return "string" if (Array.isArray(value)) return "array" if (typeof value === "object") return "object" return typeof value } export class ToolInputError extends Error { constructor(message: string) { super(message) this.name = "ToolInputError" } } export function validateJsonSchema( value: unknown, schema: Record, rootName = "input" ): void { const issues: ValidationIssue[] = [] validateNode(value, schema, rootName, issues) if (issues.length > 0) { const rendered = issues .slice(0, 5) .map((i) => `${i.path}: ${i.message}`) .join("; ") const suffix = issues.length > 5 ? `; (+${issues.length - 5} more)` : "" throw new ToolInputError(rendered + suffix) } } function validateNode( value: unknown, schema: Record, path: string, issues: ValidationIssue[] ): void { if ("oneOf" in schema) { const options = (schema.oneOf as Record[]) || [] if (options.some((opt) => isValid(value, opt))) return issues.push({ path, message: "does not match any allowed schema (oneOf)" }) return } if ("anyOf" in schema) { const options = (schema.anyOf as Record[]) || [] if (options.some((opt) => isValid(value, opt))) return issues.push({ path, message: "does not match any allowed schema (anyOf)" }) return } const expectedType = schema.type as string | undefined if (expectedType === "object") { if (typeof value !== "object" || value === null || Array.isArray(value)) { issues.push({ path, message: `expected object, got ${typeName(value)}` }) return } validateObject(value as Record, schema, path, issues) return } if (expectedType === "array") { if (!Array.isArray(value)) { issues.push({ path, message: `expected array, got ${typeName(value)}` }) return } const itemSchema = schema.items as Record | undefined if (itemSchema) { value.forEach((item, idx) => { validateNode(item, itemSchema, `${path}[${idx}]`, issues) }) } return } if (expectedType === "string") { if (typeof value !== "string") { issues.push({ path, message: `expected string, got ${typeName(value)}` }) } return } if (expectedType === "boolean") { if (typeof value !== "boolean") { issues.push({ path, message: `expected boolean, got ${typeName(value)}` }) } return } if (expectedType === "number") { if (typeof value !== "number") { issues.push({ path, message: `expected number, got ${typeName(value)}` }) } return } if (expectedType === "integer") { if (typeof value !== "number" || !Number.isInteger(value)) { issues.push({ path, message: `expected integer, got ${typeName(value)}` }) } return } if ("enum" in schema) { const allowed = (schema.enum as unknown[]) || [] if (!allowed.includes(value)) { issues.push({ path, message: `expected one of ${JSON.stringify(allowed)}, got ${JSON.stringify(value)}`, }) } } } function validateObject( value: Record, schema: Record, path: string, issues: ValidationIssue[] ): void { const required = new Set((schema.required as string[]) || []) const properties = (schema.properties as Record>) || {} const additional = schema.additionalProperties !== false for (const req of required) { if (!(req in value)) { issues.push({ path, message: `missing required field '${req}'` }) } } for (const [key, val] of Object.entries(value)) { const propSchema = properties[key] if (!propSchema) { if (!additional) { issues.push({ path: `${path}.${key}`, message: "unexpected field" }) } continue } validateNode(val, propSchema, `${path}.${key}`, issues) } } function isValid(value: unknown, schema: Record): boolean { const issues: ValidationIssue[] = [] validateNode(value, schema, "$", issues) return issues.length === 0 }