/** * A bounded, dependency-free JSON Schema validator. * * Why this exists: `response_format: {type:'json_schema'}` (and Anthropic's * `output_config.format`) is a promise to the caller that the body conforms to * its schema. This proxy cannot make the model obey — the upstream has no such * control — so the honest thing is to CHECK the answer and say so when it does * not hold. Before this module the code only "stabilised" the payload by * projecting it onto the requested key names, inventing `null`/`true` for keys * the model never produced (KH-013). * * Scope, deliberately: the keyword subset real callers actually ship — * type/enum/const, object properties/required/additionalProperties, array * items/minItems/maxItems/uniqueItems, string min/maxLength/pattern, numeric * bounds, and the anyOf/oneOf/allOf/not combinators, plus local `$ref` * (`#/$defs/...`, `#/definitions/...`). Unknown keywords are IGNORED, which is * exactly what the JSON Schema spec requires of an implementation that does not * know an annotation — it must never turn "I don't understand this" into a * failure. A keyword this module DOES support, written malformed (an invalid * `pattern`), is a different thing: the schema is not one we can evaluate, and * that is reported as `unable` rather than folded into a pass. * * A `$ref` cycle is not followed: the depth guard reports non-conformance for a * schema that cannot be evaluated rather than looping forever. * * Every judgement is one of exactly three things, and the three are never * blurred. "I could not judge, so it passes" is a lie the caller pays for: a * schema whose `pattern` was skipped because it looked dangerous used to report * `{ok:true}`, so a payload that genuinely violated the schema was returned as * a conforming answer, and `not`/`oneOf`/`anyOf` inverted the skip into * nonsense (a safe `^(cat|dog)+$` was "refused", making `not` reject a value it * should accept and `oneOf` count two matches). * * valid -> { ok: true } * invalid -> { ok: false, path, message } a real conformance failure * unable -> { ok: false, path, message, unable: true, code: 'SCHEMA_UNVALIDATED' } * the schema asked for work this module will not spend: a pattern * that exhausts the request's execution budget, a pattern or value * past its size cap, or a schema past the traversal ceiling. * * `unable` is NOT a pass and NOT a conformance failure: callers must surface it * as its own typed failure (the chat handlers map it to * STRUCTURED_OUTPUT_UNVALIDATED), and the combinators propagate it instead of * folding it into a boolean. */ import { Script, createContext } from 'node:vm'; const MAX_REF_DEPTH = 32; // --------------------------------------------------------------------------- // Pattern execution budget // // A caller-supplied `pattern` is compiled by the platform RegExp engine, which // backtracks: catastrophic nesting makes the match cost exponential in the // length of the MODEL's output, so a caller can aim a regex bomb at this // process's event loop on the request path. A static shape scan cannot close // that — the canonical bomb has an unbounded number of spellings. `^((a|a))+$` // defeats a "top-level alternation under a repeat" check by adding one harmless // group; adjacent quantified atoms (`a*a*a*…$`), backreferences and // bounded-but-large inner repeats defeat the nesting check. // // The only honest bound is to RUN the match under a watchdog that can interrupt // the engine and to say "I could not judge" when it fires. node:vm provides // that synchronously, which is what this call chain needs: the four sinks in // src/handlers/chat.js are synchronous. A constant, trusted script // (`new RegExp(pattern).test(value)`) is compiled ONCE at module load; the // caller's pattern and the model's value reach it only as primitive sandbox // bindings — never interpolated into source — inside a context where code // generation (eval/Function/WASM) is disabled. // // MEASURED (Node v24.19.0, this box): V8's `timeout` does interrupt a // backtracking regexp. `^((a|a))+$` on 26 `a`s plus a near-miss throws // ERR_SCRIPT_EXECUTION_TIMEOUT; a 50 ms grant returns at ~57-61 ms (the // watchdog's overshoot). Safe patterns cost 0.2-0.5 ms each in the shared // context (`^a+$`, `^(cat|dog)+$`), 0.4 ms for `^[a-z]+$` on a 256 KiB target, // ~5 ms for `^(a|b)+$` on the same target. // // BUDGET_MS is the whole request's allowance, not each pattern's: every // `pattern` in the schema tree draws from ONE absolute deadline created at the // top-level call, so a schema with many patterns cannot multiply the bill. The // deadline is absolute, so only the time actually spent is charged — granting // the remainder to a pattern that finishes in 0.3 ms costs 0.3 ms. // // 50 ms: enough for ~100 ordinary pattern checks, and the worst case a caller // can force is one budget plus the watchdog's overshoot (~61 ms observed) — // two orders of magnitude below the 1 s that made the original bomb a release // blocker, and a fraction of the upstream I/O on the same request. const BUDGET_MS = 50; // Traversal ceiling. The schema is caller-controlled too, so its SIZE needs a // bound that no regex watchdog can provide: a 10^5-entry `allOf`, an enum with // 10^6 candidates, or an array of 10^6 `uniqueItems` elements would otherwise // burn wall-clock in plain JavaScript. Steps are charged per schema node // visited, per enum candidate compared and per uniqueItems element serialised. // 20_000 is far above any real schema (50_000 trivial stringify steps measured // ~4.6 ms) and keeps the worst case to single-digit milliseconds. const MAX_SCHEMA_STEPS = 20_000; // Input-size caps. Past either one the module says "unable", never "pass": // silently skipping the match is what let a non-conforming answer through. const MAX_PATTERN_CHARS = 1024; const MAX_PATTERN_TARGET_CHARS = 256 * 1024; const UNVALIDATED = 'SCHEMA_UNVALIDATED'; // A constant, trusted program. `strings: false` means the context cannot // compile new source, so caller input can never become code even if the regexp // engine itself is compromised. The sandbox is module state, which is safe // because validation is synchronous end to end: no other request can interleave // between writing the bindings and running the script. const PATTERN_SCRIPT = new Script('new RegExp(pattern).test(value)', { filename: 'json-schema-pattern.js' }); const PATTERN_SANDBOX = { pattern: '', value: '' }; const PATTERN_CONTEXT = createContext(PATTERN_SANDBOX, { name: 'json-schema-pattern', codeGeneration: { strings: false, wasm: false }, }); function createBudget() { return { expiresAt: performance.now() + BUDGET_MS, steps: 0, exhausted: false }; } /** * The module's bounds, exposed so a test can build a fixture at the boundary * instead of hard-coding a number that may drift from the implementation. * Read-only: the values are the defence, not configuration. */ export const __jsonSchemaLimits = Object.freeze({ BUDGET_MS, MAX_SCHEMA_STEPS, MAX_PATTERN_CHARS, MAX_PATTERN_TARGET_CHARS, }); function unableWithinBudget(path) { return { ok: false, path, message: 'schema could not be validated within budget', unable: true, code: UNVALIDATED, reason: 'budget', }; } function unableCapacity(path, detail) { return { ok: false, path, message: `schema could not be validated: ${detail}`, unable: true, code: UNVALIDATED, reason: 'capacity', }; } function unableInvalidPattern(path) { return { ok: false, path, message: 'schema could not be validated: the pattern is not a valid regular expression', unable: true, code: UNVALIDATED, reason: 'invalid-pattern', }; } /** * Charge `n` steps against the traversal ceiling. * @returns {boolean} false once the ceiling is passed. */ function chargeSteps(budget, n = 1) { budget.steps += n; return budget.steps <= MAX_SCHEMA_STEPS; } /** * Run one `pattern` assertion under the shared budget. * * `pattern` is a SEARCH assertion, not an anchored full match: the pattern text * is used verbatim, ECMA-262 semantics, no implicit `^…$` and no truncation of * the value. * * @returns {null | {ok: false, path: string, message: string, unable?: true, code?: string}} * null when the value conforms, otherwise the verdict. There is no "no * verdict, so pass" case: an invalid pattern, an over-size pattern or value, * and an exhausted budget are all reported as `unable`. */ function evaluatePattern(pattern, value, budget, path) { if (pattern.length > MAX_PATTERN_CHARS) { return unableCapacity(path, `a pattern longer than ${MAX_PATTERN_CHARS} characters cannot be evaluated`); } if (value.length > MAX_PATTERN_TARGET_CHARS) { return unableCapacity(path, `a value longer than ${MAX_PATTERN_TARGET_CHARS} characters cannot be matched against a pattern`); } if (budget.exhausted) return unableWithinBudget(path); const remaining = budget.expiresAt - performance.now(); if (!(remaining > 0)) return unableWithinBudget(path); PATTERN_SANDBOX.pattern = pattern; PATTERN_SANDBOX.value = value; let matched; try { matched = PATTERN_SCRIPT.runInContext(PATTERN_CONTEXT, { timeout: Math.max(1, Math.ceil(remaining)) }); } catch (err) { if (err && err.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') { // The engine could not finish inside what was left of the request's // budget. One bomb spends the budget for the whole schema: handing the // next pattern a fresh slice would let a caller multiply the bill by the // number of patterns in their schema. budget.exhausted = true; return unableWithinBudget(path); } if (err && err.name === 'SyntaxError') { // `pattern` is a keyword this module SUPPORTS; the caller wrote it // malformed. That is not the neutral "unknown annotation" case (which is // an unknown KEYWORD), and folding it into a pass is the same fail-open // as refusing a valid pattern: `not` would invert the skip and `oneOf` // would count it as a match. Report it as a schema we cannot evaluate. return unableInvalidPattern(path); } budget.exhausted = true; return unableCapacity(path, 'the pattern could not be evaluated'); } if (!matched) return { ok: false, path, message: `must match ${pattern}` }; return null; } function isPlainObject(v) { return v !== null && typeof v === 'object' && !Array.isArray(v); } function typeOf(value) { if (value === null) return 'null'; if (Array.isArray(value)) return 'array'; return typeof value; } function matchesType(value, wanted) { switch (wanted) { case 'object': return isPlainObject(value); case 'array': return Array.isArray(value); case 'string': return typeof value === 'string'; case 'number': return typeof value === 'number' && Number.isFinite(value); case 'integer': return typeof value === 'number' && Number.isInteger(value); case 'boolean': return typeof value === 'boolean'; case 'null': return value === null; default: return true; // an unknown type name is an annotation we cannot judge } } function resolveRef(ref, root) { if (typeof ref !== 'string' || !ref.startsWith('#')) return undefined; const path = ref.slice(1).replace(/^\//, ''); if (!path) return root; let cur = root; for (const rawPart of path.split('/')) { const part = rawPart.replace(/~1/g, '/').replace(/~0/g, '~'); if (cur == null || typeof cur !== 'object') return undefined; cur = cur[part]; } return cur; } /** * @returns {{ok: true} | {ok: false, path: string, message: string, unable?: true, code?: string}} */ export function validateJsonSchema(value, schema, { path = '$', root = schema, depth = 0, budget = null } = {}) { // Created ONCE at the top-level call and threaded through every recursive // call below: a sub-schema must never get a fresh allowance. const scope = budget ?? createBudget(); if (schema === undefined || schema === true || schema === null) return { ok: true }; if (schema === false) return { ok: false, path, message: 'no value is allowed here' }; if (!isPlainObject(schema)) return { ok: true }; if (!chargeSteps(scope)) return unableCapacity(path, `the schema exceeds ${MAX_SCHEMA_STEPS} evaluation steps`); if (performance.now() > scope.expiresAt) return unableWithinBudget(path); if (depth > MAX_REF_DEPTH) { return { ok: false, path, message: 'schema $ref nesting is too deep to evaluate' }; } if (typeof schema.$ref === 'string') { const target = resolveRef(schema.$ref, root); if (target === undefined) return { ok: false, path, message: `unresolvable schema reference ${schema.$ref}` }; const viaRef = validateJsonSchema(value, target, { path, root, depth: depth + 1, budget: scope }); if (!viaRef.ok) return viaRef; } if (schema.type !== undefined) { const wanted = Array.isArray(schema.type) ? schema.type : [schema.type]; // OpenAPI's `nullable` is an annotation on a typed schema; honour it so a // schema written for a different toolchain is not rejected for a reason the // caller never wrote down. const allowNull = schema.nullable === true && value === null; if (!allowNull && !wanted.some((t) => matchesType(value, t))) { return { ok: false, path, message: `expected ${wanted.join(' | ')}, got ${typeOf(value)}`, }; } } if (Array.isArray(schema.enum)) { // The charge comes first so an enormous enum is refused before it is // walked, and the value is serialised ONCE — the old code re-serialised it // per candidate, which is O(candidates × |value|) work the candidate count // alone did not bound. if (!chargeSteps(scope, schema.enum.length)) { return unableCapacity(path, `the schema exceeds ${MAX_SCHEMA_STEPS} evaluation steps`); } const target = JSON.stringify(value); const hit = schema.enum.some((candidate) => JSON.stringify(candidate) === target); if (!hit) return { ok: false, path, message: `must be one of ${JSON.stringify(schema.enum)}` }; } if (Object.prototype.hasOwnProperty.call(schema, 'const')) { if (JSON.stringify(schema.const) !== JSON.stringify(value)) { return { ok: false, path, message: `must equal ${JSON.stringify(schema.const)}` }; } } if (typeof value === 'number') { if (typeof schema.minimum === 'number' && value < schema.minimum) { return { ok: false, path, message: `must be >= ${schema.minimum}` }; } if (typeof schema.maximum === 'number' && value > schema.maximum) { return { ok: false, path, message: `must be <= ${schema.maximum}` }; } if (typeof schema.exclusiveMinimum === 'number' && value <= schema.exclusiveMinimum) { return { ok: false, path, message: `must be > ${schema.exclusiveMinimum}` }; } if (typeof schema.exclusiveMaximum === 'number' && value >= schema.exclusiveMaximum) { return { ok: false, path, message: `must be < ${schema.exclusiveMaximum}` }; } if (typeof schema.multipleOf === 'number' && schema.multipleOf > 0) { const ratio = value / schema.multipleOf; if (Math.abs(ratio - Math.round(ratio)) > 1e-9) { return { ok: false, path, message: `must be a multiple of ${schema.multipleOf}` }; } } } if (typeof value === 'string') { if (typeof schema.minLength === 'number' && value.length < schema.minLength) { return { ok: false, path, message: `must be at least ${schema.minLength} characters` }; } if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) { return { ok: false, path, message: `must be at most ${schema.maxLength} characters` }; } if (typeof schema.pattern === 'string') { const verdict = evaluatePattern(schema.pattern, value, scope, path); if (verdict) return verdict; } } if (Array.isArray(value)) { if (typeof schema.minItems === 'number' && value.length < schema.minItems) { return { ok: false, path, message: `must have at least ${schema.minItems} items` }; } if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) { return { ok: false, path, message: `must have at most ${schema.maxItems} items` }; } if (schema.uniqueItems === true) { if (!chargeSteps(scope, value.length)) { return unableCapacity(path, `the schema exceeds ${MAX_SCHEMA_STEPS} evaluation steps`); } const seen = new Set(value.map((v) => JSON.stringify(v))); if (seen.size !== value.length) return { ok: false, path, message: 'items must be unique' }; } const itemSchemas = Array.isArray(schema.items) ? schema.items : null; if (itemSchemas) { for (let i = 0; i < value.length; i++) { const sub = itemSchemas[i] ?? schema.additionalItems; if (sub === undefined) continue; const r = validateJsonSchema(value[i], sub, { path: `${path}[${i}]`, root, depth: depth + 1, budget: scope }); if (!r.ok) return r; } } else if (schema.items !== undefined) { for (let i = 0; i < value.length; i++) { const r = validateJsonSchema(value[i], schema.items, { path: `${path}[${i}]`, root, depth: depth + 1, budget: scope }); if (!r.ok) return r; } } } if (isPlainObject(value)) { const props = isPlainObject(schema.properties) ? schema.properties : null; if (Array.isArray(schema.required)) { for (const key of schema.required) { if (!Object.prototype.hasOwnProperty.call(value, key)) { return { ok: false, path, message: `missing required property "${key}"` }; } } } for (const [key, child] of Object.entries(value)) { const own = props && Object.prototype.hasOwnProperty.call(props, key) ? props[key] : undefined; const sub = own !== undefined ? own : schema.additionalProperties; const childPath = `${path}.${key}`; if (sub === false) return { ok: false, path: childPath, message: 'additional properties are not allowed' }; if (sub !== undefined) { const r = validateJsonSchema(child, sub, { path: childPath, root, depth: depth + 1, budget: scope }); if (!r.ok) return r; } } } if (Array.isArray(schema.allOf)) { let pending = null; for (const sub of schema.allOf) { const r = validateJsonSchema(value, sub, { path, root, depth: depth + 1, budget: scope }); if (r.unable) { pending ??= r; continue; } if (!r.ok) return r; } // A genuine counterexample in a sibling is decisive even when another // branch could not be judged; only when nothing is provably wrong do we // report that the combination itself could not be judged. if (pending) return pending; } if (Array.isArray(schema.anyOf) && schema.anyOf.length) { const results = []; let satisfied = false; for (const sub of schema.anyOf) { const r = validateJsonSchema(value, sub, { path, root, depth: depth + 1, budget: scope }); // A conforming branch is a witness: it proves the disjunction without // needing any other branch judged. if (r.ok) { satisfied = true; break; } results.push(r); } if (!satisfied) { // A branch we could not judge is not a branch that failed. const pending = results.find((r) => r.unable); if (pending) return pending; return { ok: false, path, message: `does not match any allowed shape (${results.map((r) => r.message).join('; ')})` }; } } if (Array.isArray(schema.oneOf) && schema.oneOf.length) { const results = []; let passed = 0; for (const sub of schema.oneOf) { const r = validateJsonSchema(value, sub, { path, root, depth: depth + 1, budget: scope }); results.push(r); if (r.ok) passed++; } // Two matches already disprove `oneOf`, whatever an unjudged branch would // have said; otherwise an unjudged branch could still be a second match, // so the count is not trustworthy. if (passed >= 2) return { ok: false, path, message: `must match exactly one allowed shape, matched ${passed}` }; const pending = results.find((r) => r.unable); if (pending) return pending; if (passed !== 1) return { ok: false, path, message: `must match exactly one allowed shape, matched ${passed}` }; } if (schema.not !== undefined) { const r = validateJsonSchema(value, schema.not, { path, root, depth: depth + 1, budget: scope }); // `not` is the one combinator that inverts a verdict, so an unjudged child // must never be inverted into a pass. if (r.unable) return r; if (r.ok) return { ok: false, path, message: 'must not match the forbidden shape' }; } return { ok: true }; }