src/json-schema.js
Rama master · réplica de lectura del repositorio original.
/**
* 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 };
}