diff --git a/containers/agent/bounded-query-wrapper.sh b/containers/agent/bounded-query-wrapper.sh index d015e02da..57b832f8a 100755 --- a/containers/agent/bounded-query-wrapper.sh +++ b/containers/agent/bounded-query-wrapper.sh @@ -12,7 +12,7 @@ # # --repo owner/repo (exactly once) # --schema '' (exactly once; a finite response schema, see -# src/bounded-query/protocol.ts) +# src/bounded-execution/finite-disclosure.ts) # the query script on stdin # # Output contract: exactly one line of canonical JSON on stdout, nothing on @@ -34,8 +34,8 @@ SOCKET="${AWF_BOUNDED_QUERY_SOCKET:-}" ENDPOINT="${AWF_BOUNDED_QUERY_ENDPOINT:-}" CAPABILITY="${AWF_BOUNDED_QUERY_CAPABILITY:-}" PROTOCOL_VERSION=2 -# Keep in sync with MAX_SCHEMA_BYTES in src/bounded-query/protocol.ts and -# containers/bounded-query/broker/protocol.js. +# Keep in sync with MAX_SCHEMA_BYTES in src/bounded-execution/finite-disclosure.ts +# and containers/bounded-query/bounded-execution/finite-disclosure.js. MAX_SCHEMA_BYTES=4096 emit_error() { diff --git a/containers/bounded-query/Dockerfile b/containers/bounded-query/Dockerfile index 61629f4dd..a72a7e1a6 100644 --- a/containers/bounded-query/Dockerfile +++ b/containers/bounded-query/Dockerfile @@ -44,12 +44,19 @@ RUN apk add --no-cache docker-cli \ WORKDIR /opt/awf/broker COPY broker/ /opt/awf/broker/ +COPY bounded-execution/ /opt/awf/bounded-execution/ COPY query-seccomp.json /opt/awf/query-seccomp.json RUN chmod -R a-w /opt/awf \ && node --check /opt/awf/broker/server.js \ && node --check /opt/awf/broker/broker.js \ && node --check /opt/awf/broker/protocol.js \ + && node --check /opt/awf/bounded-execution/finite-disclosure.js \ + && node --check /opt/awf/bounded-execution/sensitivity-ledger.js \ + && node --check /opt/awf/bounded-execution/fixed-timing.js \ + && node --check /opt/awf/bounded-execution/protected-audit.js \ + && node --check /opt/awf/bounded-execution/repository-staging.js \ + && node --check /opt/awf/bounded-execution/index.js \ && node --check /opt/awf/broker/framing.js \ && node --check /opt/awf/broker/workspace.js \ && node --check /opt/awf/broker/query-runner.js \ diff --git a/containers/bounded-query/bounded-execution/finite-disclosure.js b/containers/bounded-query/bounded-execution/finite-disclosure.js new file mode 100644 index 000000000..9111a1371 --- /dev/null +++ b/containers/bounded-query/bounded-execution/finite-disclosure.js @@ -0,0 +1,627 @@ +'use strict'; + +/** + * Finite-disclosure request/result protocol v2 - broker-side implementation. + * + * This is a deliberate, behaviour-identical mirror of + * `src/bounded-execution/finite-disclosure.ts`. The broker runs inside its own + * container image and cannot import AWF's TypeScript sources, so the rules are + * restated here and pinned + * by `src/bounded-query/protocol-parity.test.ts`, which runs the *same* vector + * table through both implementations and fails if they ever diverge. + * + * Do not "improve" one side without the other. + */ + +const QUERY_PROTOCOL_VERSION = 2; + +const MAX_SCHEMA_BYTES = 4096; +const MAX_SCHEMA_DEPTH = 6; +const MAX_SCHEMA_NODES = 64; +const MAX_ENUM_VALUES = 4096; +const MAX_LITERAL_STRING_BYTES = 64; +const MAX_OBJECT_FIELDS = 16; +const MAX_TUPLE_ITEMS = 16; +const MAX_ARRAY_LENGTH = 64; +const MAX_UNION_VARIANTS = 16; +const MAX_SCRIPT_BYTES = 64 * 1024; +const MAX_RESULT_BYTES = 8 * 1024; +const MAX_PRIVATE_REPO_LENGTH = 140; + +const TIMING_BUCKETS_MS = [10, 100, 1_000, 10_000, 60_000, 600_000]; +const FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS = 60_000; +const MAX_QUERY_TIMEOUT_SECONDS = + (TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] - FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS) / 1000; + +function ceilLog2(n) { + return ceilLog2BigInt(BigInt(n)); +} + +const TIMING_BUCKET_BITS = ceilLog2(TIMING_BUCKETS_MS.length); +const RESULT_STATUS_BIT_COST = 1; + +const BOUNDED_QUERY_REPO_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)(?!.*\.\.)[A-Za-z0-9._-]{1,100}$/; +const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; + +function utf8ByteLength(value) { + return Buffer.byteLength(value, 'utf8'); +} + +function hasControlCharacters(value) { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +} + +function isValidLiteral(value) { + if (value === null) return true; + if (typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isInteger(value) && Number.isSafeInteger(value); + if (typeof value === 'string') { + return !hasControlCharacters(value) && utf8ByteLength(value) <= MAX_LITERAL_STRING_BYTES; + } + return false; +} + +function literalTypeTag(value) { + return value === null ? 'null' : typeof value; +} + +function failSchema(ctx, message) { + if (ctx.errors.length === 0) ctx.errors.push(message); + return undefined; +} + +function buildSchemaNode(raw, ctx, depth) { + if (ctx.errors.length > 0) return undefined; + if (depth > MAX_SCHEMA_DEPTH) { + return failSchema(ctx, `schema exceeds maximum depth of ${MAX_SCHEMA_DEPTH}`); + } + ctx.nodeCount += 1; + if (ctx.nodeCount > MAX_SCHEMA_NODES) { + return failSchema(ctx, `schema exceeds maximum node count of ${MAX_SCHEMA_NODES}`); + } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return failSchema(ctx, 'schema node must be a JSON object'); + } + + const node = raw; + switch (node.type) { + case 'const': { + if (Object.keys(node).length !== 2 || !('value' in node)) { + return failSchema(ctx, 'const schema must have exactly "type" and "value"'); + } + if (!isValidLiteral(node.value)) { + return failSchema(ctx, 'const value must be a bounded string, a safe integer, a boolean, or null'); + } + return { type: 'const', value: node.value }; + } + case 'boolean': { + if (Object.keys(node).length !== 1) { + return failSchema(ctx, 'boolean schema must have only "type"'); + } + return { type: 'boolean' }; + } + case 'enum': { + if (Object.keys(node).length !== 2 || !('values' in node)) { + return failSchema(ctx, 'enum schema must have exactly "type" and "values"'); + } + const values = node.values; + if (!Array.isArray(values) || values.length === 0) { + return failSchema(ctx, 'enum values must be a non-empty array'); + } + if (values.length > MAX_ENUM_VALUES) { + return failSchema(ctx, `enum values must contain at most ${MAX_ENUM_VALUES} entries`); + } + for (const value of values) { + if (!isValidLiteral(value)) { + return failSchema(ctx, 'enum values must be bounded strings, safe integers, booleans, or null'); + } + } + const firstTag = literalTypeTag(values[0]); + if (!values.every((value) => literalTypeTag(value) === firstTag)) { + return failSchema(ctx, 'enum values must all be the same JSON type'); + } + const uniqueCount = new Set(values.map((value) => JSON.stringify(value))).size; + if (uniqueCount !== values.length) { + return failSchema(ctx, 'enum values must be unique'); + } + return { type: 'enum', values }; + } + case 'integer': { + if (Object.keys(node).length !== 3 || !('minimum' in node) || !('maximum' in node)) { + return failSchema(ctx, 'integer schema must have exactly "type", "minimum", and "maximum"'); + } + const { minimum, maximum } = node; + if (typeof minimum !== 'number' || !Number.isSafeInteger(minimum)) { + return failSchema(ctx, 'integer minimum must be a safe integer'); + } + if (typeof maximum !== 'number' || !Number.isSafeInteger(maximum)) { + return failSchema(ctx, 'integer maximum must be a safe integer'); + } + if (maximum < minimum) { + return failSchema(ctx, 'integer maximum must be >= minimum'); + } + return { type: 'integer', minimum, maximum }; + } + case 'object': { + if (Object.keys(node).length !== 2 || !('fields' in node)) { + return failSchema(ctx, 'object schema must have exactly "type" and "fields"'); + } + const fieldsRaw = node.fields; + if (typeof fieldsRaw !== 'object' || fieldsRaw === null || Array.isArray(fieldsRaw)) { + return failSchema(ctx, 'object "fields" must be a JSON object mapping field name to schema'); + } + const fieldNames = Object.keys(fieldsRaw); + if (fieldNames.length === 0) { + return failSchema(ctx, 'object schema must declare at least one field'); + } + if (fieldNames.length > MAX_OBJECT_FIELDS) { + return failSchema(ctx, `object schema must declare at most ${MAX_OBJECT_FIELDS} fields`); + } + for (const name of fieldNames) { + if (!IDENTIFIER_PATTERN.test(name)) { + return failSchema(ctx, `object field name "${name}" is not a bounded ASCII identifier`); + } + } + const fields = []; + for (const name of fieldNames) { + const child = buildSchemaNode(fieldsRaw[name], ctx, depth + 1); + if (!child) return undefined; + fields.push({ name, schema: child }); + } + return { type: 'object', fields }; + } + case 'tuple': { + if (Object.keys(node).length !== 2 || !('items' in node)) { + return failSchema(ctx, 'tuple schema must have exactly "type" and "items"'); + } + const itemsRaw = node.items; + if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) { + return failSchema(ctx, 'tuple "items" must be a non-empty array'); + } + if (itemsRaw.length > MAX_TUPLE_ITEMS) { + return failSchema(ctx, `tuple schema must declare at most ${MAX_TUPLE_ITEMS} items`); + } + const items = []; + for (const itemRaw of itemsRaw) { + const child = buildSchemaNode(itemRaw, ctx, depth + 1); + if (!child) return undefined; + items.push(child); + } + return { type: 'tuple', items }; + } + case 'array': { + if (Object.keys(node).length !== 3 || !('items' in node) || !('length' in node)) { + return failSchema(ctx, 'array schema must have exactly "type", "items", and "length"'); + } + const { length } = node; + if (typeof length !== 'number' || !Number.isInteger(length) || length < 0 || length > MAX_ARRAY_LENGTH) { + return failSchema(ctx, `array "length" must be an integer between 0 and ${MAX_ARRAY_LENGTH}`); + } + const child = buildSchemaNode(node.items, ctx, depth + 1); + if (!child) return undefined; + return { type: 'array', items: child, length }; + } + case 'union': { + if (Object.keys(node).length !== 2 || !('variants' in node)) { + return failSchema(ctx, 'union schema must have exactly "type" and "variants"'); + } + const variantsRaw = node.variants; + if (typeof variantsRaw !== 'object' || variantsRaw === null || Array.isArray(variantsRaw)) { + return failSchema(ctx, 'union "variants" must be a JSON object mapping tag to schema'); + } + const tags = Object.keys(variantsRaw); + if (tags.length === 0) { + return failSchema(ctx, 'union schema must declare at least one variant'); + } + if (tags.length > MAX_UNION_VARIANTS) { + return failSchema(ctx, `union schema must declare at most ${MAX_UNION_VARIANTS} variants`); + } + for (const tag of tags) { + if (!IDENTIFIER_PATTERN.test(tag)) { + return failSchema(ctx, `union tag "${tag}" is not a bounded ASCII identifier`); + } + } + const variants = []; + for (const tag of tags) { + const child = buildSchemaNode(variantsRaw[tag], ctx, depth + 1); + if (!child) return undefined; + variants.push({ tag, schema: child }); + } + return { type: 'union', variants }; + } + default: + return failSchema( + ctx, + 'schema node "type" must be one of: const, boolean, enum, integer, object, tuple, array, union', + ); + } +} + +function validateSchema(raw) { + let serialized; + try { + serialized = JSON.stringify(raw) ?? ''; + } catch { + return { valid: false, errors: ['schema must be JSON-serializable'] }; + } + + const ctx = { errors: [], nodeCount: 0 }; + const schema = buildSchemaNode(raw, ctx, 0); + if (!schema || ctx.errors.length > 0) { + return { valid: false, errors: ctx.errors.length > 0 ? ctx.errors : ['invalid schema'] }; + } + if (raw === undefined || utf8ByteLength(serialized) > MAX_SCHEMA_BYTES) { + return { valid: false, errors: [`schema must be a JSON value of at most ${MAX_SCHEMA_BYTES} bytes`] }; + } + return { valid: true, schema }; +} + +function ceilLog2BigInt(n) { + if (n <= 1n) return 0; + let bits = 0; + let remainder = n - 1n; + while (remainder > 0n) { + remainder >>= 1n; + bits += 1; + } + return bits; +} + +function schemaCardinality(schema) { + switch (schema.type) { + case 'const': + return 1n; + case 'boolean': + return 2n; + case 'enum': + return BigInt(schema.values.length); + case 'integer': + return BigInt(schema.maximum) - BigInt(schema.minimum) + 1n; + case 'object': + return schema.fields.reduce((acc, field) => acc * schemaCardinality(field.schema), 1n); + case 'tuple': + return schema.items.reduce((acc, item) => acc * schemaCardinality(item), 1n); + case 'array': + return schemaCardinality(schema.items) ** BigInt(schema.length); + case 'union': + return schema.variants.reduce((acc, variant) => acc + schemaCardinality(variant.schema), 0n); + default: + throw new Error(`unreachable schema type: ${schema.type}`); + } +} + +function queryBitsForSchema(schema) { + return RESULT_STATUS_BIT_COST + ceilLog2BigInt(schemaCardinality(schema)) + TIMING_BUCKET_BITS; +} + +function jsonLiteralEquals(value, literal) { + if (literal === null) return value === null; + if (typeof literal === 'number') return typeof value === 'number' && Number.isInteger(value) && value === literal; + return value === literal; +} + +function validateValueAgainstSchema(schema, value) { + switch (schema.type) { + case 'const': + return jsonLiteralEquals(value, schema.value); + case 'boolean': + return typeof value === 'boolean'; + case 'enum': + return schema.values.some((candidate) => jsonLiteralEquals(value, candidate)); + case 'integer': + return ( + typeof value === 'number' + && Number.isInteger(value) + && value >= schema.minimum + && value <= schema.maximum + ); + case 'object': { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + if (Object.keys(value).length !== schema.fields.length) return false; + return schema.fields.every( + (field) => + Object.prototype.hasOwnProperty.call(value, field.name) + && validateValueAgainstSchema(field.schema, value[field.name]), + ); + } + case 'tuple': + return ( + Array.isArray(value) + && value.length === schema.items.length + && schema.items.every((itemSchema, index) => validateValueAgainstSchema(itemSchema, value[index])) + ); + case 'array': + return ( + Array.isArray(value) + && value.length === schema.length + && value.every((item) => validateValueAgainstSchema(schema.items, item)) + ); + case 'union': { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + if (Object.keys(value).length !== 2 || !('tag' in value) || !('value' in value) || typeof value.tag !== 'string') { + return false; + } + const variant = schema.variants.find((candidate) => candidate.tag === value.tag); + return variant !== undefined && validateValueAgainstSchema(variant.schema, value.value); + } + default: + return false; + } +} + +function canonicalizeSchemaValue(schema, value) { + switch (schema.type) { + case 'const': + return JSON.stringify(schema.value); + case 'boolean': + case 'enum': + case 'integer': + return JSON.stringify(value); + case 'object': { + const parts = schema.fields.map( + (field) => `${JSON.stringify(field.name)}:${canonicalizeSchemaValue(field.schema, value[field.name])}`, + ); + return `{${parts.join(',')}}`; + } + case 'tuple': + return `[${schema.items.map((itemSchema, index) => canonicalizeSchemaValue(itemSchema, value[index])).join(',')}]`; + case 'array': + return `[${value.map((item) => canonicalizeSchemaValue(schema.items, item)).join(',')}]`; + case 'union': { + const variant = schema.variants.find((candidate) => candidate.tag === value.tag); + if (!variant) return 'null'; + return `{"tag":${JSON.stringify(value.tag)},"value":${canonicalizeSchemaValue(variant.schema, value.value)}}`; + } + default: + throw new Error(`unreachable schema type: ${schema.type}`); + } +} + +// ── Strict JSON parsing (no `JSON.parse`) ──────────────────────────────────── + +const MAX_JSON_PARSE_DEPTH = 32; +const JSON_WHITESPACE = new Set([' ', '\t', '\n', '\r']); + +function skipJsonWhitespace(text, index) { + let i = index; + while (i < text.length && JSON_WHITESPACE.has(text[i])) i++; + return i; +} + +function parseJsonStringLiteral(text, start) { + if (text[start] !== '"') return undefined; + + let i = start + 1; + let value = ''; + while (i < text.length) { + const ch = text[i]; + + if (ch === '"') return { value, endIndex: i + 1 }; + + if (ch === '\\') { + const escape = text[i + 1]; + switch (escape) { + case '"': value += '"'; i += 2; continue; + case '\\': value += '\\'; i += 2; continue; + case '/': value += '/'; i += 2; continue; + case 'b': value += '\b'; i += 2; continue; + case 'f': value += '\f'; i += 2; continue; + case 'n': value += '\n'; i += 2; continue; + case 'r': value += '\r'; i += 2; continue; + case 't': value += '\t'; i += 2; continue; + case 'u': { + const hex = text.slice(i + 2, i + 6); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) return undefined; + value += String.fromCharCode(parseInt(hex, 16)); + i += 6; + continue; + } + default: + return undefined; + } + } + + if (ch.charCodeAt(0) < 0x20) return undefined; + value += ch; + i++; + } + + return undefined; +} + +function parseJsonNumber(text, start) { + let i = start; + if (text[i] === '-') i++; + if (text[i] === '0') { + i++; + } else if (text[i] >= '1' && text[i] <= '9') { + while (text[i] >= '0' && text[i] <= '9') i++; + } else { + return undefined; + } + if (text[i] === '.') { + i++; + if (!(text[i] >= '0' && text[i] <= '9')) return undefined; + while (text[i] >= '0' && text[i] <= '9') i++; + } + if (text[i] === 'e' || text[i] === 'E') { + i++; + if (text[i] === '+' || text[i] === '-') i++; + if (!(text[i] >= '0' && text[i] <= '9')) return undefined; + while (text[i] >= '0' && text[i] <= '9') i++; + } + const raw = text.slice(start, i); + const value = Number(raw); + if (!Number.isFinite(value)) return undefined; + return { value, endIndex: i }; +} + +function parseJsonValue(text, index, depth) { + if (depth > MAX_JSON_PARSE_DEPTH) return undefined; + const ch = text[index]; + + if (ch === '{') return parseJsonObject(text, index, depth); + if (ch === '[') return parseJsonArray(text, index, depth); + if (ch === '"') { + const literal = parseJsonStringLiteral(text, index); + return literal && { value: literal.value, endIndex: literal.endIndex }; + } + if (text.startsWith('true', index)) return { value: true, endIndex: index + 4 }; + if (text.startsWith('false', index)) return { value: false, endIndex: index + 5 }; + if (text.startsWith('null', index)) return { value: null, endIndex: index + 4 }; + if (ch === '-' || (ch >= '0' && ch <= '9')) return parseJsonNumber(text, index); + return undefined; +} + +function parseJsonObject(text, index, depth) { + let i = skipJsonWhitespace(text, index + 1); + const obj = {}; + if (text[i] === '}') return { value: obj, endIndex: i + 1 }; + + for (;;) { + i = skipJsonWhitespace(text, i); + const key = parseJsonStringLiteral(text, i); + if (!key) return undefined; + i = skipJsonWhitespace(text, key.endIndex); + if (text[i] !== ':') return undefined; + i = skipJsonWhitespace(text, i + 1); + const value = parseJsonValue(text, i, depth + 1); + if (!value) return undefined; + if (Object.prototype.hasOwnProperty.call(obj, key.value)) return undefined; + obj[key.value] = value.value; + i = skipJsonWhitespace(text, value.endIndex); + if (text[i] === ',') { i += 1; continue; } + if (text[i] === '}') return { value: obj, endIndex: i + 1 }; + return undefined; + } +} + +function parseJsonArray(text, index, depth) { + let i = skipJsonWhitespace(text, index + 1); + const arr = []; + if (text[i] === ']') return { value: arr, endIndex: i + 1 }; + + for (;;) { + i = skipJsonWhitespace(text, i); + const value = parseJsonValue(text, i, depth + 1); + if (!value) return undefined; + arr.push(value.value); + i = skipJsonWhitespace(text, value.endIndex); + if (text[i] === ',') { i += 1; continue; } + if (text[i] === ']') return { value: arr, endIndex: i + 1 }; + return undefined; + } +} + +function strictParseJson(text) { + const start = skipJsonWhitespace(text, 0); + const result = parseJsonValue(text, start, 0); + if (!result) return undefined; + const end = skipJsonWhitespace(text, result.endIndex); + if (end !== text.length) return undefined; + return { value: result.value }; +} + +// ── Request/result validation and canonical envelopes ─────────────────────── + +function validateBoundedQueryRequest(raw) { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return { valid: false, errors: ['request must be a JSON object'] }; + } + + const errors = []; + const { privateRepo, schema: schemaRaw, script } = raw; + const allowedKeys = new Set(['privateRepo', 'schema', 'script']); + for (const key of Object.keys(raw)) { + if (!allowedKeys.has(key)) errors.push(`request.${key} is not supported`); + } + + if (typeof privateRepo !== 'string' || privateRepo.length === 0) { + errors.push('privateRepo must be a non-empty string'); + } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) { + errors.push( + 'privateRepo must be an "owner/repo" slug (no scheme, host, path traversal, query, fragment, or wildcard)', + ); + } + + const schemaValidation = validateSchema(schemaRaw); + if (!schemaValidation.valid) { + errors.push(...schemaValidation.errors.map((error) => `schema: ${error}`)); + } + + if (typeof script !== 'string' || script.length === 0) { + errors.push('script must be a non-empty string'); + } else if (utf8ByteLength(script) > MAX_SCRIPT_BYTES) { + errors.push(`script must be at most ${MAX_SCRIPT_BYTES} bytes`); + } + + if ( + errors.length > 0 + || !schemaValidation.valid + || typeof privateRepo !== 'string' + || typeof script !== 'string' + ) { + return { valid: false, errors }; + } + + return { valid: true, request: { privateRepo, schema: schemaValidation.schema, script } }; +} + +const CANONICAL_ERROR_JSON = '{"status":"error"}'; + +function canonicalOkJson(canonicalResultJson) { + return `{"status":"ok","result":${canonicalResultJson}}`; +} + +function parseAndValidateQueryOutput(raw, schema) { + if (utf8ByteLength(raw) > MAX_RESULT_BYTES) return { ok: false }; + const parsed = strictParseJson(raw); + if (!parsed) return { ok: false }; + if (!validateValueAgainstSchema(schema, parsed.value)) return { ok: false }; + return { ok: true, canonical: canonicalizeSchemaValue(schema, parsed.value) }; +} + +module.exports = { + QUERY_PROTOCOL_VERSION, + MAX_SCHEMA_BYTES, + MAX_SCHEMA_DEPTH, + MAX_SCHEMA_NODES, + MAX_ENUM_VALUES, + MAX_LITERAL_STRING_BYTES, + MAX_OBJECT_FIELDS, + MAX_TUPLE_ITEMS, + MAX_ARRAY_LENGTH, + MAX_UNION_VARIANTS, + MAX_SCRIPT_BYTES, + MAX_RESULT_BYTES, + MAX_PRIVATE_REPO_LENGTH, + TIMING_BUCKETS_MS, + FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS, + MAX_QUERY_TIMEOUT_SECONDS, + TIMING_BUCKET_BITS, + RESULT_STATUS_BIT_COST, + BOUNDED_QUERY_REPO_PATTERN, + CANONICAL_ERROR_JSON, + validateSchema, + ceilLog2BigInt, + schemaCardinality, + queryBitsForSchema, + validateValueAgainstSchema, + canonicalizeSchemaValue, + strictParseJson, + validateBoundedQueryRequest, + canonicalOkJson, + parseAndValidateQueryOutput, + // Reusable bounded-execution names; bounded-query exports above stay stable. + validateFiniteSchema: validateSchema, + finiteSchemaCardinality: schemaCardinality, + informationChargeForSchema: queryBitsForSchema, + canonicalizeFiniteSchemaValue: canonicalizeSchemaValue, + canonicalSuccessJson: canonicalOkJson, + CANONICAL_ERROR_RESPONSE_JSON: CANONICAL_ERROR_JSON, +}; diff --git a/containers/bounded-query/bounded-execution/fixed-timing.js b/containers/bounded-query/bounded-execution/fixed-timing.js new file mode 100644 index 000000000..1c4ed7778 --- /dev/null +++ b/containers/bounded-query/bounded-execution/fixed-timing.js @@ -0,0 +1,109 @@ +'use strict'; + +const { TIMING_BUCKETS_MS } = require('./finite-disclosure'); + +/** + * Response-timing bucketing. + * + * A query's actual completion latency is itself a secret-dependent signal + * (a script that raises early on one branch and runs to completion on + * another leaks information purely through wall-clock time, with no + * dependence on the declared response schema at all). This module makes + * every *launched* invocation's observable response time fall on one of a + * small, fixed set of boundaries (`TIMING_BUCKETS_MS`), regardless of how + * long the actual work took within that bucket. + * + * Design notes (see `docs/awf-config-spec.md` §14 for the full writeup): + * + * - Time is measured with a monotonic clock (`process.hrtime.bigint()` by + * default, injectable for tests), never `Date.now()`, so system clock + * adjustments cannot shift a response across a bucket boundary. + * - `waitForBucket` resolves the bucket only after query execution, result + * validation, Docker removal, and host workspace teardown complete. + * Repository size and tree shape can affect cleanup latency, so cleanup + * must be included before choosing the charged timing bucket. Invocations + * remain serialized, preventing queued requests from observing an + * unaccounted cleanup delay from the preceding invocation. + * - If processing latency already exceeds the *last* bucket boundary + * (only possible if infrastructure overhead — not the script itself, + * whose timeout preserves a final-minute processing margin — pushes total + * processing past 10 minutes), the broker fails closed: it + * treats the invocation as a canonical error and responds immediately + * rather than waiting indefinitely for a nonexistent next boundary. This + * is a deliberately safe fail-closed fallback for a pathological + * infrastructure-latency edge case, not a normal code path. + * - A late host-scheduler wake after waiting for the final bucket does not + * turn an already completed invocation into an overflow. That delay occurs + * only after secret-dependent processing has finished and is attributable + * to public host scheduling; there is no later fixed bucket to pad to. + */ + +/** + * Public host-scheduler tolerance after a requested wake-up. Delays beyond + * this bound are padded to the next fixed boundary instead of being returned + * at a continuously varying late time. + */ +const TIMER_WAKE_TOLERANCE_MS = 5; + +/** Resolves the smallest configured bucket at or after `elapsedMs`. */ +function resolveTimingBucket(elapsedMs) { + for (const bucketMs of TIMING_BUCKETS_MS) { + if (elapsedMs <= bucketMs) return { bucketMs, overflowed: false }; + } + return { bucketMs: TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1], overflowed: true }; +} + +/** Real monotonic clock. Milliseconds, sub-millisecond precision preserved as a float. */ +function createRealClock() { + return { + nowMs: () => Number(process.hrtime.bigint()) / 1e6, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }; +} + +/** + * Waits (if necessary) until `startMs + bucket` on `clock`, where `bucket` + * is the smallest configured boundary at or after `elapsedMs`. + * + * `elapsedMs` is measured by the caller as `clock.nowMs() - startMs` at the + * moment all processing (including cleanup) completed. This function only + * performs the remaining wait to the selected fixed boundary. + * + * @returns `{ bucketMs, overflowed }`. When `overflowed` is `true`, the + * caller must fail closed (canonical error) rather than waiting further. + */ +async function waitForBucket(startMs, elapsedMs, clock) { + let observedElapsedMs = Math.max(elapsedMs, clock.nowMs() - startMs); + + while (true) { + const { bucketMs, overflowed } = resolveTimingBucket(observedElapsedMs); + if (overflowed) return { bucketMs, overflowed }; + + const targetMs = startMs + bucketMs; + const remainingMs = targetMs - clock.nowMs(); + if (remainingMs === 0) { + return { bucketMs, overflowed: false }; + } + if (remainingMs < 0) { + observedElapsedMs = clock.nowMs() - startMs; + continue; + } + + await clock.sleep(remainingMs); + const wakeMs = clock.nowMs(); + const isFinalBucket = bucketMs === TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1]; + if (wakeMs <= targetMs + TIMER_WAKE_TOLERANCE_MS || isFinalBucket) { + return { bucketMs, overflowed: false }; + } + + observedElapsedMs = wakeMs - startMs; + } +} + +module.exports = { + TIMING_BUCKETS_MS, + TIMER_WAKE_TOLERANCE_MS, + resolveTimingBucket, + createRealClock, + waitForBucket, +}; diff --git a/containers/bounded-query/bounded-execution/index.js b/containers/bounded-query/bounded-execution/index.js new file mode 100644 index 000000000..d48a33411 --- /dev/null +++ b/containers/bounded-query/bounded-execution/index.js @@ -0,0 +1,10 @@ +'use strict'; + +module.exports = { + ...require('./finite-disclosure'), + ...require('./sensitivity-policy'), + ...require('./sensitivity-ledger'), + ...require('./fixed-timing'), + ...require('./protected-audit'), + ...require('./repository-staging'), +}; diff --git a/containers/bounded-query/bounded-execution/protected-audit.js b/containers/bounded-query/bounded-execution/protected-audit.js new file mode 100644 index 000000000..a4ed53035 --- /dev/null +++ b/containers/bounded-query/bounded-execution/protected-audit.js @@ -0,0 +1,86 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +/** + * Protected broker diagnostics. + * + * Written to a directory that is mounted into the broker only — never into + * the agent and never into a query. This is where the *reason* for an + * `{"result":"ERROR"}` lives; the agent-visible answer never distinguishes + * failure classes. + * + * Records deliberately exclude repository contents, query stdout/stderr, and + * script bytes. + */ + +const MAX_REASON_LENGTH = 500; + +/** Bounds protected diagnostic detail without changing its value shape. */ +function redactAuditDetail(detail) { + return detail === undefined ? undefined : String(detail).slice(0, MAX_REASON_LENGTH); +} + +function createAuditLog(auditDir) { + let fd; + try { + fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 }); + const auditPath = path.join(auditDir, 'bounded-query.jsonl'); + fd = fs.openSync(auditPath, 'a', 0o600); + } catch (error) { + // Losing the audit file must not take the broker down; fall back to + // stderr, which is captured by `docker logs` on the broker container + // (also outside the agent's reach). + process.stderr.write(`[bounded-query] audit log unavailable: ${error.message}\n`); + fd = undefined; + } + + function write(record) { + const line = `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`; + if (fd !== undefined) { + try { + // Keep diagnostics durable when the broker is stopped immediately + // after an invocation fails. + fs.writeSync(fd, line); + return; + } catch (error) { + process.stderr.write(`[bounded-query] audit log unavailable: ${error.message}\n`); + try { + fs.closeSync(fd); + } catch { + // The original write error is the useful diagnostic. + } + fd = undefined; + } + } + process.stderr.write(line); + } + + return { + /** Records a successfully completed invocation. */ + invocation(record) { + write({ kind: 'invocation', ...record }); + }, + /** Records why an invocation resolved to the canonical ERROR result. */ + failure(invocationId, reason, detail) { + write({ + kind: 'failure', + invocationId, + reason, + detail: redactAuditDetail(detail), + }); + }, + /** Records broker lifecycle events. */ + lifecycle(event, detail) { + write({ kind: 'lifecycle', event, detail }); + }, + }; +} + +module.exports = { + createAuditLog, + createProtectedAuditLog: createAuditLog, + redactAuditDetail, + MAX_REASON_LENGTH, +}; diff --git a/containers/bounded-query/bounded-execution/repository-staging.js b/containers/bounded-query/bounded-execution/repository-staging.js new file mode 100644 index 000000000..9bf7b9208 --- /dev/null +++ b/containers/bounded-query/bounded-execution/repository-staging.js @@ -0,0 +1,37 @@ +'use strict'; + +/** + * Parses an AWF-generated private-repository seed map. + * + * The caller supplies the trusted sensitivity policy; request data can never + * choose a path, seed id, sensitivity, or run identity through this helper. + */ +function parsePrivateRepositorySeedMap(serialized, sensitivityRunBits) { + const parsed = JSON.parse(serialized); + if (!parsed || parsed.version !== 2 || !Array.isArray(parsed.seeds)) { + throw new Error('Seed map is malformed or is an unsupported version'); + } + if (typeof parsed.runId !== 'string' || !/^[0-9a-f]{8,}$/.test(parsed.runId)) { + throw new Error('Seed map has no usable runId'); + } + + const seeds = new Map(); + for (const entry of parsed.seeds) { + if ( + !entry + || typeof entry.repo !== 'string' + || typeof entry.seedId !== 'string' + || !Object.prototype.hasOwnProperty.call(sensitivityRunBits, entry.sensitivity) + ) { + throw new Error('Seed map entry is malformed'); + } + if (!/^[0-9a-f]{16,64}$/.test(entry.seedId)) { + throw new Error('Seed map entry has an unexpected seed id'); + } + seeds.set(entry.repo.toLowerCase(), { seedId: entry.seedId, sensitivity: entry.sensitivity }); + } + + return { runId: parsed.runId, seeds }; +} + +module.exports = { parsePrivateRepositorySeedMap }; diff --git a/containers/bounded-query/bounded-execution/sensitivity-ledger.js b/containers/bounded-query/bounded-execution/sensitivity-ledger.js new file mode 100644 index 000000000..791bdb727 --- /dev/null +++ b/containers/bounded-query/bounded-execution/sensitivity-ledger.js @@ -0,0 +1,57 @@ +'use strict'; + +const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity-policy'); + +/** + * Per-repository information-budget ledger. + * + * There is no per-query cap: every invocation may use an arbitrarily + * different schema, and its maximum complete-transcript charge (see + * `queryBitsForSchema` in `./protocol`) is computed and debited from the + * repository's shared run balance *before* a seed is copied or Python is + * launched. An invocation is allowed iff its charge fits the remaining + * balance. Charges are never refunded, regardless of the invocation's + * outcome (success, failure, or timeout) — the broker committed to + * revealing up to that many bits of signal the moment it decided to run. + * + * The ledger's scope is one broker process — one AWF run. The broker has no + * durable identity or storage across runs. + */ + +/** + * Builds a ledger from the loaded seed map. + * + * @param seeds `Map` as returned + * by `config.loadSeedMap`. + */ +function createLedger(seeds) { + const remaining = new Map(); + for (const [repoKey, seed] of seeds) { + remaining.set(repoKey, BOUNDED_QUERY_SENSITIVITY_RUN_BITS[seed.sensitivity]); + } + + return { + /** + * Atomically checks and debits `bits` from `repoKey`'s remaining + * balance. Returns `true` (and debits) iff the charge is affordable; + * returns `false` (and leaves the balance untouched) otherwise. Safe + * to call synchronously with no intervening `await` — Node's + * single-threaded event loop makes this indivisible. + */ + tryDebit(repoKey, bits) { + if (!remaining.has(repoKey)) return false; + const current = remaining.get(repoKey); + if (current === null) return true; // unmetered (public) + if (bits > current) return false; + remaining.set(repoKey, current - bits); + return true; + }, + + /** Returns the remaining balance for a repo, or `undefined` if unknown. */ + remainingBits(repoKey) { + return remaining.get(repoKey); + }, + }; +} + +module.exports = { createLedger, createSensitivityLedger: createLedger }; diff --git a/containers/bounded-query/bounded-execution/sensitivity-policy.js b/containers/bounded-query/bounded-execution/sensitivity-policy.js new file mode 100644 index 000000000..ef8da1e43 --- /dev/null +++ b/containers/bounded-query/bounded-execution/sensitivity-policy.js @@ -0,0 +1,32 @@ +'use strict'; + +/** + * Repository sensitivity categories and their fixed per-run information + * budgets — broker-side mirror of `BOUNDED_QUERY_SENSITIVITY_RUN_BITS` in + * `src/types/bounded-query-options.ts`. Kept in a tiny standalone module (not + * `protocol.js`) because it is config/ledger data, not wire protocol. + * + * `null` means "unmetered": `public` still runs through the same finite + * schema/result validation and operational limits (`maxInvocations`, + * timeouts, sandboxing) as every other category, but its responses are not + * debited against a confidentiality ledger. `sealed` is `0`, which — + * because every accepted query's minimum charge is 4 bits (1 status bit + + * 3 timing bits) — always exceeds the remaining balance, so a `sealed` + * repository can never fund a single query and therefore never copies a + * seed or launches Python. + */ +const BOUNDED_QUERY_SENSITIVITIES = ['public', 'internal', 'confidential', 'sealed']; + +const BOUNDED_QUERY_SENSITIVITY_RUN_BITS = { + public: null, + internal: 64, + confidential: 8, + sealed: 0, +}; + +module.exports = { + BOUNDED_QUERY_SENSITIVITIES, + BOUNDED_QUERY_SENSITIVITY_RUN_BITS, + SENSITIVITY_LEVELS: BOUNDED_QUERY_SENSITIVITIES, + SENSITIVITY_RUN_BITS: BOUNDED_QUERY_SENSITIVITY_RUN_BITS, +}; diff --git a/containers/bounded-query/broker/audit.js b/containers/bounded-query/broker/audit.js index 3bea33513..e792659f2 100644 --- a/containers/bounded-query/broker/audit.js +++ b/containers/bounded-query/broker/audit.js @@ -1,76 +1,4 @@ 'use strict'; -const fs = require('fs'); -const path = require('path'); - -/** - * Protected broker diagnostics. - * - * Written to a directory that is mounted into the broker only — never into - * the agent and never into a query. This is where the *reason* for an - * `{"result":"ERROR"}` lives; the agent-visible answer never distinguishes - * failure classes. - * - * Records deliberately exclude repository contents, query stdout/stderr, and - * script bytes. - */ - -const MAX_REASON_LENGTH = 500; - -function createAuditLog(auditDir) { - let fd; - try { - fs.mkdirSync(auditDir, { recursive: true, mode: 0o700 }); - const auditPath = path.join(auditDir, 'bounded-query.jsonl'); - fd = fs.openSync(auditPath, 'a', 0o600); - } catch (error) { - // Losing the audit file must not take the broker down; fall back to - // stderr, which is captured by `docker logs` on the broker container - // (also outside the agent's reach). - process.stderr.write(`[bounded-query] audit log unavailable: ${error.message}\n`); - fd = undefined; - } - - function write(record) { - const line = `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`; - if (fd !== undefined) { - try { - // Keep diagnostics durable when the broker is stopped immediately - // after an invocation fails. - fs.writeSync(fd, line); - return; - } catch (error) { - process.stderr.write(`[bounded-query] audit log unavailable: ${error.message}\n`); - try { - fs.closeSync(fd); - } catch { - // The original write error is the useful diagnostic. - } - fd = undefined; - } - } - process.stderr.write(line); - } - - return { - /** Records a successfully completed invocation. */ - invocation(record) { - write({ kind: 'invocation', ...record }); - }, - /** Records why an invocation resolved to the canonical ERROR result. */ - failure(invocationId, reason, detail) { - write({ - kind: 'failure', - invocationId, - reason, - detail: detail === undefined ? undefined : String(detail).slice(0, MAX_REASON_LENGTH), - }); - }, - /** Records broker lifecycle events. */ - lifecycle(event, detail) { - write({ kind: 'lifecycle', event, detail }); - }, - }; -} - -module.exports = { createAuditLog }; +// Stable bounded-query compatibility entrypoint. +module.exports = require('../bounded-execution/protected-audit'); diff --git a/containers/bounded-query/broker/config.js b/containers/bounded-query/broker/config.js index dc9a9d0ba..45838b207 100644 --- a/containers/bounded-query/broker/config.js +++ b/containers/bounded-query/broker/config.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { MAX_QUERY_TIMEOUT_SECONDS } = require('./protocol'); const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity'); +const { parsePrivateRepositorySeedMap } = require('../bounded-execution/repository-staging'); /** * Broker configuration. @@ -158,33 +159,10 @@ function loadConfig() { * a request — a request cannot choose or override its repository's budget. */ function loadSeedMap(seedMapPath) { - const parsed = JSON.parse(fs.readFileSync(seedMapPath, 'utf8')); - if (!parsed || parsed.version !== 2 || !Array.isArray(parsed.seeds)) { - throw new Error('Seed map is malformed or is an unsupported version'); - } - if (typeof parsed.runId !== 'string' || !/^[0-9a-f]{8,}$/.test(parsed.runId)) { - throw new Error('Seed map has no usable runId'); - } - - const seeds = new Map(); - for (const entry of parsed.seeds) { - if ( - !entry - || typeof entry.repo !== 'string' - || typeof entry.seedId !== 'string' - || !Object.prototype.hasOwnProperty.call(BOUNDED_QUERY_SENSITIVITY_RUN_BITS, entry.sensitivity) - ) { - throw new Error('Seed map entry is malformed'); - } - // Seed ids are AWF-generated opaque hex names. Re-validating here means a - // corrupted map can never turn into a path traversal. - if (!/^[0-9a-f]{16,64}$/.test(entry.seedId)) { - throw new Error('Seed map entry has an unexpected seed id'); - } - seeds.set(entry.repo.toLowerCase(), { seedId: entry.seedId, sensitivity: entry.sensitivity }); - } - - return { runId: parsed.runId, seeds }; + return parsePrivateRepositorySeedMap( + fs.readFileSync(seedMapPath, 'utf8'), + BOUNDED_QUERY_SENSITIVITY_RUN_BITS, + ); } module.exports = { READY_PATH, SBX_CAPABILITY_PATH, loadConfig, loadSeedMap, loadSbxIngressCapabilities }; diff --git a/containers/bounded-query/broker/ledger.js b/containers/bounded-query/broker/ledger.js index 3191d7ef5..533d44960 100644 --- a/containers/bounded-query/broker/ledger.js +++ b/containers/bounded-query/broker/ledger.js @@ -1,57 +1,4 @@ 'use strict'; -const { BOUNDED_QUERY_SENSITIVITY_RUN_BITS } = require('./sensitivity'); - -/** - * Per-repository information-budget ledger. - * - * There is no per-query cap: every invocation may use an arbitrarily - * different schema, and its maximum complete-transcript charge (see - * `queryBitsForSchema` in `./protocol`) is computed and debited from the - * repository's shared run balance *before* a seed is copied or Python is - * launched. An invocation is allowed iff its charge fits the remaining - * balance. Charges are never refunded, regardless of the invocation's - * outcome (success, failure, or timeout) — the broker committed to - * revealing up to that many bits of signal the moment it decided to run. - * - * The ledger's scope is one broker process — one AWF run. The broker has no - * durable identity or storage across runs. - */ - -/** - * Builds a ledger from the loaded seed map. - * - * @param seeds `Map` as returned - * by `config.loadSeedMap`. - */ -function createLedger(seeds) { - const remaining = new Map(); - for (const [repoKey, seed] of seeds) { - remaining.set(repoKey, BOUNDED_QUERY_SENSITIVITY_RUN_BITS[seed.sensitivity]); - } - - return { - /** - * Atomically checks and debits `bits` from `repoKey`'s remaining - * balance. Returns `true` (and debits) iff the charge is affordable; - * returns `false` (and leaves the balance untouched) otherwise. Safe - * to call synchronously with no intervening `await` — Node's - * single-threaded event loop makes this indivisible. - */ - tryDebit(repoKey, bits) { - if (!remaining.has(repoKey)) return false; - const current = remaining.get(repoKey); - if (current === null) return true; // unmetered (public) - if (bits > current) return false; - remaining.set(repoKey, current - bits); - return true; - }, - - /** Returns the remaining balance for a repo, or `undefined` if unknown. */ - remainingBits(repoKey) { - return remaining.get(repoKey); - }, - }; -} - -module.exports = { createLedger }; +// Stable bounded-query compatibility entrypoint. +module.exports = require('../bounded-execution/sensitivity-ledger'); diff --git a/containers/bounded-query/broker/protocol.js b/containers/bounded-query/broker/protocol.js index 8d5113e9a..d2362bdbe 100644 --- a/containers/bounded-query/broker/protocol.js +++ b/containers/bounded-query/broker/protocol.js @@ -1,619 +1,4 @@ 'use strict'; -/** - * Bounded-query request/result protocol v2 — broker-side implementation. - * - * This is a deliberate, behaviour-identical mirror of `src/bounded-query/ - * protocol.ts`. The broker runs inside its own container image and cannot - * import AWF's TypeScript sources, so the rules are restated here and pinned - * by `src/bounded-query/protocol-parity.test.ts`, which runs the *same* vector - * table through both implementations and fails if they ever diverge. - * - * Do not "improve" one side without the other. - */ - -const QUERY_PROTOCOL_VERSION = 2; - -const MAX_SCHEMA_BYTES = 4096; -const MAX_SCHEMA_DEPTH = 6; -const MAX_SCHEMA_NODES = 64; -const MAX_ENUM_VALUES = 4096; -const MAX_LITERAL_STRING_BYTES = 64; -const MAX_OBJECT_FIELDS = 16; -const MAX_TUPLE_ITEMS = 16; -const MAX_ARRAY_LENGTH = 64; -const MAX_UNION_VARIANTS = 16; -const MAX_SCRIPT_BYTES = 64 * 1024; -const MAX_RESULT_BYTES = 8 * 1024; -const MAX_PRIVATE_REPO_LENGTH = 140; - -const TIMING_BUCKETS_MS = [10, 100, 1_000, 10_000, 60_000, 600_000]; -const FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS = 60_000; -const MAX_QUERY_TIMEOUT_SECONDS = - (TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] - FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS) / 1000; - -function ceilLog2(n) { - return ceilLog2BigInt(BigInt(n)); -} - -const TIMING_BUCKET_BITS = ceilLog2(TIMING_BUCKETS_MS.length); -const RESULT_STATUS_BIT_COST = 1; - -const BOUNDED_QUERY_REPO_PATTERN = - /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)(?!.*\.\.)[A-Za-z0-9._-]{1,100}$/; -const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; - -function utf8ByteLength(value) { - return Buffer.byteLength(value, 'utf8'); -} - -function hasControlCharacters(value) { - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - if (code < 0x20 || code === 0x7f) return true; - } - return false; -} - -function isValidLiteral(value) { - if (value === null) return true; - if (typeof value === 'boolean') return true; - if (typeof value === 'number') return Number.isInteger(value) && Number.isSafeInteger(value); - if (typeof value === 'string') { - return !hasControlCharacters(value) && utf8ByteLength(value) <= MAX_LITERAL_STRING_BYTES; - } - return false; -} - -function literalTypeTag(value) { - return value === null ? 'null' : typeof value; -} - -function failSchema(ctx, message) { - if (ctx.errors.length === 0) ctx.errors.push(message); - return undefined; -} - -function buildSchemaNode(raw, ctx, depth) { - if (ctx.errors.length > 0) return undefined; - if (depth > MAX_SCHEMA_DEPTH) { - return failSchema(ctx, `schema exceeds maximum depth of ${MAX_SCHEMA_DEPTH}`); - } - ctx.nodeCount += 1; - if (ctx.nodeCount > MAX_SCHEMA_NODES) { - return failSchema(ctx, `schema exceeds maximum node count of ${MAX_SCHEMA_NODES}`); - } - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - return failSchema(ctx, 'schema node must be a JSON object'); - } - - const node = raw; - switch (node.type) { - case 'const': { - if (Object.keys(node).length !== 2 || !('value' in node)) { - return failSchema(ctx, 'const schema must have exactly "type" and "value"'); - } - if (!isValidLiteral(node.value)) { - return failSchema(ctx, 'const value must be a bounded string, a safe integer, a boolean, or null'); - } - return { type: 'const', value: node.value }; - } - case 'boolean': { - if (Object.keys(node).length !== 1) { - return failSchema(ctx, 'boolean schema must have only "type"'); - } - return { type: 'boolean' }; - } - case 'enum': { - if (Object.keys(node).length !== 2 || !('values' in node)) { - return failSchema(ctx, 'enum schema must have exactly "type" and "values"'); - } - const values = node.values; - if (!Array.isArray(values) || values.length === 0) { - return failSchema(ctx, 'enum values must be a non-empty array'); - } - if (values.length > MAX_ENUM_VALUES) { - return failSchema(ctx, `enum values must contain at most ${MAX_ENUM_VALUES} entries`); - } - for (const value of values) { - if (!isValidLiteral(value)) { - return failSchema(ctx, 'enum values must be bounded strings, safe integers, booleans, or null'); - } - } - const firstTag = literalTypeTag(values[0]); - if (!values.every((value) => literalTypeTag(value) === firstTag)) { - return failSchema(ctx, 'enum values must all be the same JSON type'); - } - const uniqueCount = new Set(values.map((value) => JSON.stringify(value))).size; - if (uniqueCount !== values.length) { - return failSchema(ctx, 'enum values must be unique'); - } - return { type: 'enum', values }; - } - case 'integer': { - if (Object.keys(node).length !== 3 || !('minimum' in node) || !('maximum' in node)) { - return failSchema(ctx, 'integer schema must have exactly "type", "minimum", and "maximum"'); - } - const { minimum, maximum } = node; - if (typeof minimum !== 'number' || !Number.isSafeInteger(minimum)) { - return failSchema(ctx, 'integer minimum must be a safe integer'); - } - if (typeof maximum !== 'number' || !Number.isSafeInteger(maximum)) { - return failSchema(ctx, 'integer maximum must be a safe integer'); - } - if (maximum < minimum) { - return failSchema(ctx, 'integer maximum must be >= minimum'); - } - return { type: 'integer', minimum, maximum }; - } - case 'object': { - if (Object.keys(node).length !== 2 || !('fields' in node)) { - return failSchema(ctx, 'object schema must have exactly "type" and "fields"'); - } - const fieldsRaw = node.fields; - if (typeof fieldsRaw !== 'object' || fieldsRaw === null || Array.isArray(fieldsRaw)) { - return failSchema(ctx, 'object "fields" must be a JSON object mapping field name to schema'); - } - const fieldNames = Object.keys(fieldsRaw); - if (fieldNames.length === 0) { - return failSchema(ctx, 'object schema must declare at least one field'); - } - if (fieldNames.length > MAX_OBJECT_FIELDS) { - return failSchema(ctx, `object schema must declare at most ${MAX_OBJECT_FIELDS} fields`); - } - for (const name of fieldNames) { - if (!IDENTIFIER_PATTERN.test(name)) { - return failSchema(ctx, `object field name "${name}" is not a bounded ASCII identifier`); - } - } - const fields = []; - for (const name of fieldNames) { - const child = buildSchemaNode(fieldsRaw[name], ctx, depth + 1); - if (!child) return undefined; - fields.push({ name, schema: child }); - } - return { type: 'object', fields }; - } - case 'tuple': { - if (Object.keys(node).length !== 2 || !('items' in node)) { - return failSchema(ctx, 'tuple schema must have exactly "type" and "items"'); - } - const itemsRaw = node.items; - if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) { - return failSchema(ctx, 'tuple "items" must be a non-empty array'); - } - if (itemsRaw.length > MAX_TUPLE_ITEMS) { - return failSchema(ctx, `tuple schema must declare at most ${MAX_TUPLE_ITEMS} items`); - } - const items = []; - for (const itemRaw of itemsRaw) { - const child = buildSchemaNode(itemRaw, ctx, depth + 1); - if (!child) return undefined; - items.push(child); - } - return { type: 'tuple', items }; - } - case 'array': { - if (Object.keys(node).length !== 3 || !('items' in node) || !('length' in node)) { - return failSchema(ctx, 'array schema must have exactly "type", "items", and "length"'); - } - const { length } = node; - if (typeof length !== 'number' || !Number.isInteger(length) || length < 0 || length > MAX_ARRAY_LENGTH) { - return failSchema(ctx, `array "length" must be an integer between 0 and ${MAX_ARRAY_LENGTH}`); - } - const child = buildSchemaNode(node.items, ctx, depth + 1); - if (!child) return undefined; - return { type: 'array', items: child, length }; - } - case 'union': { - if (Object.keys(node).length !== 2 || !('variants' in node)) { - return failSchema(ctx, 'union schema must have exactly "type" and "variants"'); - } - const variantsRaw = node.variants; - if (typeof variantsRaw !== 'object' || variantsRaw === null || Array.isArray(variantsRaw)) { - return failSchema(ctx, 'union "variants" must be a JSON object mapping tag to schema'); - } - const tags = Object.keys(variantsRaw); - if (tags.length === 0) { - return failSchema(ctx, 'union schema must declare at least one variant'); - } - if (tags.length > MAX_UNION_VARIANTS) { - return failSchema(ctx, `union schema must declare at most ${MAX_UNION_VARIANTS} variants`); - } - for (const tag of tags) { - if (!IDENTIFIER_PATTERN.test(tag)) { - return failSchema(ctx, `union tag "${tag}" is not a bounded ASCII identifier`); - } - } - const variants = []; - for (const tag of tags) { - const child = buildSchemaNode(variantsRaw[tag], ctx, depth + 1); - if (!child) return undefined; - variants.push({ tag, schema: child }); - } - return { type: 'union', variants }; - } - default: - return failSchema( - ctx, - 'schema node "type" must be one of: const, boolean, enum, integer, object, tuple, array, union', - ); - } -} - -function validateSchema(raw) { - let serialized; - try { - serialized = JSON.stringify(raw) ?? ''; - } catch { - return { valid: false, errors: ['schema must be JSON-serializable'] }; - } - - const ctx = { errors: [], nodeCount: 0 }; - const schema = buildSchemaNode(raw, ctx, 0); - if (!schema || ctx.errors.length > 0) { - return { valid: false, errors: ctx.errors.length > 0 ? ctx.errors : ['invalid schema'] }; - } - if (raw === undefined || utf8ByteLength(serialized) > MAX_SCHEMA_BYTES) { - return { valid: false, errors: [`schema must be a JSON value of at most ${MAX_SCHEMA_BYTES} bytes`] }; - } - return { valid: true, schema }; -} - -function ceilLog2BigInt(n) { - if (n <= 1n) return 0; - let bits = 0; - let remainder = n - 1n; - while (remainder > 0n) { - remainder >>= 1n; - bits += 1; - } - return bits; -} - -function schemaCardinality(schema) { - switch (schema.type) { - case 'const': - return 1n; - case 'boolean': - return 2n; - case 'enum': - return BigInt(schema.values.length); - case 'integer': - return BigInt(schema.maximum) - BigInt(schema.minimum) + 1n; - case 'object': - return schema.fields.reduce((acc, field) => acc * schemaCardinality(field.schema), 1n); - case 'tuple': - return schema.items.reduce((acc, item) => acc * schemaCardinality(item), 1n); - case 'array': - return schemaCardinality(schema.items) ** BigInt(schema.length); - case 'union': - return schema.variants.reduce((acc, variant) => acc + schemaCardinality(variant.schema), 0n); - default: - throw new Error(`unreachable schema type: ${schema.type}`); - } -} - -function queryBitsForSchema(schema) { - return RESULT_STATUS_BIT_COST + ceilLog2BigInt(schemaCardinality(schema)) + TIMING_BUCKET_BITS; -} - -function jsonLiteralEquals(value, literal) { - if (literal === null) return value === null; - if (typeof literal === 'number') return typeof value === 'number' && Number.isInteger(value) && value === literal; - return value === literal; -} - -function validateValueAgainstSchema(schema, value) { - switch (schema.type) { - case 'const': - return jsonLiteralEquals(value, schema.value); - case 'boolean': - return typeof value === 'boolean'; - case 'enum': - return schema.values.some((candidate) => jsonLiteralEquals(value, candidate)); - case 'integer': - return ( - typeof value === 'number' - && Number.isInteger(value) - && value >= schema.minimum - && value <= schema.maximum - ); - case 'object': { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - if (Object.keys(value).length !== schema.fields.length) return false; - return schema.fields.every( - (field) => - Object.prototype.hasOwnProperty.call(value, field.name) - && validateValueAgainstSchema(field.schema, value[field.name]), - ); - } - case 'tuple': - return ( - Array.isArray(value) - && value.length === schema.items.length - && schema.items.every((itemSchema, index) => validateValueAgainstSchema(itemSchema, value[index])) - ); - case 'array': - return ( - Array.isArray(value) - && value.length === schema.length - && value.every((item) => validateValueAgainstSchema(schema.items, item)) - ); - case 'union': { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - if (Object.keys(value).length !== 2 || !('tag' in value) || !('value' in value) || typeof value.tag !== 'string') { - return false; - } - const variant = schema.variants.find((candidate) => candidate.tag === value.tag); - return variant !== undefined && validateValueAgainstSchema(variant.schema, value.value); - } - default: - return false; - } -} - -function canonicalizeSchemaValue(schema, value) { - switch (schema.type) { - case 'const': - return JSON.stringify(schema.value); - case 'boolean': - case 'enum': - case 'integer': - return JSON.stringify(value); - case 'object': { - const parts = schema.fields.map( - (field) => `${JSON.stringify(field.name)}:${canonicalizeSchemaValue(field.schema, value[field.name])}`, - ); - return `{${parts.join(',')}}`; - } - case 'tuple': - return `[${schema.items.map((itemSchema, index) => canonicalizeSchemaValue(itemSchema, value[index])).join(',')}]`; - case 'array': - return `[${value.map((item) => canonicalizeSchemaValue(schema.items, item)).join(',')}]`; - case 'union': { - const variant = schema.variants.find((candidate) => candidate.tag === value.tag); - if (!variant) return 'null'; - return `{"tag":${JSON.stringify(value.tag)},"value":${canonicalizeSchemaValue(variant.schema, value.value)}}`; - } - default: - throw new Error(`unreachable schema type: ${schema.type}`); - } -} - -// ── Strict JSON parsing (no `JSON.parse`) ──────────────────────────────────── - -const MAX_JSON_PARSE_DEPTH = 32; -const JSON_WHITESPACE = new Set([' ', '\t', '\n', '\r']); - -function skipJsonWhitespace(text, index) { - let i = index; - while (i < text.length && JSON_WHITESPACE.has(text[i])) i++; - return i; -} - -function parseJsonStringLiteral(text, start) { - if (text[start] !== '"') return undefined; - - let i = start + 1; - let value = ''; - while (i < text.length) { - const ch = text[i]; - - if (ch === '"') return { value, endIndex: i + 1 }; - - if (ch === '\\') { - const escape = text[i + 1]; - switch (escape) { - case '"': value += '"'; i += 2; continue; - case '\\': value += '\\'; i += 2; continue; - case '/': value += '/'; i += 2; continue; - case 'b': value += '\b'; i += 2; continue; - case 'f': value += '\f'; i += 2; continue; - case 'n': value += '\n'; i += 2; continue; - case 'r': value += '\r'; i += 2; continue; - case 't': value += '\t'; i += 2; continue; - case 'u': { - const hex = text.slice(i + 2, i + 6); - if (!/^[0-9a-fA-F]{4}$/.test(hex)) return undefined; - value += String.fromCharCode(parseInt(hex, 16)); - i += 6; - continue; - } - default: - return undefined; - } - } - - if (ch.charCodeAt(0) < 0x20) return undefined; - value += ch; - i++; - } - - return undefined; -} - -function parseJsonNumber(text, start) { - let i = start; - if (text[i] === '-') i++; - if (text[i] === '0') { - i++; - } else if (text[i] >= '1' && text[i] <= '9') { - while (text[i] >= '0' && text[i] <= '9') i++; - } else { - return undefined; - } - if (text[i] === '.') { - i++; - if (!(text[i] >= '0' && text[i] <= '9')) return undefined; - while (text[i] >= '0' && text[i] <= '9') i++; - } - if (text[i] === 'e' || text[i] === 'E') { - i++; - if (text[i] === '+' || text[i] === '-') i++; - if (!(text[i] >= '0' && text[i] <= '9')) return undefined; - while (text[i] >= '0' && text[i] <= '9') i++; - } - const raw = text.slice(start, i); - const value = Number(raw); - if (!Number.isFinite(value)) return undefined; - return { value, endIndex: i }; -} - -function parseJsonValue(text, index, depth) { - if (depth > MAX_JSON_PARSE_DEPTH) return undefined; - const ch = text[index]; - - if (ch === '{') return parseJsonObject(text, index, depth); - if (ch === '[') return parseJsonArray(text, index, depth); - if (ch === '"') { - const literal = parseJsonStringLiteral(text, index); - return literal && { value: literal.value, endIndex: literal.endIndex }; - } - if (text.startsWith('true', index)) return { value: true, endIndex: index + 4 }; - if (text.startsWith('false', index)) return { value: false, endIndex: index + 5 }; - if (text.startsWith('null', index)) return { value: null, endIndex: index + 4 }; - if (ch === '-' || (ch >= '0' && ch <= '9')) return parseJsonNumber(text, index); - return undefined; -} - -function parseJsonObject(text, index, depth) { - let i = skipJsonWhitespace(text, index + 1); - const obj = {}; - if (text[i] === '}') return { value: obj, endIndex: i + 1 }; - - for (;;) { - i = skipJsonWhitespace(text, i); - const key = parseJsonStringLiteral(text, i); - if (!key) return undefined; - i = skipJsonWhitespace(text, key.endIndex); - if (text[i] !== ':') return undefined; - i = skipJsonWhitespace(text, i + 1); - const value = parseJsonValue(text, i, depth + 1); - if (!value) return undefined; - if (Object.prototype.hasOwnProperty.call(obj, key.value)) return undefined; - obj[key.value] = value.value; - i = skipJsonWhitespace(text, value.endIndex); - if (text[i] === ',') { i += 1; continue; } - if (text[i] === '}') return { value: obj, endIndex: i + 1 }; - return undefined; - } -} - -function parseJsonArray(text, index, depth) { - let i = skipJsonWhitespace(text, index + 1); - const arr = []; - if (text[i] === ']') return { value: arr, endIndex: i + 1 }; - - for (;;) { - i = skipJsonWhitespace(text, i); - const value = parseJsonValue(text, i, depth + 1); - if (!value) return undefined; - arr.push(value.value); - i = skipJsonWhitespace(text, value.endIndex); - if (text[i] === ',') { i += 1; continue; } - if (text[i] === ']') return { value: arr, endIndex: i + 1 }; - return undefined; - } -} - -function strictParseJson(text) { - const start = skipJsonWhitespace(text, 0); - const result = parseJsonValue(text, start, 0); - if (!result) return undefined; - const end = skipJsonWhitespace(text, result.endIndex); - if (end !== text.length) return undefined; - return { value: result.value }; -} - -// ── Request/result validation and canonical envelopes ─────────────────────── - -function validateBoundedQueryRequest(raw) { - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - return { valid: false, errors: ['request must be a JSON object'] }; - } - - const errors = []; - const { privateRepo, schema: schemaRaw, script } = raw; - const allowedKeys = new Set(['privateRepo', 'schema', 'script']); - for (const key of Object.keys(raw)) { - if (!allowedKeys.has(key)) errors.push(`request.${key} is not supported`); - } - - if (typeof privateRepo !== 'string' || privateRepo.length === 0) { - errors.push('privateRepo must be a non-empty string'); - } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) { - errors.push( - 'privateRepo must be an "owner/repo" slug (no scheme, host, path traversal, query, fragment, or wildcard)', - ); - } - - const schemaValidation = validateSchema(schemaRaw); - if (!schemaValidation.valid) { - errors.push(...schemaValidation.errors.map((error) => `schema: ${error}`)); - } - - if (typeof script !== 'string' || script.length === 0) { - errors.push('script must be a non-empty string'); - } else if (utf8ByteLength(script) > MAX_SCRIPT_BYTES) { - errors.push(`script must be at most ${MAX_SCRIPT_BYTES} bytes`); - } - - if ( - errors.length > 0 - || !schemaValidation.valid - || typeof privateRepo !== 'string' - || typeof script !== 'string' - ) { - return { valid: false, errors }; - } - - return { valid: true, request: { privateRepo, schema: schemaValidation.schema, script } }; -} - -const CANONICAL_ERROR_JSON = '{"status":"error"}'; - -function canonicalOkJson(canonicalResultJson) { - return `{"status":"ok","result":${canonicalResultJson}}`; -} - -function parseAndValidateQueryOutput(raw, schema) { - if (utf8ByteLength(raw) > MAX_RESULT_BYTES) return { ok: false }; - const parsed = strictParseJson(raw); - if (!parsed) return { ok: false }; - if (!validateValueAgainstSchema(schema, parsed.value)) return { ok: false }; - return { ok: true, canonical: canonicalizeSchemaValue(schema, parsed.value) }; -} - -module.exports = { - QUERY_PROTOCOL_VERSION, - MAX_SCHEMA_BYTES, - MAX_SCHEMA_DEPTH, - MAX_SCHEMA_NODES, - MAX_ENUM_VALUES, - MAX_LITERAL_STRING_BYTES, - MAX_OBJECT_FIELDS, - MAX_TUPLE_ITEMS, - MAX_ARRAY_LENGTH, - MAX_UNION_VARIANTS, - MAX_SCRIPT_BYTES, - MAX_RESULT_BYTES, - MAX_PRIVATE_REPO_LENGTH, - TIMING_BUCKETS_MS, - FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS, - MAX_QUERY_TIMEOUT_SECONDS, - TIMING_BUCKET_BITS, - RESULT_STATUS_BIT_COST, - BOUNDED_QUERY_REPO_PATTERN, - CANONICAL_ERROR_JSON, - validateSchema, - ceilLog2BigInt, - schemaCardinality, - queryBitsForSchema, - validateValueAgainstSchema, - canonicalizeSchemaValue, - strictParseJson, - validateBoundedQueryRequest, - canonicalOkJson, - parseAndValidateQueryOutput, -}; +// Stable bounded-query compatibility entrypoint. +module.exports = require('../bounded-execution/finite-disclosure'); diff --git a/containers/bounded-query/broker/scheduler.js b/containers/bounded-query/broker/scheduler.js index 9d633f963..14642fbe7 100644 --- a/containers/bounded-query/broker/scheduler.js +++ b/containers/bounded-query/broker/scheduler.js @@ -1,109 +1,4 @@ 'use strict'; -const { TIMING_BUCKETS_MS } = require('./protocol'); - -/** - * Response-timing bucketing. - * - * A query's actual completion latency is itself a secret-dependent signal - * (a script that raises early on one branch and runs to completion on - * another leaks information purely through wall-clock time, with no - * dependence on the declared response schema at all). This module makes - * every *launched* invocation's observable response time fall on one of a - * small, fixed set of boundaries (`TIMING_BUCKETS_MS`), regardless of how - * long the actual work took within that bucket. - * - * Design notes (see `docs/awf-config-spec.md` §14 for the full writeup): - * - * - Time is measured with a monotonic clock (`process.hrtime.bigint()` by - * default, injectable for tests), never `Date.now()`, so system clock - * adjustments cannot shift a response across a bucket boundary. - * - `waitForBucket` resolves the bucket only after query execution, result - * validation, Docker removal, and host workspace teardown complete. - * Repository size and tree shape can affect cleanup latency, so cleanup - * must be included before choosing the charged timing bucket. Invocations - * remain serialized, preventing queued requests from observing an - * unaccounted cleanup delay from the preceding invocation. - * - If processing latency already exceeds the *last* bucket boundary - * (only possible if infrastructure overhead — not the script itself, - * whose timeout preserves a final-minute processing margin — pushes total - * processing past 10 minutes), the broker fails closed: it - * treats the invocation as a canonical error and responds immediately - * rather than waiting indefinitely for a nonexistent next boundary. This - * is a deliberately safe fail-closed fallback for a pathological - * infrastructure-latency edge case, not a normal code path. - * - A late host-scheduler wake after waiting for the final bucket does not - * turn an already completed invocation into an overflow. That delay occurs - * only after secret-dependent processing has finished and is attributable - * to public host scheduling; there is no later fixed bucket to pad to. - */ - -/** - * Public host-scheduler tolerance after a requested wake-up. Delays beyond - * this bound are padded to the next fixed boundary instead of being returned - * at a continuously varying late time. - */ -const TIMER_WAKE_TOLERANCE_MS = 5; - -/** Resolves the smallest configured bucket at or after `elapsedMs`. */ -function resolveTimingBucket(elapsedMs) { - for (const bucketMs of TIMING_BUCKETS_MS) { - if (elapsedMs <= bucketMs) return { bucketMs, overflowed: false }; - } - return { bucketMs: TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1], overflowed: true }; -} - -/** Real monotonic clock. Milliseconds, sub-millisecond precision preserved as a float. */ -function createRealClock() { - return { - nowMs: () => Number(process.hrtime.bigint()) / 1e6, - sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), - }; -} - -/** - * Waits (if necessary) until `startMs + bucket` on `clock`, where `bucket` - * is the smallest configured boundary at or after `elapsedMs`. - * - * `elapsedMs` is measured by the caller as `clock.nowMs() - startMs` at the - * moment all processing (including cleanup) completed. This function only - * performs the remaining wait to the selected fixed boundary. - * - * @returns `{ bucketMs, overflowed }`. When `overflowed` is `true`, the - * caller must fail closed (canonical error) rather than waiting further. - */ -async function waitForBucket(startMs, elapsedMs, clock) { - let observedElapsedMs = Math.max(elapsedMs, clock.nowMs() - startMs); - - while (true) { - const { bucketMs, overflowed } = resolveTimingBucket(observedElapsedMs); - if (overflowed) return { bucketMs, overflowed }; - - const targetMs = startMs + bucketMs; - const remainingMs = targetMs - clock.nowMs(); - if (remainingMs === 0) { - return { bucketMs, overflowed: false }; - } - if (remainingMs < 0) { - observedElapsedMs = clock.nowMs() - startMs; - continue; - } - - await clock.sleep(remainingMs); - const wakeMs = clock.nowMs(); - const isFinalBucket = bucketMs === TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1]; - if (wakeMs <= targetMs + TIMER_WAKE_TOLERANCE_MS || isFinalBucket) { - return { bucketMs, overflowed: false }; - } - - observedElapsedMs = wakeMs - startMs; - } -} - -module.exports = { - TIMING_BUCKETS_MS, - TIMER_WAKE_TOLERANCE_MS, - resolveTimingBucket, - createRealClock, - waitForBucket, -}; +// Stable bounded-query compatibility entrypoint. +module.exports = require('../bounded-execution/fixed-timing'); diff --git a/containers/bounded-query/broker/sensitivity.js b/containers/bounded-query/broker/sensitivity.js index d13933240..a50223306 100644 --- a/containers/bounded-query/broker/sensitivity.js +++ b/containers/bounded-query/broker/sensitivity.js @@ -1,27 +1,4 @@ 'use strict'; -/** - * Repository sensitivity categories and their fixed per-run information - * budgets — broker-side mirror of `BOUNDED_QUERY_SENSITIVITY_RUN_BITS` in - * `src/types/bounded-query-options.ts`. Kept in a tiny standalone module (not - * `protocol.js`) because it is config/ledger data, not wire protocol. - * - * `null` means "unmetered": `public` still runs through the same finite - * schema/result validation and operational limits (`maxInvocations`, - * timeouts, sandboxing) as every other category, but its responses are not - * debited against a confidentiality ledger. `sealed` is `0`, which — - * because every accepted query's minimum charge is 4 bits (1 status bit + - * 3 timing bits) — always exceeds the remaining balance, so a `sealed` - * repository can never fund a single query and therefore never copies a - * seed or launches Python. - */ -const BOUNDED_QUERY_SENSITIVITIES = ['public', 'internal', 'confidential', 'sealed']; - -const BOUNDED_QUERY_SENSITIVITY_RUN_BITS = { - public: null, - internal: 64, - confidential: 8, - sealed: 0, -}; - -module.exports = { BOUNDED_QUERY_SENSITIVITIES, BOUNDED_QUERY_SENSITIVITY_RUN_BITS }; +// Stable bounded-query compatibility entrypoint. +module.exports = require('../bounded-execution/sensitivity-policy'); diff --git a/src/bounded-execution/compatibility.test.ts b/src/bounded-execution/compatibility.test.ts new file mode 100644 index 000000000..a0748720d --- /dev/null +++ b/src/bounded-execution/compatibility.test.ts @@ -0,0 +1,130 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + BOUNDED_QUERY_SEED_MAP_VERSION, + CANONICAL_ERROR_JSON, + PRIVATE_REPOSITORY_SEED_MAP_VERSION, + canonicalOkJson, + informationChargeForSchema, + queryBitsForSchema, + serializePrivateRepositorySeedMap, + validateFiniteSchema, + validateSchema, + type BoundedQuerySeedMap, + type PrivateRepositorySeedMap, +} from './index'; +import * as boundedQueryProtocol from '../bounded-query/protocol'; +import * as boundedQueryTypes from '../bounded-query/types'; + +/* eslint-disable @typescript-eslint/no-require-imports */ +const sharedRuntime = require( + path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'bounded-execution'), +); +const queryProtocol = require( + path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'protocol.js'), +); +const queryLedger = require( + path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'ledger.js'), +); +const queryScheduler = require( + path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'scheduler.js'), +); +const queryAudit = require( + path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'audit.js'), +); +/* eslint-enable @typescript-eslint/no-require-imports */ + +describe('bounded-execution compatibility foundation', () => { + it('keeps TypeScript bounded-query exports on the shared implementations', () => { + expect(boundedQueryProtocol.validateSchema).toBe(validateSchema); + expect(boundedQueryProtocol.canonicalOkJson).toBe(canonicalOkJson); + expect(boundedQueryTypes.BOUNDED_QUERY_SEED_MAP_VERSION).toBe(BOUNDED_QUERY_SEED_MAP_VERSION); + }); + + it('preserves schema acceptance, rejection, canonical bytes, and information charges', () => { + const accepted = { type: 'object', fields: { z: { type: 'boolean' }, a: { type: 'boolean' } } }; + const rejected = { type: 'object', fields: {} }; + const validation = validateFiniteSchema(accepted); + expect(validation).toEqual(validateSchema(accepted)); + expect(validateFiniteSchema(rejected)).toEqual(validateSchema(rejected)); + if (!validation.valid) throw new Error('expected accepted schema'); + + expect(informationChargeForSchema(validation.schema)).toBe(queryBitsForSchema(validation.schema)); + expect(canonicalOkJson('{"a":false,"z":true}')).toBe( + '{"status":"ok","result":{"a":false,"z":true}}', + ); + expect(CANONICAL_ERROR_JSON).toBe('{"status":"error"}'); + }); + + it('keeps broker compatibility modules identical to the shared runtime', () => { + expect(queryProtocol.validateSchema).toBe(sharedRuntime.validateSchema); + expect(queryProtocol.canonicalOkJson('"ok"')).toBe(sharedRuntime.canonicalSuccessJson('"ok"')); + expect(queryLedger.createLedger).toBe(sharedRuntime.createSensitivityLedger); + expect(queryScheduler.resolveTimingBucket).toBe(sharedRuntime.resolveTimingBucket); + expect(queryAudit.createAuditLog).toBe(sharedRuntime.createProtectedAuditLog); + }); + + it('preserves ledger debit decisions and fixed timing bucket choice', () => { + const seeds = new Map([['octo/private', { seedId: 'a'.repeat(32), sensitivity: 'confidential' }]]); + const legacy = queryLedger.createLedger(seeds); + const shared = sharedRuntime.createSensitivityLedger(seeds); + + for (const charge of [4, 4, 1]) { + expect(shared.tryDebit('octo/private', charge)).toBe(legacy.tryDebit('octo/private', charge)); + expect(shared.remainingBits('octo/private')).toBe(legacy.remainingBits('octo/private')); + } + for (const elapsed of [0, 10, 11, 100, 60_001, 600_001]) { + expect(sharedRuntime.resolveTimingBucket(elapsed)).toEqual(queryScheduler.resolveTimingBucket(elapsed)); + } + }); + + it('preserves protected audit detail bounding and canonical record shape', () => { + const detail = `secret-${'x'.repeat(sharedRuntime.MAX_REASON_LENGTH + 20)}`; + expect(sharedRuntime.redactAuditDetail(detail)).toBe(detail.slice(0, sharedRuntime.MAX_REASON_LENGTH)); + + const auditDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-bounded-execution-audit-')); + try { + const audit = sharedRuntime.createProtectedAuditLog(auditDir); + audit.failure('invocation-1', 'query-error', detail); + const record = JSON.parse( + fs.readFileSync(path.join(auditDir, 'bounded-query.jsonl'), 'utf8').trim(), + ); + expect(record).toMatchObject({ + kind: 'failure', + invocationId: 'invocation-1', + reason: 'query-error', + detail: detail.slice(0, sharedRuntime.MAX_REASON_LENGTH), + }); + expect(Object.keys(record).sort()).toEqual( + ['ts', 'kind', 'invocationId', 'reason', 'detail'].sort(), + ); + } finally { + fs.rmSync(auditDir, { recursive: true, force: true }); + } + }); + + it('keeps private repository staging descriptors and serialized bytes unchanged', () => { + expect(PRIVATE_REPOSITORY_SEED_MAP_VERSION).toBe(BOUNDED_QUERY_SEED_MAP_VERSION); + const shared: PrivateRepositorySeedMap = { + version: PRIVATE_REPOSITORY_SEED_MAP_VERSION, + runId: 'f'.repeat(32), + seeds: [{ repo: 'octo/private', seedId: 'a'.repeat(32), sensitivity: 'internal' }], + }; + const legacy: BoundedQuerySeedMap = shared; + + const expected = JSON.stringify(legacy, null, 2) + '\n'; + expect(serializePrivateRepositorySeedMap(shared)).toBe(expected); + + const parsed = sharedRuntime.parsePrivateRepositorySeedMap( + expected, + sharedRuntime.SENSITIVITY_RUN_BITS, + ); + expect(parsed).toEqual({ + runId: shared.runId, + seeds: new Map([ + ['octo/private', { seedId: 'a'.repeat(32), sensitivity: 'internal' }], + ]), + }); + }); +}); diff --git a/src/bounded-execution/finite-disclosure.ts b/src/bounded-execution/finite-disclosure.ts new file mode 100644 index 000000000..c19e9a3af --- /dev/null +++ b/src/bounded-execution/finite-disclosure.ts @@ -0,0 +1,851 @@ +/** + * Bounded-query request/result protocol v2: a deliberately finite, + * agent-authored response-schema algebra plus request/result validation and + * canonicalization. + * + * This module defines the wire protocol for bounded queries independently of + * any broker or sandbox runtime. + * + * Protocol summary: + * - A **request** asks the trusted broker to run an agent-authored Python + * script against a private repository and report a value conforming to + * an agent-authored, but AWF-bounded, finite response **schema**. + * - The schema is drawn from a small, closed algebra (`const`, `boolean`, + * unique `enum`, bounded `integer`, fixed `object`, `tuple`, fixed-length + * `array`, and tagged `union`) — general JSON Schema is not accepted. + * Every construct has a computable, finite cardinality (number of + * distinguishable values), calculated with `BigInt` so it can never + * silently overflow. + * - A **result** is always exactly one of two canonical envelopes: + * `{"status":"ok","result":}` or `{"status":"error"}`. The error + * state lives *outside* the declared schema — the schema only describes + * the shape of a successful `result` value — so, unlike protocol v1, + * schemas never need a reserved sentinel member. + * - Every accepted invocation reserves a fixed number of information-budget + * bits: one for the ok/error distinction, `ceil(log2(cardinality))` for + * the success payload, and {@link TIMING_BUCKET_BITS} for the observable + * response-timing bucket (see `docs/awf-config-spec.md` §14). Budget + * accounting itself lives in the broker's per-repository ledger; this + * module only computes the charge for a given schema. + * + * Schema parsing and result parsing deliberately do NOT use a general-purpose + * JSON Schema validator, nor `JSON.parse`. Both use small, hand-written, + * linear-time (no backtracking) recursive-descent parsers below, bounded by + * fixed depth/node/size limits, so nothing attacker-influenced (schema text + * or query output) can grow an unbounded parse tree, and duplicate object + * keys — which `JSON.parse` would silently collapse — are rejected outright. + * + * `containers/bounded-query/bounded-execution/finite-disclosure.js` is a deliberate, + * behaviour-identical mirror of this module for the broker's container + * image, which cannot import AWF's TypeScript sources. Keep both in sync; + * `src/bounded-query/protocol-parity.test.ts` runs shared vectors through + * both and fails the moment they disagree. + */ + +/** Wire protocol version. Only this exact value is accepted. */ +export const QUERY_PROTOCOL_VERSION = 2; + +/** Maximum size, in UTF-8 bytes, of a serialized agent-authored schema. */ +export const MAX_SCHEMA_BYTES = 4096; + +/** Maximum nesting depth of a schema (object/tuple/array/union children). */ +export const MAX_SCHEMA_DEPTH = 6; + +/** Maximum total number of schema nodes (bounds parse/cardinality work). */ +export const MAX_SCHEMA_NODES = 64; + +/** Maximum number of members in one `enum` schema. */ +export const MAX_ENUM_VALUES = 4096; + +/** Maximum size, in UTF-8 bytes, of one `const`/`enum` string literal. */ +export const MAX_LITERAL_STRING_BYTES = 64; + +/** Maximum number of fields in one `object` schema. */ +export const MAX_OBJECT_FIELDS = 16; + +/** Maximum number of items in one `tuple` schema. */ +export const MAX_TUPLE_ITEMS = 16; + +/** Maximum fixed length of one `array` schema. */ +export const MAX_ARRAY_LENGTH = 64; + +/** Maximum number of variants in one `union` schema. */ +export const MAX_UNION_VARIANTS = 16; + +/** Maximum size, in UTF-8 bytes, of a query script. */ +export const MAX_SCRIPT_BYTES = 64 * 1024; + +/** Maximum size, in UTF-8 bytes, of the query's raw output file. */ +export const MAX_RESULT_BYTES = 8 * 1024; + +/** Maximum length of a `privateRepo` "owner/repo" slug. */ +export const MAX_PRIVATE_REPO_LENGTH = 140; + +/** Number of observable response-timing buckets (see `docs/awf-config-spec.md` §14). */ +export const TIMING_BUCKETS_MS: readonly number[] = [10, 100, 1_000, 10_000, 60_000, 600_000]; + +/** + * Time reserved inside the final bucket for Docker termination, result + * validation, container removal, and workspace cleanup after the script's + * configured wall-clock budget expires. + */ +export const FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS = 60_000; + +/** Largest configurable script timeout while preserving the final-bucket margin. */ +export const MAX_QUERY_TIMEOUT_SECONDS = + (TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] - FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS) / 1000; + +/** + * Bits reserved for the timing side channel: `ceil(log2(TIMING_BUCKETS_MS.length))`. + * Fixed at 3 for the current six-bucket design; recomputed defensively below + * so the constant can never silently drift out of sync with the bucket list. + */ +export const TIMING_BUCKET_BITS = ceilLog2(TIMING_BUCKETS_MS.length); + +/** Bits reserved for the canonical ok/error distinction. */ +export const RESULT_STATUS_BIT_COST = 1; + +/** + * Matches a bare `owner/repo` slug only: no scheme/host (`://`), no path + * traversal (`..`), no query string or fragment (`?`/`#`), no wildcard + * (`*`), and no extra path segments (only one `/` is allowed). + * + * Keep in sync with `boundedQueries.privateRepos.items` in + * `docs/awf-config.schema.json` (JSON Schema cannot share a regex constant + * with TypeScript source). + */ +export const BOUNDED_QUERY_REPO_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)(?!.*\.\.)[A-Za-z0-9._-]{1,100}$/; + +/** Bounded ASCII identifier accepted for object field names and union tags. */ +const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; + +function utf8ByteLength(value: string): number { + return Buffer.byteLength(value, 'utf8'); +} + +function hasControlCharacters(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code === 0x7f) return true; + } + return false; +} + +/** Non-negative integer ceiling of `log2(n)`, for plain (non-BigInt) `n >= 1`. */ +function ceilLog2(n: number): number { + return ceilLog2BigInt(BigInt(n)); +} + +// ── Finite schema algebra ──────────────────────────────────────────────────── + +/** A JSON scalar literal usable in `const`/`enum` schema nodes. */ +export type JsonLiteral = string | number | boolean | null; + +export interface ConstSchemaNode { + readonly type: 'const'; + readonly value: JsonLiteral; +} +export interface BooleanSchemaNode { + readonly type: 'boolean'; +} +export interface EnumSchemaNode { + readonly type: 'enum'; + readonly values: readonly JsonLiteral[]; +} +export interface IntegerSchemaNode { + readonly type: 'integer'; + readonly minimum: number; + readonly maximum: number; +} +export interface ObjectSchemaNode { + readonly type: 'object'; + readonly fields: readonly { name: string; schema: BoundedQuerySchemaNode }[]; +} +export interface TupleSchemaNode { + readonly type: 'tuple'; + readonly items: readonly BoundedQuerySchemaNode[]; +} +export interface ArraySchemaNode { + readonly type: 'array'; + readonly items: BoundedQuerySchemaNode; + readonly length: number; +} +export interface UnionSchemaNode { + readonly type: 'union'; + readonly variants: readonly { tag: string; schema: BoundedQuerySchemaNode }[]; +} + +/** + * A validated, finite response schema. + * + * This is the *parsed* representation — every instance has already passed + * {@link validateSchema}'s bounds (depth, node count, enum/field/item counts, + * literal sizes). Cardinality, value validation, and canonical serialization + * below all assume that. + */ +export type BoundedQuerySchemaNode = + | ConstSchemaNode + | BooleanSchemaNode + | EnumSchemaNode + | IntegerSchemaNode + | ObjectSchemaNode + | TupleSchemaNode + | ArraySchemaNode + | UnionSchemaNode; + +export type BoundedQuerySchemaValidation = + | { valid: true; schema: BoundedQuerySchemaNode } + | { valid: false; errors: string[] }; + +function isValidLiteral(value: unknown): value is JsonLiteral { + if (value === null) return true; + if (typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isInteger(value) && Number.isSafeInteger(value); + if (typeof value === 'string') { + return !hasControlCharacters(value) && utf8ByteLength(value) <= MAX_LITERAL_STRING_BYTES; + } + return false; +} + +function literalTypeTag(value: JsonLiteral): string { + return value === null ? 'null' : typeof value; +} + +interface SchemaParseContext { + errors: string[]; + nodeCount: number; +} + +function failSchema(ctx: SchemaParseContext, message: string): undefined { + if (ctx.errors.length === 0) ctx.errors.push(message); + return undefined; +} + +/** + * Builds one validated {@link BoundedQuerySchemaNode}, enforcing every finite + * bound as it recurses. Stops at the first violation (`ctx.errors` becomes + * non-empty) rather than continuing to build a tree that will be discarded. + */ +function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): BoundedQuerySchemaNode | undefined { + if (ctx.errors.length > 0) return undefined; + if (depth > MAX_SCHEMA_DEPTH) { + return failSchema(ctx, `schema exceeds maximum depth of ${MAX_SCHEMA_DEPTH}`); + } + ctx.nodeCount += 1; + if (ctx.nodeCount > MAX_SCHEMA_NODES) { + return failSchema(ctx, `schema exceeds maximum node count of ${MAX_SCHEMA_NODES}`); + } + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return failSchema(ctx, 'schema node must be a JSON object'); + } + + const node = raw as Record; + switch (node.type) { + case 'const': { + if (Object.keys(node).length !== 2 || !('value' in node)) { + return failSchema(ctx, 'const schema must have exactly "type" and "value"'); + } + if (!isValidLiteral(node.value)) { + return failSchema(ctx, 'const value must be a bounded string, a safe integer, a boolean, or null'); + } + return { type: 'const', value: node.value as JsonLiteral }; + } + case 'boolean': { + if (Object.keys(node).length !== 1) { + return failSchema(ctx, 'boolean schema must have only "type"'); + } + return { type: 'boolean' }; + } + case 'enum': { + if (Object.keys(node).length !== 2 || !('values' in node)) { + return failSchema(ctx, 'enum schema must have exactly "type" and "values"'); + } + const values = node.values; + if (!Array.isArray(values) || values.length === 0) { + return failSchema(ctx, 'enum values must be a non-empty array'); + } + if (values.length > MAX_ENUM_VALUES) { + return failSchema(ctx, `enum values must contain at most ${MAX_ENUM_VALUES} entries`); + } + for (const value of values) { + if (!isValidLiteral(value)) { + return failSchema(ctx, 'enum values must be bounded strings, safe integers, booleans, or null'); + } + } + const literals = values as JsonLiteral[]; + const firstTag = literalTypeTag(literals[0]); + if (!literals.every((value) => literalTypeTag(value) === firstTag)) { + return failSchema(ctx, 'enum values must all be the same JSON type'); + } + const uniqueCount = new Set(literals.map((value) => JSON.stringify(value))).size; + if (uniqueCount !== literals.length) { + return failSchema(ctx, 'enum values must be unique'); + } + return { type: 'enum', values: literals }; + } + case 'integer': { + if (Object.keys(node).length !== 3 || !('minimum' in node) || !('maximum' in node)) { + return failSchema(ctx, 'integer schema must have exactly "type", "minimum", and "maximum"'); + } + const { minimum, maximum } = node; + if (typeof minimum !== 'number' || !Number.isSafeInteger(minimum)) { + return failSchema(ctx, 'integer minimum must be a safe integer'); + } + if (typeof maximum !== 'number' || !Number.isSafeInteger(maximum)) { + return failSchema(ctx, 'integer maximum must be a safe integer'); + } + if (maximum < minimum) { + return failSchema(ctx, 'integer maximum must be >= minimum'); + } + return { type: 'integer', minimum, maximum }; + } + case 'object': { + if (Object.keys(node).length !== 2 || !('fields' in node)) { + return failSchema(ctx, 'object schema must have exactly "type" and "fields"'); + } + const fieldsRaw = node.fields; + if (typeof fieldsRaw !== 'object' || fieldsRaw === null || Array.isArray(fieldsRaw)) { + return failSchema(ctx, 'object "fields" must be a JSON object mapping field name to schema'); + } + const fieldNames = Object.keys(fieldsRaw); + if (fieldNames.length === 0) { + return failSchema(ctx, 'object schema must declare at least one field'); + } + if (fieldNames.length > MAX_OBJECT_FIELDS) { + return failSchema(ctx, `object schema must declare at most ${MAX_OBJECT_FIELDS} fields`); + } + for (const name of fieldNames) { + if (!IDENTIFIER_PATTERN.test(name)) { + return failSchema(ctx, `object field name "${name}" is not a bounded ASCII identifier`); + } + } + const fields: { name: string; schema: BoundedQuerySchemaNode }[] = []; + for (const name of fieldNames) { + const child = buildSchemaNode((fieldsRaw as Record)[name], ctx, depth + 1); + if (!child) return undefined; + fields.push({ name, schema: child }); + } + return { type: 'object', fields }; + } + case 'tuple': { + if (Object.keys(node).length !== 2 || !('items' in node)) { + return failSchema(ctx, 'tuple schema must have exactly "type" and "items"'); + } + const itemsRaw = node.items; + if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) { + return failSchema(ctx, 'tuple "items" must be a non-empty array'); + } + if (itemsRaw.length > MAX_TUPLE_ITEMS) { + return failSchema(ctx, `tuple schema must declare at most ${MAX_TUPLE_ITEMS} items`); + } + const items: BoundedQuerySchemaNode[] = []; + for (const itemRaw of itemsRaw) { + const child = buildSchemaNode(itemRaw, ctx, depth + 1); + if (!child) return undefined; + items.push(child); + } + return { type: 'tuple', items }; + } + case 'array': { + if (Object.keys(node).length !== 3 || !('items' in node) || !('length' in node)) { + return failSchema(ctx, 'array schema must have exactly "type", "items", and "length"'); + } + const { length } = node; + if (typeof length !== 'number' || !Number.isInteger(length) || length < 0 || length > MAX_ARRAY_LENGTH) { + return failSchema(ctx, `array "length" must be an integer between 0 and ${MAX_ARRAY_LENGTH}`); + } + const child = buildSchemaNode(node.items, ctx, depth + 1); + if (!child) return undefined; + return { type: 'array', items: child, length }; + } + case 'union': { + if (Object.keys(node).length !== 2 || !('variants' in node)) { + return failSchema(ctx, 'union schema must have exactly "type" and "variants"'); + } + const variantsRaw = node.variants; + if (typeof variantsRaw !== 'object' || variantsRaw === null || Array.isArray(variantsRaw)) { + return failSchema(ctx, 'union "variants" must be a JSON object mapping tag to schema'); + } + const tags = Object.keys(variantsRaw); + if (tags.length === 0) { + return failSchema(ctx, 'union schema must declare at least one variant'); + } + if (tags.length > MAX_UNION_VARIANTS) { + return failSchema(ctx, `union schema must declare at most ${MAX_UNION_VARIANTS} variants`); + } + for (const tag of tags) { + if (!IDENTIFIER_PATTERN.test(tag)) { + return failSchema(ctx, `union tag "${tag}" is not a bounded ASCII identifier`); + } + } + const variants: { tag: string; schema: BoundedQuerySchemaNode }[] = []; + for (const tag of tags) { + const child = buildSchemaNode((variantsRaw as Record)[tag], ctx, depth + 1); + if (!child) return undefined; + variants.push({ tag, schema: child }); + } + return { type: 'union', variants }; + } + default: + return failSchema( + ctx, + 'schema node "type" must be one of: const, boolean, enum, integer, object, tuple, array, union', + ); + } +} + +/** + * Validates and parses an agent-authored schema. + * + * Rejects anything outside the finite algebra above: unbounded strings, + * floats, regex domains, recursion/`$ref` (there is no such construct to + * begin with), optional properties, `additionalProperties`, and overlapping + * untagged unions are all structurally impossible to express, so they are + * rejected by construction rather than by a separate deny-list. + */ +export function validateSchema(raw: unknown): BoundedQuerySchemaValidation { + let serialized: string; + try { + serialized = JSON.stringify(raw) ?? ''; + } catch { + return { valid: false, errors: ['schema must be JSON-serializable'] }; + } + + const ctx: SchemaParseContext = { errors: [], nodeCount: 0 }; + const schema = buildSchemaNode(raw, ctx, 0); + if (!schema || ctx.errors.length > 0) { + return { valid: false, errors: ctx.errors.length > 0 ? ctx.errors : ['invalid schema'] }; + } + if (raw === undefined || utf8ByteLength(serialized) > MAX_SCHEMA_BYTES) { + return { valid: false, errors: [`schema must be a JSON value of at most ${MAX_SCHEMA_BYTES} bytes`] }; + } + return { valid: true, schema }; +} + +/** Ceiling of `log2(n)` for a non-negative `BigInt`, without floating point. */ +export function ceilLog2BigInt(n: bigint): number { + if (n <= 1n) return 0; + let bits = 0; + let remainder = n - 1n; + while (remainder > 0n) { + remainder >>= 1n; + bits += 1; + } + return bits; +} + +/** + * Computes a schema's successful-outcome cardinality (number of + * distinguishable valid values) as a `BigInt`, so it can never silently + * overflow even for schemas near the configured bounds. + */ +export function schemaCardinality(schema: BoundedQuerySchemaNode): bigint { + switch (schema.type) { + case 'const': + return 1n; + case 'boolean': + return 2n; + case 'enum': + return BigInt(schema.values.length); + case 'integer': + return BigInt(schema.maximum) - BigInt(schema.minimum) + 1n; + case 'object': + return schema.fields.reduce((acc, field) => acc * schemaCardinality(field.schema), 1n); + case 'tuple': + return schema.items.reduce((acc, item) => acc * schemaCardinality(item), 1n); + case 'array': + return schemaCardinality(schema.items) ** BigInt(schema.length); + case 'union': + return schema.variants.reduce((acc, variant) => acc + schemaCardinality(variant.schema), 0n); + } +} + +/** + * The maximum complete-transcript information charge, in bits, for one + * invocation using this schema: + * + * ```text + * queryBits = 1 (ok/error) + ceil(log2(successCardinality)) + 3 (timing) + * ``` + * + * This is the value the broker's per-repository ledger debits *before* + * copying a seed or launching Python — never refunded, regardless of the + * actual result or completion bucket. + */ +export function queryBitsForSchema(schema: BoundedQuerySchemaNode): number { + return RESULT_STATUS_BIT_COST + ceilLog2BigInt(schemaCardinality(schema)) + TIMING_BUCKET_BITS; +} + +function jsonLiteralEquals(value: unknown, literal: JsonLiteral): boolean { + if (literal === null) return value === null; + if (typeof literal === 'number') return typeof value === 'number' && Number.isInteger(value) && value === literal; + return value === literal; +} + +/** + * Strictly validates a parsed JSON value against an already-approved schema: + * exact JSON type, enum membership, integer range, exact required + * object/tuple/array shape (no extras, no missing fields, exact length), and + * an explicit tagged-union variant. Never coerces. + */ +export function validateValueAgainstSchema(schema: BoundedQuerySchemaNode, value: unknown): boolean { + switch (schema.type) { + case 'const': + return jsonLiteralEquals(value, schema.value); + case 'boolean': + return typeof value === 'boolean'; + case 'enum': + return schema.values.some((candidate) => jsonLiteralEquals(value, candidate)); + case 'integer': + return ( + typeof value === 'number' + && Number.isInteger(value) + && value >= schema.minimum + && value <= schema.maximum + ); + case 'object': { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const obj = value as Record; + if (Object.keys(obj).length !== schema.fields.length) return false; + return schema.fields.every( + (field) => + Object.prototype.hasOwnProperty.call(obj, field.name) + && validateValueAgainstSchema(field.schema, obj[field.name]), + ); + } + case 'tuple': + return ( + Array.isArray(value) + && value.length === schema.items.length + && schema.items.every((itemSchema, index) => validateValueAgainstSchema(itemSchema, value[index])) + ); + case 'array': + return ( + Array.isArray(value) + && value.length === schema.length + && value.every((item) => validateValueAgainstSchema(schema.items, item)) + ); + case 'union': { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const obj = value as Record; + if (Object.keys(obj).length !== 2 || !('tag' in obj) || !('value' in obj) || typeof obj.tag !== 'string') { + return false; + } + const variant = schema.variants.find((candidate) => candidate.tag === obj.tag); + return variant !== undefined && validateValueAgainstSchema(variant.schema, obj.value); + } + } +} + +/** + * Canonically re-serializes an already-validated value. + * + * The broker calls this on its own parsed representation — never on the raw + * bytes a query wrote — so two different serializations of the same + * semantic value (whitespace, key order, numeric formatting) collapse to the + * identical observable transcript. + */ +export function canonicalizeSchemaValue(schema: BoundedQuerySchemaNode, value: unknown): string { + switch (schema.type) { + case 'const': + return JSON.stringify(schema.value); + case 'boolean': + case 'enum': + case 'integer': + return JSON.stringify(value); + case 'object': { + const obj = value as Record; + const parts = schema.fields.map( + (field) => `${JSON.stringify(field.name)}:${canonicalizeSchemaValue(field.schema, obj[field.name])}`, + ); + return `{${parts.join(',')}}`; + } + case 'tuple': { + const arr = value as unknown[]; + return `[${schema.items.map((itemSchema, index) => canonicalizeSchemaValue(itemSchema, arr[index])).join(',')}]`; + } + case 'array': { + const arr = value as unknown[]; + return `[${arr.map((item) => canonicalizeSchemaValue(schema.items, item)).join(',')}]`; + } + case 'union': { + const obj = value as { tag: string; value: unknown }; + const variant = schema.variants.find((candidate) => candidate.tag === obj.tag); + // Unreachable when `value` already passed validateValueAgainstSchema. + if (!variant) return 'null'; + return `{"tag":${JSON.stringify(obj.tag)},"value":${canonicalizeSchemaValue(variant.schema, obj.value)}}`; + } + } +} + +// ── Strict JSON parsing (no `JSON.parse`) ──────────────────────────────────── + +/** Hard cap on parser recursion, independent of any schema's own depth bound. */ +const MAX_JSON_PARSE_DEPTH = 32; + +const JSON_WHITESPACE = new Set([' ', '\t', '\n', '\r']); + +function skipJsonWhitespace(text: string, index: number): number { + let i = index; + while (i < text.length && JSON_WHITESPACE.has(text[i])) i++; + return i; +} + +interface ParsedNode { + value: unknown; + endIndex: number; +} + +/** + * Parses a JSON string literal starting at `text[start]` (`text[start]` must + * be `"`). Rejects raw control characters and invalid/unterminated escapes. + */ +function parseJsonStringLiteral(text: string, start: number): { value: string; endIndex: number } | undefined { + if (text[start] !== '"') return undefined; + + let i = start + 1; + let value = ''; + while (i < text.length) { + const ch = text[i]; + + if (ch === '"') return { value, endIndex: i + 1 }; + + if (ch === '\\') { + const escape = text[i + 1]; + switch (escape) { + case '"': value += '"'; i += 2; continue; + case '\\': value += '\\'; i += 2; continue; + case '/': value += '/'; i += 2; continue; + case 'b': value += '\b'; i += 2; continue; + case 'f': value += '\f'; i += 2; continue; + case 'n': value += '\n'; i += 2; continue; + case 'r': value += '\r'; i += 2; continue; + case 't': value += '\t'; i += 2; continue; + case 'u': { + const hex = text.slice(i + 2, i + 6); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) return undefined; + value += String.fromCharCode(parseInt(hex, 16)); + i += 6; + continue; + } + default: + return undefined; + } + } + + if (ch.charCodeAt(0) < 0x20) return undefined; + value += ch; + i++; + } + + return undefined; +} + +function parseJsonNumber(text: string, start: number): ParsedNode | undefined { + let i = start; + if (text[i] === '-') i++; + if (text[i] === '0') { + i++; + } else if (text[i] >= '1' && text[i] <= '9') { + while (text[i] >= '0' && text[i] <= '9') i++; + } else { + return undefined; + } + if (text[i] === '.') { + i++; + if (!(text[i] >= '0' && text[i] <= '9')) return undefined; + while (text[i] >= '0' && text[i] <= '9') i++; + } + if (text[i] === 'e' || text[i] === 'E') { + i++; + if (text[i] === '+' || text[i] === '-') i++; + if (!(text[i] >= '0' && text[i] <= '9')) return undefined; + while (text[i] >= '0' && text[i] <= '9') i++; + } + const raw = text.slice(start, i); + const value = Number(raw); + if (!Number.isFinite(value)) return undefined; + return { value, endIndex: i }; +} + +function parseJsonValue(text: string, index: number, depth: number): ParsedNode | undefined { + if (depth > MAX_JSON_PARSE_DEPTH) return undefined; + const ch = text[index]; + + if (ch === '{') return parseJsonObject(text, index, depth); + if (ch === '[') return parseJsonArray(text, index, depth); + if (ch === '"') { + const literal = parseJsonStringLiteral(text, index); + return literal && { value: literal.value, endIndex: literal.endIndex }; + } + if (text.startsWith('true', index)) return { value: true, endIndex: index + 4 }; + if (text.startsWith('false', index)) return { value: false, endIndex: index + 5 }; + if (text.startsWith('null', index)) return { value: null, endIndex: index + 4 }; + if (ch === '-' || (ch >= '0' && ch <= '9')) return parseJsonNumber(text, index); + return undefined; +} + +function parseJsonObject(text: string, index: number, depth: number): ParsedNode | undefined { + let i = skipJsonWhitespace(text, index + 1); + const obj: Record = {}; + if (text[i] === '}') return { value: obj, endIndex: i + 1 }; + + for (;;) { + i = skipJsonWhitespace(text, i); + const key = parseJsonStringLiteral(text, i); + if (!key) return undefined; + i = skipJsonWhitespace(text, key.endIndex); + if (text[i] !== ':') return undefined; + i = skipJsonWhitespace(text, i + 1); + const value = parseJsonValue(text, i, depth + 1); + if (!value) return undefined; + // Reject duplicate keys outright rather than silently keeping the last + // occurrence (which is what `JSON.parse` does) — a dedicated strict + // parser, not a more permissive result encoding, is the safer choice. + if (Object.prototype.hasOwnProperty.call(obj, key.value)) return undefined; + obj[key.value] = value.value; + i = skipJsonWhitespace(text, value.endIndex); + if (text[i] === ',') { i += 1; continue; } + if (text[i] === '}') return { value: obj, endIndex: i + 1 }; + return undefined; + } +} + +function parseJsonArray(text: string, index: number, depth: number): ParsedNode | undefined { + let i = skipJsonWhitespace(text, index + 1); + const arr: unknown[] = []; + if (text[i] === ']') return { value: arr, endIndex: i + 1 }; + + for (;;) { + i = skipJsonWhitespace(text, i); + const value = parseJsonValue(text, i, depth + 1); + if (!value) return undefined; + arr.push(value.value); + i = skipJsonWhitespace(text, value.endIndex); + if (text[i] === ',') { i += 1; continue; } + if (text[i] === ']') return { value: arr, endIndex: i + 1 }; + return undefined; + } +} + +/** + * Strictly parses exactly one JSON value from `text` — no trailing data, + * no duplicate object keys. + */ +export function strictParseJson(text: string): { value: unknown } | undefined { + const start = skipJsonWhitespace(text, 0); + const result = parseJsonValue(text, start, 0); + if (!result) return undefined; + const end = skipJsonWhitespace(text, result.endIndex); + if (end !== text.length) return undefined; + return { value: result.value }; +} + +// ── Request/result validation and canonical envelopes ─────────────────────── + +/** A bounded-query execution request, already assembled from wire framing. */ +export interface BoundedQueryRequest { + /** Private repository (`owner/repo`) the query script runs against. */ + privateRepo: string; + /** The agent-authored, AWF-bounded finite response schema. */ + schema: BoundedQuerySchemaNode; + /** The query script source. */ + script: string; +} + +export type BoundedQueryValidation = + | { valid: true; request: BoundedQueryRequest } + | { valid: false; errors: string[] }; + +/** + * Validates an unknown value as a {@link BoundedQueryRequest}: field shape, + * the `privateRepo` slug pattern, the finite response schema, and the + * script size cap. + */ +export function validateBoundedQueryRequest(raw: unknown): BoundedQueryValidation { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return { valid: false, errors: ['request must be a JSON object'] }; + } + + const errors: string[] = []; + const record = raw as Record; + const { privateRepo, schema: schemaRaw, script } = record; + const allowedKeys = new Set(['privateRepo', 'schema', 'script']); + for (const key of Object.keys(record)) { + if (!allowedKeys.has(key)) errors.push(`request.${key} is not supported`); + } + + if (typeof privateRepo !== 'string' || privateRepo.length === 0) { + errors.push('privateRepo must be a non-empty string'); + } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) { + errors.push( + 'privateRepo must be an "owner/repo" slug (no scheme, host, path traversal, query, fragment, or wildcard)', + ); + } + + const schemaValidation = validateSchema(schemaRaw); + if (!schemaValidation.valid) { + errors.push(...schemaValidation.errors.map((error) => `schema: ${error}`)); + } + + if (typeof script !== 'string' || script.length === 0) { + errors.push('script must be a non-empty string'); + } else if (utf8ByteLength(script) > MAX_SCRIPT_BYTES) { + errors.push(`script must be at most ${MAX_SCRIPT_BYTES} bytes`); + } + + if ( + errors.length > 0 + || !schemaValidation.valid + || typeof privateRepo !== 'string' + || typeof script !== 'string' + ) { + return { valid: false, errors }; + } + + return { valid: true, request: { privateRepo, schema: schemaValidation.schema, script } }; +} + +/** The canonical JSON text for every failure: `{"status":"error"}`. */ +export const CANONICAL_ERROR_JSON = '{"status":"error"}'; + +/** Wraps an already-canonicalized result value into the canonical success envelope. */ +export function canonicalOkJson(canonicalResultJson: string): string { + return `{"status":"ok","result":${canonicalResultJson}}`; +} + +/** + * Parses and validates a query's raw output file contents against the + * request's approved schema, returning the broker's own canonical + * re-serialization of the value on success. + * + * Every failure mode — oversized output, malformed JSON, duplicate keys, + * wrong type, out-of-range value, unknown enum member, missing/extra + * fields, wrong tuple/array length, unknown union tag — maps to the same + * `{ ok: false }`, which callers turn into {@link CANONICAL_ERROR_JSON}. + */ +export function parseAndValidateQueryOutput( + raw: string, + schema: BoundedQuerySchemaNode, +): { ok: true; canonical: string } | { ok: false } { + if (utf8ByteLength(raw) > MAX_RESULT_BYTES) return { ok: false }; + const parsed = strictParseJson(raw); + if (!parsed) return { ok: false }; + if (!validateValueAgainstSchema(schema, parsed.value)) return { ok: false }; + return { ok: true, canonical: canonicalizeSchemaValue(schema, parsed.value) }; +} + +/** + * Reusable bounded-execution names. The bounded-query names above remain the + * compatibility contract; these aliases expose the same implementations and + * constants to later trusted brokers without creating a second code path. + */ +export type FiniteSchemaNode = BoundedQuerySchemaNode; +export type FiniteSchemaValidation = BoundedQuerySchemaValidation; +export const validateFiniteSchema = validateSchema; +export const finiteSchemaCardinality = schemaCardinality; +export const informationChargeForSchema = queryBitsForSchema; +export const canonicalizeFiniteSchemaValue = canonicalizeSchemaValue; +export const canonicalSuccessJson = canonicalOkJson; +export const CANONICAL_ERROR_RESPONSE_JSON = CANONICAL_ERROR_JSON; diff --git a/src/bounded-execution/index.ts b/src/bounded-execution/index.ts new file mode 100644 index 000000000..5b97171ca --- /dev/null +++ b/src/bounded-execution/index.ts @@ -0,0 +1,2 @@ +export * from './finite-disclosure'; +export * from './repository-staging'; diff --git a/src/bounded-execution/repository-staging.ts b/src/bounded-execution/repository-staging.ts new file mode 100644 index 000000000..593a01031 --- /dev/null +++ b/src/bounded-execution/repository-staging.ts @@ -0,0 +1,70 @@ +/** + * Runtime descriptors for trusted private-repository staging. + * + * The request/result wire protocol lives in `./finite-disclosure.ts`; this module + * describes the host-side staging output and the seed map the trusted broker + * consumes. + */ + +import type { BoundedQuerySensitivity } from '../types/bounded-query-options'; + +/** + * Version of the on-disk seed-map document. + * + * v2 adds trusted `sensitivity` metadata to every entry (see + * {@link BoundedQuerySeedMap}) so the broker can derive each repository's + * per-run information budget without trusting anything the agent sends. + */ +export const PRIVATE_REPOSITORY_SEED_MAP_VERSION = 2; + +/** Bounded-query compatibility constant. */ +export const BOUNDED_QUERY_SEED_MAP_VERSION = PRIVATE_REPOSITORY_SEED_MAP_VERSION; + +/** One staged, immutable repository seed. */ +export interface PrivateRepositorySeedDescriptor { + /** Normalized (lowercased) `owner/repo` lookup key. */ + repoKey: string; + /** Repository slug exactly as configured, used for clone-URL construction. */ + repo: string; + /** Opaque directory name of the seed under the seeds root. */ + seedId: string; + /** Absolute host path of the immutable seed directory. */ + seedPath: string; + /** Commit the seed was materialized at, recorded for protected audit state. */ + commit: string; + /** Trusted confidentiality category, carried unmodified into the seed map. */ + sensitivity: BoundedQuerySensitivity; +} + +/** + * The document written to the dedicated broker-private host root and mounted + * read-only into the broker. + * + * It intentionally contains only what the broker needs: the mapping from a + * normalized repo id to an AWF-chosen opaque seed directory name plus its + * trusted sensitivity, and the run id used for container labelling/orphan + * cleanup. No credentials, no absolute host paths, and no caller-controllable + * fields — in particular, `sensitivity` is trusted AWF configuration state + * that a query request can never choose or override. + */ +export interface PrivateRepositorySeedMap { + version: typeof PRIVATE_REPOSITORY_SEED_MAP_VERSION; + runId: string; + seeds: Array<{ repo: string; seedId: string; sensitivity: BoundedQuerySensitivity }>; +} + +/** Result of the trusted host staging phase. */ +export interface PrivateRepositoryStagingResult { + runId: string; + seeds: PrivateRepositorySeedDescriptor[]; +} + +/** Bounded-query compatibility aliases. */ +export type BoundedQuerySeed = PrivateRepositorySeedDescriptor; +export type BoundedQuerySeedMap = PrivateRepositorySeedMap; +export type BoundedQueryStagingResult = PrivateRepositoryStagingResult; + +/** Canonically serializes the protected broker seed map. */ +export function serializePrivateRepositorySeedMap(seedMap: PrivateRepositorySeedMap): string { + return JSON.stringify(seedMap, null, 2) + '\n'; +} diff --git a/src/bounded-query/manager.ts b/src/bounded-query/manager.ts index 107323ba4..6790d41cd 100644 --- a/src/bounded-query/manager.ts +++ b/src/bounded-query/manager.ts @@ -18,7 +18,11 @@ import { import { writeBoundedQuerySkill } from './skill'; import { writeBoundedQueryWrapper } from './wrapper-artifact'; import { releaseSeedPermissions, resolveStagingToken, stageBoundedQuerySeeds, type GitRunner } from './staging'; -import { BOUNDED_QUERY_SEED_MAP_VERSION, type BoundedQuerySeedMap } from './types'; +import { + BOUNDED_QUERY_SEED_MAP_VERSION, + serializePrivateRepositorySeedMap, + type BoundedQuerySeedMap, +} from './types'; import { assertBoundedQueryPrivateRootIsolated } from './mount-policy'; import { fixArtifactPermissionsForRootless } from '../artifact-permissions'; import { runtimeUsesComposeAgent } from '../container-runtime'; @@ -123,7 +127,7 @@ function removePrivateState( /** Writes the broker's repo → opaque seed map. */ function writeSeedMap(paths: BoundedQueryPaths, seedMap: BoundedQuerySeedMap): void { - const content = JSON.stringify(seedMap, null, 2) + '\n'; + const content = serializePrivateRepositorySeedMap(seedMap); // O_EXCL | O_NOFOLLOW: atomically create; fail if a symlink or existing file // is already at this path (insecure-temp-file guard). const fd = fs.openSync( diff --git a/src/bounded-query/protocol-parity.test.ts b/src/bounded-query/protocol-parity.test.ts index 027e56d56..d0110dc8d 100644 --- a/src/bounded-query/protocol-parity.test.ts +++ b/src/bounded-query/protocol-parity.test.ts @@ -38,20 +38,33 @@ import { /** * The broker runs in its own container image and cannot import AWF's - * TypeScript sources, so `containers/bounded-query/broker/protocol.js` - * restates the entire v2 protocol (finite schema algebra, cardinality/bit + * TypeScript sources, so + * `containers/bounded-query/bounded-execution/finite-disclosure.js` restates + * the entire v2 protocol (finite schema algebra, cardinality/bit * charge, strict JSON parsing, request/result validation, canonicalization). * This suite runs one shared vector table through *both* implementations and * fails the moment they disagree, which is what makes the duplication safe. */ // eslint-disable-next-line @typescript-eslint/no-require-imports -const brokerProtocol = require( - path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'protocol.js'), -); +const brokerProtocol = require(path.join( + __dirname, + '..', + '..', + 'containers', + 'bounded-query', + 'bounded-execution', + 'finite-disclosure.js', +)); // eslint-disable-next-line @typescript-eslint/no-require-imports -const brokerSensitivity = require( - path.join(__dirname, '..', '..', 'containers', 'bounded-query', 'broker', 'sensitivity.js'), -); +const brokerSensitivity = require(path.join( + __dirname, + '..', + '..', + 'containers', + 'bounded-query', + 'bounded-execution', + 'sensitivity-policy.js', +)); const SCHEMA_VECTORS: Array<{ name: string; schema: unknown }> = [ { name: 'const string', schema: { type: 'const', value: 'ok' } }, diff --git a/src/bounded-query/protocol.ts b/src/bounded-query/protocol.ts index f8d7b0271..24397ea73 100644 --- a/src/bounded-query/protocol.ts +++ b/src/bounded-query/protocol.ts @@ -1,837 +1,8 @@ /** - * Bounded-query request/result protocol v2: a deliberately finite, - * agent-authored response-schema algebra plus request/result validation and - * canonicalization. + * Compatibility surface for the bounded-query finite-disclosure protocol. * - * This module defines the wire protocol for bounded queries independently of - * any broker or sandbox runtime. - * - * Protocol summary: - * - A **request** asks the trusted broker to run an agent-authored Python - * script against a private repository and report a value conforming to - * an agent-authored, but AWF-bounded, finite response **schema**. - * - The schema is drawn from a small, closed algebra (`const`, `boolean`, - * unique `enum`, bounded `integer`, fixed `object`, `tuple`, fixed-length - * `array`, and tagged `union`) — general JSON Schema is not accepted. - * Every construct has a computable, finite cardinality (number of - * distinguishable values), calculated with `BigInt` so it can never - * silently overflow. - * - A **result** is always exactly one of two canonical envelopes: - * `{"status":"ok","result":}` or `{"status":"error"}`. The error - * state lives *outside* the declared schema — the schema only describes - * the shape of a successful `result` value — so, unlike protocol v1, - * schemas never need a reserved sentinel member. - * - Every accepted invocation reserves a fixed number of information-budget - * bits: one for the ok/error distinction, `ceil(log2(cardinality))` for - * the success payload, and {@link TIMING_BUCKET_BITS} for the observable - * response-timing bucket (see `docs/awf-config-spec.md` §14). Budget - * accounting itself lives in the broker's per-repository ledger; this - * module only computes the charge for a given schema. - * - * Schema parsing and result parsing deliberately do NOT use a general-purpose - * JSON Schema validator, nor `JSON.parse`. Both use small, hand-written, - * linear-time (no backtracking) recursive-descent parsers below, bounded by - * fixed depth/node/size limits, so nothing attacker-influenced (schema text - * or query output) can grow an unbounded parse tree, and duplicate object - * keys — which `JSON.parse` would silently collapse — are rejected outright. - * - * `containers/bounded-query/broker/protocol.js` is a deliberate, - * behaviour-identical mirror of this module for the broker's container - * image, which cannot import AWF's TypeScript sources. Keep both in sync; - * `src/bounded-query/protocol-parity.test.ts` runs shared vectors through - * both and fails the moment they disagree. - */ - -/** Wire protocol version. Only this exact value is accepted. */ -export const QUERY_PROTOCOL_VERSION = 2; - -/** Maximum size, in UTF-8 bytes, of a serialized agent-authored schema. */ -export const MAX_SCHEMA_BYTES = 4096; - -/** Maximum nesting depth of a schema (object/tuple/array/union children). */ -export const MAX_SCHEMA_DEPTH = 6; - -/** Maximum total number of schema nodes (bounds parse/cardinality work). */ -export const MAX_SCHEMA_NODES = 64; - -/** Maximum number of members in one `enum` schema. */ -export const MAX_ENUM_VALUES = 4096; - -/** Maximum size, in UTF-8 bytes, of one `const`/`enum` string literal. */ -export const MAX_LITERAL_STRING_BYTES = 64; - -/** Maximum number of fields in one `object` schema. */ -export const MAX_OBJECT_FIELDS = 16; - -/** Maximum number of items in one `tuple` schema. */ -export const MAX_TUPLE_ITEMS = 16; - -/** Maximum fixed length of one `array` schema. */ -export const MAX_ARRAY_LENGTH = 64; - -/** Maximum number of variants in one `union` schema. */ -export const MAX_UNION_VARIANTS = 16; - -/** Maximum size, in UTF-8 bytes, of a query script. */ -export const MAX_SCRIPT_BYTES = 64 * 1024; - -/** Maximum size, in UTF-8 bytes, of the query's raw output file. */ -export const MAX_RESULT_BYTES = 8 * 1024; - -/** Maximum length of a `privateRepo` "owner/repo" slug. */ -export const MAX_PRIVATE_REPO_LENGTH = 140; - -/** Number of observable response-timing buckets (see `docs/awf-config-spec.md` §14). */ -export const TIMING_BUCKETS_MS: readonly number[] = [10, 100, 1_000, 10_000, 60_000, 600_000]; - -/** - * Time reserved inside the final bucket for Docker termination, result - * validation, container removal, and workspace cleanup after the script's - * configured wall-clock budget expires. - */ -export const FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS = 60_000; - -/** Largest configurable script timeout while preserving the final-bucket margin. */ -export const MAX_QUERY_TIMEOUT_SECONDS = - (TIMING_BUCKETS_MS[TIMING_BUCKETS_MS.length - 1] - FINAL_TIMING_BUCKET_PROCESSING_MARGIN_MS) / 1000; - -/** - * Bits reserved for the timing side channel: `ceil(log2(TIMING_BUCKETS_MS.length))`. - * Fixed at 3 for the current six-bucket design; recomputed defensively below - * so the constant can never silently drift out of sync with the bucket list. - */ -export const TIMING_BUCKET_BITS = ceilLog2(TIMING_BUCKETS_MS.length); - -/** Bits reserved for the canonical ok/error distinction. */ -export const RESULT_STATUS_BIT_COST = 1; - -/** - * Matches a bare `owner/repo` slug only: no scheme/host (`://`), no path - * traversal (`..`), no query string or fragment (`?`/`#`), no wildcard - * (`*`), and no extra path segments (only one `/` is allowed). - * - * Keep in sync with `boundedQueries.privateRepos.items` in - * `docs/awf-config.schema.json` (JSON Schema cannot share a regex constant - * with TypeScript source). - */ -export const BOUNDED_QUERY_REPO_PATTERN = - /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)(?!.*\.\.)[A-Za-z0-9._-]{1,100}$/; - -/** Bounded ASCII identifier accepted for object field names and union tags. */ -const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,63}$/; - -function utf8ByteLength(value: string): number { - return Buffer.byteLength(value, 'utf8'); -} - -function hasControlCharacters(value: string): boolean { - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - if (code < 0x20 || code === 0x7f) return true; - } - return false; -} - -/** Non-negative integer ceiling of `log2(n)`, for plain (non-BigInt) `n >= 1`. */ -function ceilLog2(n: number): number { - return ceilLog2BigInt(BigInt(n)); -} - -// ── Finite schema algebra ──────────────────────────────────────────────────── - -/** A JSON scalar literal usable in `const`/`enum` schema nodes. */ -export type JsonLiteral = string | number | boolean | null; - -export interface ConstSchemaNode { - readonly type: 'const'; - readonly value: JsonLiteral; -} -export interface BooleanSchemaNode { - readonly type: 'boolean'; -} -export interface EnumSchemaNode { - readonly type: 'enum'; - readonly values: readonly JsonLiteral[]; -} -export interface IntegerSchemaNode { - readonly type: 'integer'; - readonly minimum: number; - readonly maximum: number; -} -export interface ObjectSchemaNode { - readonly type: 'object'; - readonly fields: readonly { name: string; schema: BoundedQuerySchemaNode }[]; -} -export interface TupleSchemaNode { - readonly type: 'tuple'; - readonly items: readonly BoundedQuerySchemaNode[]; -} -export interface ArraySchemaNode { - readonly type: 'array'; - readonly items: BoundedQuerySchemaNode; - readonly length: number; -} -export interface UnionSchemaNode { - readonly type: 'union'; - readonly variants: readonly { tag: string; schema: BoundedQuerySchemaNode }[]; -} - -/** - * A validated, finite response schema. - * - * This is the *parsed* representation — every instance has already passed - * {@link validateSchema}'s bounds (depth, node count, enum/field/item counts, - * literal sizes). Cardinality, value validation, and canonical serialization - * below all assume that. - */ -export type BoundedQuerySchemaNode = - | ConstSchemaNode - | BooleanSchemaNode - | EnumSchemaNode - | IntegerSchemaNode - | ObjectSchemaNode - | TupleSchemaNode - | ArraySchemaNode - | UnionSchemaNode; - -export type BoundedQuerySchemaValidation = - | { valid: true; schema: BoundedQuerySchemaNode } - | { valid: false; errors: string[] }; - -function isValidLiteral(value: unknown): value is JsonLiteral { - if (value === null) return true; - if (typeof value === 'boolean') return true; - if (typeof value === 'number') return Number.isInteger(value) && Number.isSafeInteger(value); - if (typeof value === 'string') { - return !hasControlCharacters(value) && utf8ByteLength(value) <= MAX_LITERAL_STRING_BYTES; - } - return false; -} - -function literalTypeTag(value: JsonLiteral): string { - return value === null ? 'null' : typeof value; -} - -interface SchemaParseContext { - errors: string[]; - nodeCount: number; -} - -function failSchema(ctx: SchemaParseContext, message: string): undefined { - if (ctx.errors.length === 0) ctx.errors.push(message); - return undefined; -} - -/** - * Builds one validated {@link BoundedQuerySchemaNode}, enforcing every finite - * bound as it recurses. Stops at the first violation (`ctx.errors` becomes - * non-empty) rather than continuing to build a tree that will be discarded. - */ -function buildSchemaNode(raw: unknown, ctx: SchemaParseContext, depth: number): BoundedQuerySchemaNode | undefined { - if (ctx.errors.length > 0) return undefined; - if (depth > MAX_SCHEMA_DEPTH) { - return failSchema(ctx, `schema exceeds maximum depth of ${MAX_SCHEMA_DEPTH}`); - } - ctx.nodeCount += 1; - if (ctx.nodeCount > MAX_SCHEMA_NODES) { - return failSchema(ctx, `schema exceeds maximum node count of ${MAX_SCHEMA_NODES}`); - } - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - return failSchema(ctx, 'schema node must be a JSON object'); - } - - const node = raw as Record; - switch (node.type) { - case 'const': { - if (Object.keys(node).length !== 2 || !('value' in node)) { - return failSchema(ctx, 'const schema must have exactly "type" and "value"'); - } - if (!isValidLiteral(node.value)) { - return failSchema(ctx, 'const value must be a bounded string, a safe integer, a boolean, or null'); - } - return { type: 'const', value: node.value as JsonLiteral }; - } - case 'boolean': { - if (Object.keys(node).length !== 1) { - return failSchema(ctx, 'boolean schema must have only "type"'); - } - return { type: 'boolean' }; - } - case 'enum': { - if (Object.keys(node).length !== 2 || !('values' in node)) { - return failSchema(ctx, 'enum schema must have exactly "type" and "values"'); - } - const values = node.values; - if (!Array.isArray(values) || values.length === 0) { - return failSchema(ctx, 'enum values must be a non-empty array'); - } - if (values.length > MAX_ENUM_VALUES) { - return failSchema(ctx, `enum values must contain at most ${MAX_ENUM_VALUES} entries`); - } - for (const value of values) { - if (!isValidLiteral(value)) { - return failSchema(ctx, 'enum values must be bounded strings, safe integers, booleans, or null'); - } - } - const literals = values as JsonLiteral[]; - const firstTag = literalTypeTag(literals[0]); - if (!literals.every((value) => literalTypeTag(value) === firstTag)) { - return failSchema(ctx, 'enum values must all be the same JSON type'); - } - const uniqueCount = new Set(literals.map((value) => JSON.stringify(value))).size; - if (uniqueCount !== literals.length) { - return failSchema(ctx, 'enum values must be unique'); - } - return { type: 'enum', values: literals }; - } - case 'integer': { - if (Object.keys(node).length !== 3 || !('minimum' in node) || !('maximum' in node)) { - return failSchema(ctx, 'integer schema must have exactly "type", "minimum", and "maximum"'); - } - const { minimum, maximum } = node; - if (typeof minimum !== 'number' || !Number.isSafeInteger(minimum)) { - return failSchema(ctx, 'integer minimum must be a safe integer'); - } - if (typeof maximum !== 'number' || !Number.isSafeInteger(maximum)) { - return failSchema(ctx, 'integer maximum must be a safe integer'); - } - if (maximum < minimum) { - return failSchema(ctx, 'integer maximum must be >= minimum'); - } - return { type: 'integer', minimum, maximum }; - } - case 'object': { - if (Object.keys(node).length !== 2 || !('fields' in node)) { - return failSchema(ctx, 'object schema must have exactly "type" and "fields"'); - } - const fieldsRaw = node.fields; - if (typeof fieldsRaw !== 'object' || fieldsRaw === null || Array.isArray(fieldsRaw)) { - return failSchema(ctx, 'object "fields" must be a JSON object mapping field name to schema'); - } - const fieldNames = Object.keys(fieldsRaw); - if (fieldNames.length === 0) { - return failSchema(ctx, 'object schema must declare at least one field'); - } - if (fieldNames.length > MAX_OBJECT_FIELDS) { - return failSchema(ctx, `object schema must declare at most ${MAX_OBJECT_FIELDS} fields`); - } - for (const name of fieldNames) { - if (!IDENTIFIER_PATTERN.test(name)) { - return failSchema(ctx, `object field name "${name}" is not a bounded ASCII identifier`); - } - } - const fields: { name: string; schema: BoundedQuerySchemaNode }[] = []; - for (const name of fieldNames) { - const child = buildSchemaNode((fieldsRaw as Record)[name], ctx, depth + 1); - if (!child) return undefined; - fields.push({ name, schema: child }); - } - return { type: 'object', fields }; - } - case 'tuple': { - if (Object.keys(node).length !== 2 || !('items' in node)) { - return failSchema(ctx, 'tuple schema must have exactly "type" and "items"'); - } - const itemsRaw = node.items; - if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) { - return failSchema(ctx, 'tuple "items" must be a non-empty array'); - } - if (itemsRaw.length > MAX_TUPLE_ITEMS) { - return failSchema(ctx, `tuple schema must declare at most ${MAX_TUPLE_ITEMS} items`); - } - const items: BoundedQuerySchemaNode[] = []; - for (const itemRaw of itemsRaw) { - const child = buildSchemaNode(itemRaw, ctx, depth + 1); - if (!child) return undefined; - items.push(child); - } - return { type: 'tuple', items }; - } - case 'array': { - if (Object.keys(node).length !== 3 || !('items' in node) || !('length' in node)) { - return failSchema(ctx, 'array schema must have exactly "type", "items", and "length"'); - } - const { length } = node; - if (typeof length !== 'number' || !Number.isInteger(length) || length < 0 || length > MAX_ARRAY_LENGTH) { - return failSchema(ctx, `array "length" must be an integer between 0 and ${MAX_ARRAY_LENGTH}`); - } - const child = buildSchemaNode(node.items, ctx, depth + 1); - if (!child) return undefined; - return { type: 'array', items: child, length }; - } - case 'union': { - if (Object.keys(node).length !== 2 || !('variants' in node)) { - return failSchema(ctx, 'union schema must have exactly "type" and "variants"'); - } - const variantsRaw = node.variants; - if (typeof variantsRaw !== 'object' || variantsRaw === null || Array.isArray(variantsRaw)) { - return failSchema(ctx, 'union "variants" must be a JSON object mapping tag to schema'); - } - const tags = Object.keys(variantsRaw); - if (tags.length === 0) { - return failSchema(ctx, 'union schema must declare at least one variant'); - } - if (tags.length > MAX_UNION_VARIANTS) { - return failSchema(ctx, `union schema must declare at most ${MAX_UNION_VARIANTS} variants`); - } - for (const tag of tags) { - if (!IDENTIFIER_PATTERN.test(tag)) { - return failSchema(ctx, `union tag "${tag}" is not a bounded ASCII identifier`); - } - } - const variants: { tag: string; schema: BoundedQuerySchemaNode }[] = []; - for (const tag of tags) { - const child = buildSchemaNode((variantsRaw as Record)[tag], ctx, depth + 1); - if (!child) return undefined; - variants.push({ tag, schema: child }); - } - return { type: 'union', variants }; - } - default: - return failSchema( - ctx, - 'schema node "type" must be one of: const, boolean, enum, integer, object, tuple, array, union', - ); - } -} - -/** - * Validates and parses an agent-authored schema. - * - * Rejects anything outside the finite algebra above: unbounded strings, - * floats, regex domains, recursion/`$ref` (there is no such construct to - * begin with), optional properties, `additionalProperties`, and overlapping - * untagged unions are all structurally impossible to express, so they are - * rejected by construction rather than by a separate deny-list. - */ -export function validateSchema(raw: unknown): BoundedQuerySchemaValidation { - let serialized: string; - try { - serialized = JSON.stringify(raw) ?? ''; - } catch { - return { valid: false, errors: ['schema must be JSON-serializable'] }; - } - - const ctx: SchemaParseContext = { errors: [], nodeCount: 0 }; - const schema = buildSchemaNode(raw, ctx, 0); - if (!schema || ctx.errors.length > 0) { - return { valid: false, errors: ctx.errors.length > 0 ? ctx.errors : ['invalid schema'] }; - } - if (raw === undefined || utf8ByteLength(serialized) > MAX_SCHEMA_BYTES) { - return { valid: false, errors: [`schema must be a JSON value of at most ${MAX_SCHEMA_BYTES} bytes`] }; - } - return { valid: true, schema }; -} - -/** Ceiling of `log2(n)` for a non-negative `BigInt`, without floating point. */ -export function ceilLog2BigInt(n: bigint): number { - if (n <= 1n) return 0; - let bits = 0; - let remainder = n - 1n; - while (remainder > 0n) { - remainder >>= 1n; - bits += 1; - } - return bits; -} - -/** - * Computes a schema's successful-outcome cardinality (number of - * distinguishable valid values) as a `BigInt`, so it can never silently - * overflow even for schemas near the configured bounds. - */ -export function schemaCardinality(schema: BoundedQuerySchemaNode): bigint { - switch (schema.type) { - case 'const': - return 1n; - case 'boolean': - return 2n; - case 'enum': - return BigInt(schema.values.length); - case 'integer': - return BigInt(schema.maximum) - BigInt(schema.minimum) + 1n; - case 'object': - return schema.fields.reduce((acc, field) => acc * schemaCardinality(field.schema), 1n); - case 'tuple': - return schema.items.reduce((acc, item) => acc * schemaCardinality(item), 1n); - case 'array': - return schemaCardinality(schema.items) ** BigInt(schema.length); - case 'union': - return schema.variants.reduce((acc, variant) => acc + schemaCardinality(variant.schema), 0n); - } -} - -/** - * The maximum complete-transcript information charge, in bits, for one - * invocation using this schema: - * - * ```text - * queryBits = 1 (ok/error) + ceil(log2(successCardinality)) + 3 (timing) - * ``` - * - * This is the value the broker's per-repository ledger debits *before* - * copying a seed or launching Python — never refunded, regardless of the - * actual result or completion bucket. - */ -export function queryBitsForSchema(schema: BoundedQuerySchemaNode): number { - return RESULT_STATUS_BIT_COST + ceilLog2BigInt(schemaCardinality(schema)) + TIMING_BUCKET_BITS; -} - -function jsonLiteralEquals(value: unknown, literal: JsonLiteral): boolean { - if (literal === null) return value === null; - if (typeof literal === 'number') return typeof value === 'number' && Number.isInteger(value) && value === literal; - return value === literal; -} - -/** - * Strictly validates a parsed JSON value against an already-approved schema: - * exact JSON type, enum membership, integer range, exact required - * object/tuple/array shape (no extras, no missing fields, exact length), and - * an explicit tagged-union variant. Never coerces. - */ -export function validateValueAgainstSchema(schema: BoundedQuerySchemaNode, value: unknown): boolean { - switch (schema.type) { - case 'const': - return jsonLiteralEquals(value, schema.value); - case 'boolean': - return typeof value === 'boolean'; - case 'enum': - return schema.values.some((candidate) => jsonLiteralEquals(value, candidate)); - case 'integer': - return ( - typeof value === 'number' - && Number.isInteger(value) - && value >= schema.minimum - && value <= schema.maximum - ); - case 'object': { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const obj = value as Record; - if (Object.keys(obj).length !== schema.fields.length) return false; - return schema.fields.every( - (field) => - Object.prototype.hasOwnProperty.call(obj, field.name) - && validateValueAgainstSchema(field.schema, obj[field.name]), - ); - } - case 'tuple': - return ( - Array.isArray(value) - && value.length === schema.items.length - && schema.items.every((itemSchema, index) => validateValueAgainstSchema(itemSchema, value[index])) - ); - case 'array': - return ( - Array.isArray(value) - && value.length === schema.length - && value.every((item) => validateValueAgainstSchema(schema.items, item)) - ); - case 'union': { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const obj = value as Record; - if (Object.keys(obj).length !== 2 || !('tag' in obj) || !('value' in obj) || typeof obj.tag !== 'string') { - return false; - } - const variant = schema.variants.find((candidate) => candidate.tag === obj.tag); - return variant !== undefined && validateValueAgainstSchema(variant.schema, obj.value); - } - } -} - -/** - * Canonically re-serializes an already-validated value. - * - * The broker calls this on its own parsed representation — never on the raw - * bytes a query wrote — so two different serializations of the same - * semantic value (whitespace, key order, numeric formatting) collapse to the - * identical observable transcript. - */ -export function canonicalizeSchemaValue(schema: BoundedQuerySchemaNode, value: unknown): string { - switch (schema.type) { - case 'const': - return JSON.stringify(schema.value); - case 'boolean': - case 'enum': - case 'integer': - return JSON.stringify(value); - case 'object': { - const obj = value as Record; - const parts = schema.fields.map( - (field) => `${JSON.stringify(field.name)}:${canonicalizeSchemaValue(field.schema, obj[field.name])}`, - ); - return `{${parts.join(',')}}`; - } - case 'tuple': { - const arr = value as unknown[]; - return `[${schema.items.map((itemSchema, index) => canonicalizeSchemaValue(itemSchema, arr[index])).join(',')}]`; - } - case 'array': { - const arr = value as unknown[]; - return `[${arr.map((item) => canonicalizeSchemaValue(schema.items, item)).join(',')}]`; - } - case 'union': { - const obj = value as { tag: string; value: unknown }; - const variant = schema.variants.find((candidate) => candidate.tag === obj.tag); - // Unreachable when `value` already passed validateValueAgainstSchema. - if (!variant) return 'null'; - return `{"tag":${JSON.stringify(obj.tag)},"value":${canonicalizeSchemaValue(variant.schema, obj.value)}}`; - } - } -} - -// ── Strict JSON parsing (no `JSON.parse`) ──────────────────────────────────── - -/** Hard cap on parser recursion, independent of any schema's own depth bound. */ -const MAX_JSON_PARSE_DEPTH = 32; - -const JSON_WHITESPACE = new Set([' ', '\t', '\n', '\r']); - -function skipJsonWhitespace(text: string, index: number): number { - let i = index; - while (i < text.length && JSON_WHITESPACE.has(text[i])) i++; - return i; -} - -interface ParsedNode { - value: unknown; - endIndex: number; -} - -/** - * Parses a JSON string literal starting at `text[start]` (`text[start]` must - * be `"`). Rejects raw control characters and invalid/unterminated escapes. - */ -function parseJsonStringLiteral(text: string, start: number): { value: string; endIndex: number } | undefined { - if (text[start] !== '"') return undefined; - - let i = start + 1; - let value = ''; - while (i < text.length) { - const ch = text[i]; - - if (ch === '"') return { value, endIndex: i + 1 }; - - if (ch === '\\') { - const escape = text[i + 1]; - switch (escape) { - case '"': value += '"'; i += 2; continue; - case '\\': value += '\\'; i += 2; continue; - case '/': value += '/'; i += 2; continue; - case 'b': value += '\b'; i += 2; continue; - case 'f': value += '\f'; i += 2; continue; - case 'n': value += '\n'; i += 2; continue; - case 'r': value += '\r'; i += 2; continue; - case 't': value += '\t'; i += 2; continue; - case 'u': { - const hex = text.slice(i + 2, i + 6); - if (!/^[0-9a-fA-F]{4}$/.test(hex)) return undefined; - value += String.fromCharCode(parseInt(hex, 16)); - i += 6; - continue; - } - default: - return undefined; - } - } - - if (ch.charCodeAt(0) < 0x20) return undefined; - value += ch; - i++; - } - - return undefined; -} - -function parseJsonNumber(text: string, start: number): ParsedNode | undefined { - let i = start; - if (text[i] === '-') i++; - if (text[i] === '0') { - i++; - } else if (text[i] >= '1' && text[i] <= '9') { - while (text[i] >= '0' && text[i] <= '9') i++; - } else { - return undefined; - } - if (text[i] === '.') { - i++; - if (!(text[i] >= '0' && text[i] <= '9')) return undefined; - while (text[i] >= '0' && text[i] <= '9') i++; - } - if (text[i] === 'e' || text[i] === 'E') { - i++; - if (text[i] === '+' || text[i] === '-') i++; - if (!(text[i] >= '0' && text[i] <= '9')) return undefined; - while (text[i] >= '0' && text[i] <= '9') i++; - } - const raw = text.slice(start, i); - const value = Number(raw); - if (!Number.isFinite(value)) return undefined; - return { value, endIndex: i }; -} - -function parseJsonValue(text: string, index: number, depth: number): ParsedNode | undefined { - if (depth > MAX_JSON_PARSE_DEPTH) return undefined; - const ch = text[index]; - - if (ch === '{') return parseJsonObject(text, index, depth); - if (ch === '[') return parseJsonArray(text, index, depth); - if (ch === '"') { - const literal = parseJsonStringLiteral(text, index); - return literal && { value: literal.value, endIndex: literal.endIndex }; - } - if (text.startsWith('true', index)) return { value: true, endIndex: index + 4 }; - if (text.startsWith('false', index)) return { value: false, endIndex: index + 5 }; - if (text.startsWith('null', index)) return { value: null, endIndex: index + 4 }; - if (ch === '-' || (ch >= '0' && ch <= '9')) return parseJsonNumber(text, index); - return undefined; -} - -function parseJsonObject(text: string, index: number, depth: number): ParsedNode | undefined { - let i = skipJsonWhitespace(text, index + 1); - const obj: Record = {}; - if (text[i] === '}') return { value: obj, endIndex: i + 1 }; - - for (;;) { - i = skipJsonWhitespace(text, i); - const key = parseJsonStringLiteral(text, i); - if (!key) return undefined; - i = skipJsonWhitespace(text, key.endIndex); - if (text[i] !== ':') return undefined; - i = skipJsonWhitespace(text, i + 1); - const value = parseJsonValue(text, i, depth + 1); - if (!value) return undefined; - // Reject duplicate keys outright rather than silently keeping the last - // occurrence (which is what `JSON.parse` does) — a dedicated strict - // parser, not a more permissive result encoding, is the safer choice. - if (Object.prototype.hasOwnProperty.call(obj, key.value)) return undefined; - obj[key.value] = value.value; - i = skipJsonWhitespace(text, value.endIndex); - if (text[i] === ',') { i += 1; continue; } - if (text[i] === '}') return { value: obj, endIndex: i + 1 }; - return undefined; - } -} - -function parseJsonArray(text: string, index: number, depth: number): ParsedNode | undefined { - let i = skipJsonWhitespace(text, index + 1); - const arr: unknown[] = []; - if (text[i] === ']') return { value: arr, endIndex: i + 1 }; - - for (;;) { - i = skipJsonWhitespace(text, i); - const value = parseJsonValue(text, i, depth + 1); - if (!value) return undefined; - arr.push(value.value); - i = skipJsonWhitespace(text, value.endIndex); - if (text[i] === ',') { i += 1; continue; } - if (text[i] === ']') return { value: arr, endIndex: i + 1 }; - return undefined; - } -} - -/** - * Strictly parses exactly one JSON value from `text` — no trailing data, - * no duplicate object keys. - */ -export function strictParseJson(text: string): { value: unknown } | undefined { - const start = skipJsonWhitespace(text, 0); - const result = parseJsonValue(text, start, 0); - if (!result) return undefined; - const end = skipJsonWhitespace(text, result.endIndex); - if (end !== text.length) return undefined; - return { value: result.value }; -} - -// ── Request/result validation and canonical envelopes ─────────────────────── - -/** A bounded-query execution request, already assembled from wire framing. */ -export interface BoundedQueryRequest { - /** Private repository (`owner/repo`) the query script runs against. */ - privateRepo: string; - /** The agent-authored, AWF-bounded finite response schema. */ - schema: BoundedQuerySchemaNode; - /** The query script source. */ - script: string; -} - -export type BoundedQueryValidation = - | { valid: true; request: BoundedQueryRequest } - | { valid: false; errors: string[] }; - -/** - * Validates an unknown value as a {@link BoundedQueryRequest}: field shape, - * the `privateRepo` slug pattern, the finite response schema, and the - * script size cap. - */ -export function validateBoundedQueryRequest(raw: unknown): BoundedQueryValidation { - if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { - return { valid: false, errors: ['request must be a JSON object'] }; - } - - const errors: string[] = []; - const record = raw as Record; - const { privateRepo, schema: schemaRaw, script } = record; - const allowedKeys = new Set(['privateRepo', 'schema', 'script']); - for (const key of Object.keys(record)) { - if (!allowedKeys.has(key)) errors.push(`request.${key} is not supported`); - } - - if (typeof privateRepo !== 'string' || privateRepo.length === 0) { - errors.push('privateRepo must be a non-empty string'); - } else if (privateRepo.length > MAX_PRIVATE_REPO_LENGTH || !BOUNDED_QUERY_REPO_PATTERN.test(privateRepo)) { - errors.push( - 'privateRepo must be an "owner/repo" slug (no scheme, host, path traversal, query, fragment, or wildcard)', - ); - } - - const schemaValidation = validateSchema(schemaRaw); - if (!schemaValidation.valid) { - errors.push(...schemaValidation.errors.map((error) => `schema: ${error}`)); - } - - if (typeof script !== 'string' || script.length === 0) { - errors.push('script must be a non-empty string'); - } else if (utf8ByteLength(script) > MAX_SCRIPT_BYTES) { - errors.push(`script must be at most ${MAX_SCRIPT_BYTES} bytes`); - } - - if ( - errors.length > 0 - || !schemaValidation.valid - || typeof privateRepo !== 'string' - || typeof script !== 'string' - ) { - return { valid: false, errors }; - } - - return { valid: true, request: { privateRepo, schema: schemaValidation.schema, script } }; -} - -/** The canonical JSON text for every failure: `{"status":"error"}`. */ -export const CANONICAL_ERROR_JSON = '{"status":"error"}'; - -/** Wraps an already-canonicalized result value into the canonical success envelope. */ -export function canonicalOkJson(canonicalResultJson: string): string { - return `{"status":"ok","result":${canonicalResultJson}}`; -} - -/** - * Parses and validates a query's raw output file contents against the - * request's approved schema, returning the broker's own canonical - * re-serialization of the value on success. - * - * Every failure mode — oversized output, malformed JSON, duplicate keys, - * wrong type, out-of-range value, unknown enum member, missing/extra - * fields, wrong tuple/array length, unknown union tag — maps to the same - * `{ ok: false }`, which callers turn into {@link CANONICAL_ERROR_JSON}. + * The reusable implementation lives in `bounded-execution`; bounded-query + * imports remain stable so this foundation refactor does not change its public + * API or emitted bytes. */ -export function parseAndValidateQueryOutput( - raw: string, - schema: BoundedQuerySchemaNode, -): { ok: true; canonical: string } | { ok: false } { - if (utf8ByteLength(raw) > MAX_RESULT_BYTES) return { ok: false }; - const parsed = strictParseJson(raw); - if (!parsed) return { ok: false }; - if (!validateValueAgainstSchema(schema, parsed.value)) return { ok: false }; - return { ok: true, canonical: canonicalizeSchemaValue(schema, parsed.value) }; -} +export * from '../bounded-execution/finite-disclosure'; diff --git a/src/bounded-query/types.ts b/src/bounded-query/types.ts index b7eeb78d6..1533697a8 100644 --- a/src/bounded-query/types.ts +++ b/src/bounded-query/types.ts @@ -1,57 +1,2 @@ -/** - * Runtime (non-protocol) types for the bounded-query subsystem. - * - * The request/result wire protocol lives in `./protocol.ts`; this module - * describes the host-side staging output and the seed map the trusted broker - * consumes. - */ - -import type { BoundedQuerySensitivity } from '../types/bounded-query-options'; - -/** - * Version of the on-disk seed-map document. - * - * v2 adds trusted `sensitivity` metadata to every entry (see - * {@link BoundedQuerySeedMap}) so the broker can derive each repository's - * per-run information budget without trusting anything the agent sends. - */ -export const BOUNDED_QUERY_SEED_MAP_VERSION = 2; - -/** One staged, immutable repository seed. */ -export interface BoundedQuerySeed { - /** Normalized (lowercased) `owner/repo` lookup key. */ - repoKey: string; - /** Repository slug exactly as configured, used for clone-URL construction. */ - repo: string; - /** Opaque directory name of the seed under the seeds root. */ - seedId: string; - /** Absolute host path of the immutable seed directory. */ - seedPath: string; - /** Commit the seed was materialized at, recorded for protected audit state. */ - commit: string; - /** Trusted confidentiality category, carried unmodified into the seed map. */ - sensitivity: BoundedQuerySensitivity; -} - -/** - * The document written to the dedicated broker-private host root and mounted - * read-only into the broker. - * - * It intentionally contains only what the broker needs: the mapping from a - * normalized repo id to an AWF-chosen opaque seed directory name plus its - * trusted sensitivity, and the run id used for container labelling/orphan - * cleanup. No credentials, no absolute host paths, and no caller-controllable - * fields — in particular, `sensitivity` is trusted AWF configuration state - * that a query request can never choose or override. - */ -export interface BoundedQuerySeedMap { - version: typeof BOUNDED_QUERY_SEED_MAP_VERSION; - runId: string; - seeds: Array<{ repo: string; seedId: string; sensitivity: BoundedQuerySensitivity }>; -} - -/** Result of the trusted host staging phase. */ -export interface BoundedQueryStagingResult { - runId: string; - seeds: BoundedQuerySeed[]; -} +/** Compatibility exports for bounded-query staging descriptors. */ +export * from '../bounded-execution/repository-staging'; diff --git a/src/types/bounded-query-options.ts b/src/types/bounded-query-options.ts index 3e68ec5d1..4ca00559a 100644 --- a/src/types/bounded-query-options.ts +++ b/src/types/bounded-query-options.ts @@ -5,7 +5,7 @@ * of `boundedQueries` config, its normalized runtime representation, and the * centralized defaults applied when a field is not explicitly set. No * broker or sandbox runtime is implemented yet — see docs/awf-config-spec.md - * §14 and src/bounded-query/protocol.ts for the request/result protocol. + * §14 and src/bounded-execution/finite-disclosure.ts for the request/result protocol. */ /** Sandbox runtime backends supported for bounded-query execution. */