diff --git a/scripts/build-addie-tool-surface-inventory.ts b/scripts/build-addie-tool-surface-inventory.ts index 6fc160d9b8..89d9a8d693 100644 --- a/scripts/build-addie-tool-surface-inventory.ts +++ b/scripts/build-addie-tool-surface-inventory.ts @@ -74,6 +74,7 @@ const BUDGET_FILE = path.join(REPO_ROOT, 'scripts/addie-tool-surface-budget.json const REGISTRATION_SOURCES = [ 'server/src/addie/claude-client.ts', 'server/src/addie/request-tool-assembly.ts', + 'server/src/addie/request-tool-replay-binding.ts', 'server/src/addie/prompts.ts', 'server/src/addie/tool-wire-shape.ts', 'server/src/addie/prompt-assembly.ts', diff --git a/server/src/addie/generated/tool-surface-inventory.generated.json b/server/src/addie/generated/tool-surface-inventory.generated.json index bba2705ba3..7bdd7a6b21 100644 --- a/server/src/addie/generated/tool-surface-inventory.generated.json +++ b/server/src/addie/generated/tool-surface-inventory.generated.json @@ -60,6 +60,7 @@ "registration_source_sha256": { "server/src/addie/claude-client.ts": "dc041880137e2c4678ffdb5d1f48aec080f94286ded3451dac6932b369451399", "server/src/addie/request-tool-assembly.ts": "cf4443d27ec69940f22aa9fcf46fdefd3f444762259c2700ac8fa01554ead4da", + "server/src/addie/request-tool-replay-binding.ts": "3aed4c0bd18032e4418b8fff7e8deb263936b6c75b27941f9818a2a9a87287f2", "server/src/addie/prompts.ts": "f091b8d7dd9c3b09b9630d858370c4dff00260e05bdbf0fb2c94b9bb9ae958fa", "server/src/addie/tool-wire-shape.ts": "4c5bcb95c32164b153c0f9d36648ea9f885e1df05e9796fdd71684c56162a81e", "server/src/addie/prompt-assembly.ts": "6c002ae5a52cdd26e13f424f6e00757dff163b3b273a1b4ab069fdb86f4f375f", diff --git a/server/src/addie/request-tool-replay-binding.ts b/server/src/addie/request-tool-replay-binding.ts new file mode 100644 index 0000000000..6fa84dd0f7 --- /dev/null +++ b/server/src/addie/request-tool-replay-binding.ts @@ -0,0 +1,710 @@ +import { createHash } from 'node:crypto'; +import { isProxy } from 'node:util/types'; +import type { ToolHandler } from './model-providers/tool-orchestration.js'; +import { + assembleAddieRequestTools, + type AddieRequestToolDefinitionOptions, + type AddieRequestTools, +} from './request-tool-assembly.js'; +import type { AddieTool } from './types.js'; + +/** Version of the assembly semantics sealed into a replay binding. */ +export const ADDIE_REQUEST_TOOL_REPLAY_ASSEMBLY_POLICY_VERSION = + 'request-tool-intersection:v1'; + +/** A bounded lifetime makes an unconsumed request-local binding unusable. */ +export const ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS = 60_000; + +/** + * Request facts to bind when a repository-validated production request + * boundary is eventually wired to this seam. This deliberately contains no + * caller-controlled `verified` marker: a fact claim is never trusted. + */ +export interface AddieRequestToolReplayFacts { + readonly caseId: string; + readonly surface: string; + readonly isAAOAdmin: boolean; + readonly isThread: boolean; + readonly privacy: 'private' | 'public' | 'unknown'; + readonly source: string; + readonly requestTimeMs: number; + readonly replayPrincipal: string; +} + +/** Safe, handler-free descriptor for later evaluator-owned substitution. */ +export interface SealedReplayToolDescriptor { + readonly index: number; + readonly name: string; + readonly definitionSha256: string; + readonly handlerSlotSha256: string; + readonly definitionWinner: 'global' | 'request_local'; + readonly handlerWinner: 'global' | 'request_local'; +} + +/** + * This is only a projection. Its identity is looked up in a module-private + * WeakMap; copying, serializing, branding, hashing, or changing a prototype + * therefore cannot recreate the module-private integrity state behind it. + */ +export interface SealedRequestToolReplayBinding { + readonly policyVersion: typeof ADDIE_REQUEST_TOOL_REPLAY_ASSEMBLY_POLICY_VERSION; + readonly factsSha256: string; + readonly tools: readonly SealedReplayToolDescriptor[]; + readonly intersectionSha256: string; +} + +export interface CaptureSealedRequestToolReplayBindingInput { + readonly facts: AddieRequestToolReplayFacts; + readonly globalTools: readonly AddieTool[]; + readonly globalHandlers: ReadonlyMap; + readonly requestTools?: AddieRequestTools; + readonly definitionOptions?: AddieRequestToolDefinitionOptions; + readonly handlerAllowedToolNames?: ReadonlySet | null; +} + +export type SealedReplayBindingValidation = + | { readonly valid: true; readonly tools: readonly SealedReplayToolDescriptor[] } + | { + readonly valid: false; + readonly reason: + | 'binding_unknown_or_forged' + | 'binding_already_consumed' + | 'binding_expired' + | 'binding_clock_invalid' + | 'binding_aborted' + | 'request_facts_drift' + | 'assembly_drift'; + }; + +interface BoundState { + readonly facts: AddieRequestToolReplayFacts; + readonly factsSha256: string; + readonly createdAtMonotonicMs: number; + /** Detached, deeply frozen inputs used by all assembly and descriptor work. */ + readonly globalTools: readonly AddieTool[]; + readonly globalHandlers: ReadonlyMap; + readonly requestDefinitions: readonly AddieTool[] | undefined; + readonly requestHandlers: ReadonlyMap | undefined; + readonly definitionOptions: AddieRequestToolDefinitionOptions | undefined; + readonly handlerAllowedToolNames: ReadonlySet | null | undefined; + /** Plain-data sources are revalidated without invoking accessors or traps. */ + readonly sourceGlobalTools: readonly AddieTool[]; + readonly sourceGlobalHandlers: ReadonlyMap; + readonly originalRequestTools: AddieRequestTools | undefined; + readonly originalRequestDefinitions: readonly AddieTool[] | undefined; + readonly originalRequestHandlers: ReadonlyMap | undefined; + readonly sourceDefinitionOptions: AddieRequestToolDefinitionOptions | undefined; + readonly sourceHandlerAllowedToolNames: ReadonlySet | null | undefined; + readonly globalToolEvidence: string; + readonly requestToolEvidence: string; + readonly globalHandlerEvidence: string; + readonly requestHandlerEvidence: string; + readonly definitionOptionsEvidence: string; + readonly handlerAllowedToolNamesEvidence: string; + readonly assembledTools: readonly AddieTool[]; + readonly assembledHandlers: ReadonlyMap; + readonly assembledDefinitionEvidence: string; + readonly descriptors: readonly SealedReplayToolDescriptor[]; + consumed: boolean; +} + +const boundStates = new WeakMap(); +const handlerIdentities = new WeakMap(); +let nextHandlerIdentity = 1; + +/** + * Wall time is deliberately module-private and is used only to reject a + * future-dated request fact at capture. It never measures TTL elapsed time. + */ +function readWallClockMs(): number { + const nowMs = Date.now(); + if (!Number.isSafeInteger(nowMs) || nowMs < 0) { + throw new Error('Replay binding clock is invalid'); + } + return nowMs; +} + +/** + * Module-private monotonic elapsed clock. DOMHighResTimeStamp values may be + * fractional, so this accepts finite, nonnegative, safe-range milliseconds + * and compares them with a strict TTL boundary. + */ +function readMonotonicClockMs(): number { + const nowMs = performance.now(); + if (!Number.isFinite(nowMs) || nowMs < 0 || nowMs > Number.MAX_SAFE_INTEGER) { + throw new Error('Replay binding monotonic clock is invalid'); + } + return nowMs; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('Replay binding cannot canonicalize a non-finite number'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`; + } + throw new Error('Replay binding cannot canonicalize a non-JSON value'); +} + +function sha256(value: unknown): string { + return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); +} + +function rejectProxy(value: unknown, owner: string): void { + if ((typeof value === 'object' && value !== null) || typeof value === 'function') { + if (isProxy(value)) throw new Error(`Replay binding ${owner} must not be a Proxy`); + } +} + +function plainDataRecord(source: unknown, owner: string): Record { + rejectProxy(source, owner); + if (typeof source !== 'object' || source === null || Object.getPrototypeOf(source) !== Object.prototype) { + throw new Error(`Replay binding ${owner} must be a plain object`); + } + if (Object.getOwnPropertySymbols(source).length > 0) { + throw new Error(`Replay binding ${owner} must not contain symbol properties`); + } + const result: Record = Object.create(null) as Record; + for (const key of Object.getOwnPropertyNames(source)) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw new Error(`Replay binding ${owner}.${key} must be an enumerable own data property`); + } + result[key] = descriptor.value; + } + return result; +} + +function exactFields(record: Record, expected: readonly string[], owner: string): void { + const actual = Object.keys(record).sort(); + const sortedExpected = [...expected].sort(); + if (actual.length !== sortedExpected.length || actual.some((name, index) => name !== sortedExpected[index])) { + throw new Error(`Replay binding ${owner} has unsupported fields`); + } +} + +function allowedWrapperFields( + record: Record, + required: readonly string[], + optional: readonly string[], + owner: string, +): void { + const allowed = new Set([...required, ...optional]); + if (Object.keys(record).some((name) => !allowed.has(name)) || required.some((name) => !(name in record))) { + throw new Error(`Replay binding ${owner} has missing or unsupported fields`); + } +} + +function plainDataArray(source: unknown, owner: string): readonly unknown[] { + rejectProxy(source, owner); + if (!Array.isArray(source) || Object.getPrototypeOf(source) !== Array.prototype) { + throw new Error(`Replay binding ${owner} must be a plain array`); + } + if (Object.getOwnPropertySymbols(source).length > 0) { + throw new Error(`Replay binding ${owner} must not contain symbol properties`); + } + const names = Object.getOwnPropertyNames(source); + if (names.length !== source.length + 1 || !names.includes('length')) { + throw new Error(`Replay binding ${owner} must not contain extra or sparse properties`); + } + const values: unknown[] = []; + for (let index = 0; index < source.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(source, String(index)); + if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) { + throw new Error(`Replay binding ${owner}[${index}] must be an enumerable own data property`); + } + values.push(descriptor.value); + } + return values; +} + +type JsonValue = null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue }; + +function snapshotJson(value: unknown, owner: string): JsonValue { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error(`Replay binding ${owner} must contain finite JSON numbers`); + return value; + } + if (Array.isArray(value)) { + return Object.freeze(plainDataArray(value, owner).map((entry, index) => snapshotJson(entry, `${owner}[${index}]`))); + } + const source = plainDataRecord(value, owner); + const result: Record = Object.create(null) as Record; + for (const [key, entry] of Object.entries(source)) { + Object.defineProperty(result, key, { + enumerable: true, + value: snapshotJson(entry, `${owner}.${key}`), + }); + } + return Object.freeze(result); +} + +function snapshotFacts(input: AddieRequestToolReplayFacts): AddieRequestToolReplayFacts { + const expected = [ + 'caseId', 'surface', 'isAAOAdmin', 'isThread', 'privacy', 'source', 'requestTimeMs', 'replayPrincipal', + ]; + const inputRecord = plainDataRecord(input, 'facts'); + exactFields(inputRecord, expected, 'facts'); + const facts = { + caseId: inputRecord.caseId, + surface: inputRecord.surface, + isAAOAdmin: inputRecord.isAAOAdmin, + isThread: inputRecord.isThread, + privacy: inputRecord.privacy, + source: inputRecord.source, + requestTimeMs: inputRecord.requestTimeMs, + replayPrincipal: inputRecord.replayPrincipal, + }; + const snapshot = facts as AddieRequestToolReplayFacts; + if ( + typeof snapshot.caseId !== 'string' || !snapshot.caseId.trim() + || typeof snapshot.surface !== 'string' || !snapshot.surface.trim() + || typeof snapshot.isAAOAdmin !== 'boolean' + || typeof snapshot.isThread !== 'boolean' + || (snapshot.privacy !== 'private' && snapshot.privacy !== 'public' && snapshot.privacy !== 'unknown') + || typeof snapshot.source !== 'string' || !snapshot.source.trim() + || !Number.isSafeInteger(snapshot.requestTimeMs) || snapshot.requestTimeMs < 0 + || typeof snapshot.replayPrincipal !== 'string' || !snapshot.replayPrincipal.trim() + ) throw new Error('Replay binding facts are invalid'); + return Object.freeze(snapshot); +} + +function definitionEvidence(tools: readonly AddieTool[]): string { + return sha256(tools); +} + +function privateHandlerIdentity(handler: ToolHandler): string { + let identity = handlerIdentities.get(handler); + if (!identity) { + identity = `handler-${nextHandlerIdentity++}`; + handlerIdentities.set(handler, identity); + } + return identity; +} + +function handlerEvidence(handlers: ReadonlyMap): string { + // Function identity remains module-private. This binds every source slot, + // including off-intersection slots, without making any handler reachable. + return sha256([...handlers.entries()].map(([name, handler], index) => ({ + name, + index, + handlerIdentity: privateHandlerIdentity(handler), + }))); +} + +interface RequestToolsSnapshot { + readonly original: AddieRequestTools; + readonly originalDefinitions: readonly AddieTool[]; + readonly originalHandlers: ReadonlyMap; + readonly assemblyTools: AddieRequestTools; +} + +function snapshotTool(tool: unknown, owner: string): AddieTool { + const source = plainDataRecord(tool, owner); + const required = ['name', 'description', 'input_schema']; + const allowed = ['name', 'description', 'input_schema', 'usage_hints', 'replaySafety']; + for (const name of required) { + if (!(name in source)) throw new Error(`Replay binding ${owner}.${name} is required`); + } + if (Object.keys(source).some((name) => !allowed.includes(name))) { + throw new Error(`Replay binding ${owner} has unsupported fields`); + } + if (typeof source.name !== 'string' || typeof source.description !== 'string') { + throw new Error(`Replay binding ${owner} has invalid required fields`); + } + if ('usage_hints' in source && typeof source.usage_hints !== 'string') { + throw new Error(`Replay binding ${owner}.usage_hints must be a string when present`); + } + if ('replaySafety' in source && !['pure_local', 'public_read', 'principal_read', 'external_read', 'mutation'].includes(source.replaySafety as string)) { + throw new Error(`Replay binding ${owner}.replaySafety is invalid`); + } + const inputSchema = snapshotJson(source.input_schema, `${owner}.input_schema`); + if (typeof inputSchema !== 'object' || inputSchema === null || Array.isArray(inputSchema)) { + throw new Error(`Replay binding ${owner}.input_schema must be a JSON object`); + } + const snapshot: AddieTool = { + name: source.name, + description: source.description, + input_schema: inputSchema as AddieTool['input_schema'], + }; + if ('usage_hints' in source) snapshot.usage_hints = source.usage_hints as string; + if ('replaySafety' in source) snapshot.replaySafety = source.replaySafety as AddieTool['replaySafety']; + return Object.freeze(snapshot); +} + +function snapshotToolDefinitions(source: unknown, owner: string): readonly AddieTool[] { + return Object.freeze(plainDataArray(source, owner).map((tool, index) => snapshotTool(tool, `${owner}[${index}]`))); +} + +function assertNativeCollection(source: unknown, owner: string, prototype: object): void { + rejectProxy(source, owner); + if (typeof source !== 'object' || source === null || Object.getPrototypeOf(source) !== prototype + || Object.getOwnPropertyNames(source).length > 0 || Object.getOwnPropertySymbols(source).length > 0) { + throw new Error(`Replay binding ${owner} must be an unextended native collection`); + } +} + +function snapshotHandlers(source: unknown, owner: string): ReadonlyMap { + assertNativeCollection(source, owner, Map.prototype); + const handlers = new Map(); + for (const [name, handler] of Map.prototype.entries.call(source as Map)) { + if (typeof name !== 'string' || typeof handler !== 'function') { + throw new Error(`Replay binding ${owner} must map string names to functions`); + } + rejectProxy(handler, `${owner}.${name}`); + handlers.set(name, handler as ToolHandler); + } + return handlers; +} + +function snapshotDefinitionOptions(source: AddieRequestToolDefinitionOptions | undefined): AddieRequestToolDefinitionOptions | undefined { + if (source === undefined) return undefined; + const record = plainDataRecord(source, 'definitionOptions'); + if (Object.keys(record).some((name) => name !== 'allowedToolNames')) { + throw new Error('Replay binding definitionOptions has unsupported fields'); + } + if (!('allowedToolNames' in record)) return Object.freeze({}); + const names = plainDataArray(record.allowedToolNames, 'definitionOptions.allowedToolNames'); + if (names.some((name) => typeof name !== 'string')) { + throw new Error('Replay binding definitionOptions.allowedToolNames must contain strings'); + } + return Object.freeze({ allowedToolNames: Object.freeze([...names] as string[]) }); +} + +function snapshotAllowedToolNames(source: ReadonlySet | null | undefined): ReadonlySet | null | undefined { + if (source === undefined || source === null) return source; + assertNativeCollection(source, 'handlerAllowedToolNames', Set.prototype); + const names = new Set(); + for (const name of Set.prototype.values.call(source as Set)) { + if (typeof name !== 'string') throw new Error('Replay binding handlerAllowedToolNames must contain strings'); + names.add(name); + } + return names; +} + +interface CaptureInputSnapshot { + readonly facts: AddieRequestToolReplayFacts; + readonly globalTools: readonly AddieTool[]; + readonly globalHandlers: ReadonlyMap; + readonly requestTools: AddieRequestTools | undefined; + readonly definitionOptions: AddieRequestToolDefinitionOptions | undefined; + readonly handlerAllowedToolNames: ReadonlySet | null | undefined; +} + +function snapshotCaptureInput(input: CaptureSealedRequestToolReplayBindingInput): CaptureInputSnapshot { + const record = plainDataRecord(input, 'capture input'); + allowedWrapperFields( + record, + ['facts', 'globalTools', 'globalHandlers'], + ['requestTools', 'definitionOptions', 'handlerAllowedToolNames'], + 'capture input', + ); + return Object.freeze({ + facts: record.facts as AddieRequestToolReplayFacts, + globalTools: record.globalTools as readonly AddieTool[], + globalHandlers: record.globalHandlers as ReadonlyMap, + requestTools: record.requestTools as AddieRequestTools | undefined, + definitionOptions: record.definitionOptions as AddieRequestToolDefinitionOptions | undefined, + handlerAllowedToolNames: record.handlerAllowedToolNames as ReadonlySet | null | undefined, + }); +} + +function snapshotAbortState(signal: unknown): boolean { + if (signal === undefined) return false; + rejectProxy(signal, 'claim signal'); + if (typeof signal !== 'object' || signal === null) { + throw new Error('Replay binding claim signal must be an AbortSignal'); + } + const abortedGetter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, 'aborted')?.get; + if (typeof abortedGetter !== 'function') { + throw new Error('Replay binding AbortSignal support is unavailable'); + } + const aborted = abortedGetter.call(signal); + if (typeof aborted !== 'boolean') { + throw new Error('Replay binding claim signal is invalid'); + } + return aborted; +} + +interface ClaimInputSnapshot { + readonly binding: SealedRequestToolReplayBinding; + readonly facts: AddieRequestToolReplayFacts; + /** Kept opaque until the authentic binding has been terminally consumed. */ + readonly signal: unknown; +} + +function snapshotClaimInput(input: { + readonly binding: SealedRequestToolReplayBinding; + readonly facts: AddieRequestToolReplayFacts; + readonly signal?: AbortSignal; +}): ClaimInputSnapshot { + const record = plainDataRecord(input, 'claim input'); + allowedWrapperFields(record, ['binding', 'facts'], ['signal'], 'claim input'); + const binding = record.binding as SealedRequestToolReplayBinding; + rejectProxy(binding, 'claim binding'); + return Object.freeze({ + binding, + facts: record.facts as AddieRequestToolReplayFacts, + signal: record.signal, + }); +} + +/** + * Read request-local plain data fields once, then give the shared assembly + * helper an ordinary detached container. This rejects accessors rather than + * allowing a getter to change values observed by validation versus assembly. + */ +function snapshotRequestTools(requestTools: AddieRequestTools | undefined): RequestToolsSnapshot | undefined { + if (!requestTools) return undefined; + const requestToolsRecord = plainDataRecord(requestTools, 'requestTools'); + exactFields(requestToolsRecord, ['tools', 'handlers'], 'requestTools'); + const definitions = requestToolsRecord.tools; + const handlers = requestToolsRecord.handlers; + const toolSnapshot = snapshotToolDefinitions(definitions, 'requestTools.tools'); + const handlerSnapshot = snapshotHandlers(handlers, 'requestTools.handlers'); + return Object.freeze({ + original: requestTools, + originalDefinitions: definitions as readonly AddieTool[], + originalHandlers: handlers as ReadonlyMap, + assemblyTools: Object.freeze({ + tools: toolSnapshot, + handlers: handlerSnapshot, + }), + }); +} + +function definitionWinner( + name: string, + requestDefinitions: readonly AddieTool[] | undefined, +): 'global' | 'request_local' { + return requestDefinitions?.some((tool) => tool.name === name) ? 'request_local' : 'global'; +} + +function handlerWinner( + name: string, + requestHandlers: ReadonlyMap | undefined, +): 'global' | 'request_local' { + return requestHandlers?.has(name) ? 'request_local' : 'global'; +} + +function sameFacts(left: AddieRequestToolReplayFacts, right: AddieRequestToolReplayFacts): boolean { + return canonicalJson(left) === canonicalJson(right); +} + +function sourceDefinitionEvidence(source: unknown, owner: string): string { + return definitionEvidence(snapshotToolDefinitions(source, owner)); +} + +function sourceHandlerEvidence(source: unknown, owner: string): string { + return handlerEvidence(snapshotHandlers(source, owner)); +} + +function sourceDefinitionOptionsEvidence(source: AddieRequestToolDefinitionOptions | undefined): string { + return sha256(snapshotDefinitionOptions(source) ?? null); +} + +function sourceAllowedToolNamesEvidence(source: ReadonlySet | null | undefined): string { + const snapshot = snapshotAllowedToolNames(source); + return sha256(snapshot === undefined ? { absent: true } : snapshot === null ? null : [...snapshot]); +} + +function assemblyStillMatches(state: BoundState): boolean { + try { + if (state.originalRequestTools !== undefined) { + const currentRequestTools = plainDataRecord(state.originalRequestTools, 'requestTools'); + exactFields(currentRequestTools, ['tools', 'handlers'], 'requestTools'); + if ( + currentRequestTools.tools !== state.originalRequestDefinitions + || currentRequestTools.handlers !== state.originalRequestHandlers + ) return false; + } + if ( + sourceDefinitionEvidence(state.sourceGlobalTools, 'globalTools') !== state.globalToolEvidence + || sourceDefinitionEvidence(state.originalRequestDefinitions ?? [], 'requestTools.tools') !== state.requestToolEvidence + || sourceHandlerEvidence(state.sourceGlobalHandlers, 'globalHandlers') !== state.globalHandlerEvidence + || sourceHandlerEvidence(state.originalRequestHandlers ?? new Map(), 'requestTools.handlers') !== state.requestHandlerEvidence + || sourceDefinitionOptionsEvidence(state.sourceDefinitionOptions) !== state.definitionOptionsEvidence + || sourceAllowedToolNamesEvidence(state.sourceHandlerAllowedToolNames) !== state.handlerAllowedToolNamesEvidence + || definitionEvidence(state.assembledTools) !== state.assembledDefinitionEvidence + ) return false; + const names = new Set(); + let intersectionCount = 0; + for (const [index, definition] of state.assembledTools.entries()) { + if (names.has(definition.name)) return false; + names.add(definition.name); + const descriptor = state.descriptors.find((candidate) => candidate.index === index); + const handler = state.assembledHandlers.get(definition.name); + if (!handler) { + if (descriptor) return false; + continue; + } + intersectionCount++; + if (!descriptor || descriptor.name !== definition.name) return false; + if (descriptor.definitionSha256 !== sha256(definition)) return false; + const expectedDefinition = definitionWinner(definition.name, state.requestDefinitions); + const expectedHandler = handlerWinner(definition.name, state.requestHandlers); + if (descriptor.definitionWinner !== expectedDefinition || descriptor.handlerWinner !== expectedHandler) return false; + const sourceHandler = expectedHandler === 'request_local' + ? state.requestHandlers?.get(definition.name) + : state.globalHandlers.get(definition.name); + if (handler !== sourceHandler) return false; + } + return state.descriptors.length === intersectionCount; + } catch { + return false; + } +} + +/** + * Seal a fresh result of #7299's production request-local assembly helper. + * This does not dispatch anything, return handlers, or validate a replay. Until + * repository-validated production facts are wired here, it remains a sealed, + * non-dispatching prerequisite rather than a fixed-trace admission path. + */ +export function captureSealedRequestToolReplayBinding( + input: CaptureSealedRequestToolReplayBindingInput, +): SealedRequestToolReplayBinding { + const captureInput = snapshotCaptureInput(input); + const facts = snapshotFacts(captureInput.facts); + const createdAtMs = readWallClockMs(); + if (createdAtMs < facts.requestTimeMs) { + throw new Error('Replay binding request time is in the future'); + } + const createdAtMonotonicMs = readMonotonicClockMs(); + const globalTools = snapshotToolDefinitions(captureInput.globalTools, 'globalTools'); + const globalHandlers = snapshotHandlers(captureInput.globalHandlers, 'globalHandlers'); + const requestSnapshot = snapshotRequestTools(captureInput.requestTools); + const definitionOptions = snapshotDefinitionOptions(captureInput.definitionOptions); + const handlerAllowedToolNames = snapshotAllowedToolNames(captureInput.handlerAllowedToolNames); + const requestDefinitions = requestSnapshot?.assemblyTools.tools; + const requestHandlers = requestSnapshot?.assemblyTools.handlers; + const assembled = assembleAddieRequestTools( + globalTools, + globalHandlers, + requestSnapshot?.assemblyTools, + definitionOptions, + handlerAllowedToolNames, + ); + const assembledTools = Object.freeze([...assembled.tools]); + const descriptors = assembledTools.flatMap((definition, index) => { + const handler = assembled.handlers.get(definition.name); + if (!handler) return []; + const definitionWinnerValue = definitionWinner(definition.name, requestDefinitions); + const handlerWinnerValue = handlerWinner(definition.name, requestHandlers); + const sourceHandler = handlerWinnerValue === 'request_local' + ? requestHandlers?.get(definition.name) + : globalHandlers.get(definition.name); + if (handler !== sourceHandler) { + throw new Error(`Replay binding handler winner drifted: ${definition.name}`); + } + const definitionSha256 = sha256(definition); + return [Object.freeze({ + index, + name: definition.name, + definitionSha256, + handlerSlotSha256: sha256({ name: definition.name, index, definitionWinner: definitionWinnerValue, handlerWinner: handlerWinnerValue }), + definitionWinner: definitionWinnerValue, + handlerWinner: handlerWinnerValue, + } satisfies SealedReplayToolDescriptor)]; + }); + const frozenDescriptors = Object.freeze(descriptors); + const factsSha256 = sha256(facts); + const projection = Object.freeze({ + policyVersion: ADDIE_REQUEST_TOOL_REPLAY_ASSEMBLY_POLICY_VERSION, + factsSha256, + tools: frozenDescriptors, + intersectionSha256: sha256(frozenDescriptors), + } satisfies SealedRequestToolReplayBinding); + boundStates.set(projection, { + facts, + factsSha256, + createdAtMonotonicMs, + globalTools, + globalHandlers, + requestDefinitions, + requestHandlers, + definitionOptions, + handlerAllowedToolNames, + sourceGlobalTools: captureInput.globalTools, + sourceGlobalHandlers: captureInput.globalHandlers, + originalRequestTools: requestSnapshot?.original, + originalRequestDefinitions: requestSnapshot?.originalDefinitions, + originalRequestHandlers: requestSnapshot?.originalHandlers, + sourceDefinitionOptions: captureInput.definitionOptions, + sourceHandlerAllowedToolNames: captureInput.handlerAllowedToolNames, + globalToolEvidence: definitionEvidence(globalTools), + requestToolEvidence: definitionEvidence(requestDefinitions ?? []), + globalHandlerEvidence: handlerEvidence(globalHandlers), + requestHandlerEvidence: handlerEvidence(requestHandlers ?? new Map()), + definitionOptionsEvidence: sha256(definitionOptions ?? null), + handlerAllowedToolNamesEvidence: sha256( + handlerAllowedToolNames === undefined ? { absent: true } : handlerAllowedToolNames === null ? null : [...handlerAllowedToolNames], + ), + assembledTools, + assembledHandlers: assembled.handlers, + assembledDefinitionEvidence: definitionEvidence(assembledTools), + descriptors: frozenDescriptors, + consumed: false, + }); + return projection; +} + +/** + * Consume a binding for evaluator-owned simulator substitution. Production + * handlers never cross this boundary; callers receive frozen descriptors only. + */ +export function claimSealedRequestToolReplayBinding(input: { + readonly binding: SealedRequestToolReplayBinding; + readonly facts: AddieRequestToolReplayFacts; + readonly signal?: AbortSignal; +}): SealedReplayBindingValidation { + let claimInput: ClaimInputSnapshot; + try { + claimInput = snapshotClaimInput(input); + } catch { + return { valid: false, reason: 'binding_unknown_or_forged' }; + } + const state = boundStates.get(claimInput.binding); + if (!state) return { valid: false, reason: 'binding_unknown_or_forged' }; + if (state.consumed) return { valid: false, reason: 'binding_already_consumed' }; + // Any attempted validation is terminal, so mutable facts cannot be repaired + // after learning why an integrity check failed. + state.consumed = true; + let signalAborted: boolean; + try { + signalAborted = snapshotAbortState(claimInput.signal); + } catch { + return { valid: false, reason: 'binding_unknown_or_forged' }; + } + if (signalAborted) return { valid: false, reason: 'binding_aborted' }; + let nowMs: number; + try { + nowMs = readMonotonicClockMs(); + } catch { + return { valid: false, reason: 'binding_clock_invalid' }; + } + const elapsedMs = nowMs - state.createdAtMonotonicMs; + if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { + return { valid: false, reason: 'binding_clock_invalid' }; + } + if (elapsedMs > ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS) { + return { valid: false, reason: 'binding_expired' }; + } + let facts: AddieRequestToolReplayFacts; + try { + facts = snapshotFacts(claimInput.facts); + } catch { + return { valid: false, reason: 'request_facts_drift' }; + } + if (!sameFacts(state.facts, facts) || state.factsSha256 !== claimInput.binding.factsSha256) { + return { valid: false, reason: 'request_facts_drift' }; + } + if (!assemblyStillMatches(state)) return { valid: false, reason: 'assembly_drift' }; + return { valid: true, tools: state.descriptors }; +} diff --git a/server/tests/unit/addie/request-tool-replay-binding.test.ts b/server/tests/unit/addie/request-tool-replay-binding.test.ts new file mode 100644 index 0000000000..ecf36d84f5 --- /dev/null +++ b/server/tests/unit/addie/request-tool-replay-binding.test.ts @@ -0,0 +1,499 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS, + captureSealedRequestToolReplayBinding, + claimSealedRequestToolReplayBinding, + type AddieRequestToolReplayFacts, + type CaptureSealedRequestToolReplayBindingInput, + type SealedRequestToolReplayBinding, +} from '../../../src/addie/request-tool-replay-binding.js'; +import type { AddieRequestTools } from '../../../src/addie/request-tool-assembly.js'; +import type { ToolHandler } from '../../../src/addie/model-providers/tool-orchestration.js'; +import type { AddieTool } from '../../../src/addie/types.js'; + +function tool(name: string, description = name): AddieTool { + return { name, description, input_schema: { type: 'object', properties: {} } }; +} + +function facts(overrides: Partial = {}): AddieRequestToolReplayFacts { + return { + caseId: 'case-a', surface: 'slack', isAAOAdmin: false, isThread: true, + privacy: 'private', source: 'channel_message', requestTimeMs: Date.now(), + replayPrincipal: 'U_TEST', ...overrides, + }; +} + +function setup() { + const globalAlpha = vi.fn(async () => 'global alpha') as ToolHandler; + const globalBeta = vi.fn(async () => 'global beta') as ToolHandler; + const localBeta = vi.fn(async () => 'local beta') as ToolHandler; + const localGamma = vi.fn(async () => 'local gamma') as ToolHandler; + const globalTools = [ + { + ...tool('alpha'), + usage_hints: 'initial alpha hint', + replaySafety: 'pure_local' as const, + input_schema: { + type: 'object' as const, + properties: { nested: { items: [{ safe: true }] } }, + required: ['nested'], + additionalProperties: false, + }, + }, + tool('beta', 'global beta'), + tool('orphan'), + ]; + const globalHandlers = new Map([ + ['alpha', globalAlpha], ['beta', globalBeta], ['unrelated', vi.fn(async () => 'unused')], + ]); + const requestTools: AddieRequestTools = { + tools: [tool('beta', 'request beta'), tool('gamma')], + handlers: new Map([['beta', localBeta], ['gamma', localGamma]]), + }; + return { + facts: facts(), globalTools, globalHandlers, requestTools, + definitionOptions: undefined as CaptureSealedRequestToolReplayBindingInput['definitionOptions'], + handlerAllowedToolNames: undefined as CaptureSealedRequestToolReplayBindingInput['handlerAllowedToolNames'], + globalAlpha, globalBeta, localBeta, localGamma, + }; +} + +function captureInput(value: ReturnType): CaptureSealedRequestToolReplayBindingInput { + return { + facts: value.facts, + globalTools: value.globalTools, + globalHandlers: value.globalHandlers, + requestTools: value.requestTools, + definitionOptions: value.definitionOptions, + handlerAllowedToolNames: value.handlerAllowedToolNames, + }; +} + +function capture() { + const value = setup(); + return { ...value, binding: captureSealedRequestToolReplayBinding(captureInput(value)) }; +} + +function trapProxy(target: T, calls: { count: number }): T { + return new Proxy(target, { + get: () => { calls.count++; throw new Error('proxy trap must not run'); }, + getOwnPropertyDescriptor: () => { calls.count++; throw new Error('proxy trap must not run'); }, + getPrototypeOf: () => { calls.count++; throw new Error('proxy trap must not run'); }, + ownKeys: () => { calls.count++; throw new Error('proxy trap must not run'); }, + }); +} + +describe('sealed request-local replay binding', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date', 'performance'] }); + vi.setSystemTime(new Date('2030-01-01T00:00:00.000Z')); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('uses #7299 assembly and projects only ordered handler-free intersection descriptors', () => { + const value = capture(); + + expect(value.binding.tools).toEqual([ + expect.objectContaining({ index: 0, name: 'alpha', definitionWinner: 'global', handlerWinner: 'global' }), + expect.objectContaining({ index: 1, name: 'beta', definitionWinner: 'request_local', handlerWinner: 'request_local' }), + expect.objectContaining({ index: 3, name: 'gamma', definitionWinner: 'request_local', handlerWinner: 'request_local' }), + ]); + expect(value.binding.tools.every((descriptor) => ( + Object.values(descriptor).every((field) => typeof field !== 'function') + ))).toBe(true); + expect(Object.isFrozen(value.binding)).toBe(true); + expect(Object.isFrozen(value.binding.tools)).toBe(true); + for (const handler of [value.globalAlpha, value.globalBeta, value.localBeta, value.localGamma]) { + expect(handler).not.toHaveBeenCalled(); + } + }); + + it('rejects copied brands, prototypes, hashes, and serialized projections without exposing handlers', () => { + class ForgedBinding {} + const { binding, globalAlpha, globalBeta, localBeta, localGamma } = capture(); + const copied = { ...binding, tools: [...binding.tools] }; + const branded = Object.assign(Object.create(ForgedBinding.prototype), copied); + const serialized = JSON.parse(JSON.stringify(binding)) as SealedRequestToolReplayBinding; + const hashForged = { ...binding, factsSha256: 'a'.repeat(64) }; + + for (const forged of [copied, branded, serialized, hashForged]) { + expect(claimSealedRequestToolReplayBinding({ binding: forged, facts: facts() })) + .toEqual({ valid: false, reason: 'binding_unknown_or_forged' }); + } + for (const handler of [globalAlpha, globalBeta, localBeta, localGamma]) expect(handler).not.toHaveBeenCalled(); + }); + + it('binds the case and every request fact without accepting restamped facts', () => { + for (const changed of [ + { caseId: 'case-b' }, { surface: 'web' }, { isAAOAdmin: true }, { isThread: false }, + { privacy: 'public' as const }, { source: 'web_chat' }, { requestTimeMs: 1_001 }, { replayPrincipal: 'U_OTHER' }, + ]) { + const { binding } = capture(); + expect(claimSealedRequestToolReplayBinding({ binding, facts: facts(changed) })) + .toEqual({ valid: false, reason: 'request_facts_drift' }); + } + }); + + it('rejects a caller-supplied verified marker instead of treating it as trusted', () => { + const { binding } = capture(); + const claimedVerified = { ...facts(), verified: true } as AddieRequestToolReplayFacts; + + expect(claimSealedRequestToolReplayBinding({ binding, facts: claimedVerified })) + .toEqual({ valid: false, reason: 'request_facts_drift' }); + }); + + it('detects missing, extra, reordered, duplicated, and mutated definitions after mint', () => { + const mutations: Array<(value: ReturnType) => void> = [ + (value) => { value.globalTools.pop(); }, + (value) => { value.globalTools.push(tool('extra')); }, + (value) => { value.globalTools.reverse(); }, + (value) => { value.globalTools.push(tool('alpha', 'duplicate')); }, + (value) => { value.requestTools.tools[0]!.description = 'mutated'; }, + ]; + for (const mutate of mutations) { + const value = capture(); + mutate(value); + expect(claimSealedRequestToolReplayBinding({ binding: value.binding, facts: facts() })) + .toEqual({ valid: false, reason: 'assembly_drift' }); + } + }); + + it('binds every semantic definition field and complete nested input schemas', () => { + const mutations: Array<(value: ReturnType) => void> = [ + (value) => { value.globalTools[0]!.name = 'renamed'; }, + (value) => { value.globalTools[0]!.description = 'changed'; }, + (value) => { value.globalTools[0]!.usage_hints = 'changed hint'; }, + (value) => { value.globalTools[0]!.replaySafety = 'mutation'; }, + (value) => { delete value.globalTools[0]!.usage_hints; }, + (value) => { delete value.globalTools[0]!.replaySafety; }, + (value) => { value.globalTools[2]!.usage_hints = 'present instead of absent'; }, + (value) => { value.globalTools[2]!.replaySafety = 'external_read'; }, + (value) => { (value.globalTools[0]!.input_schema.properties.nested as { items: Array<{ safe: boolean }> }).items[0]!.safe = false; }, + (value) => { (value.globalTools[0]!.input_schema.properties.nested as { items: Array<{ safe: boolean }> }).items.push({ safe: false }); }, + (value) => { (value.globalTools[0]!.input_schema as { type: string }).type = 'array'; }, + (value) => { value.globalTools[0]!.input_schema.required!.push('other'); }, + (value) => { value.globalTools[0]!.input_schema.additionalProperties = { nested: { type: 'string' } }; }, + // `orphan` is absent from the handler intersection but remains bound. + ]; + for (const mutate of mutations) { + const value = capture(); + mutate(value); + expect(claimSealedRequestToolReplayBinding({ binding: value.binding, facts: facts() })) + .toEqual({ valid: false, reason: 'assembly_drift' }); + } + }); + + it('detects missing, swapped, and request-local shadowed handler slots after mint', () => { + const mutations: Array<(value: ReturnType) => void> = [ + (value) => { value.globalHandlers.delete('alpha'); }, + (value) => { value.globalHandlers.set('alpha', vi.fn(async () => 'swapped')); }, + (value) => { value.requestTools.handlers.delete('beta'); }, + (value) => { value.requestTools.handlers.set('beta', vi.fn(async () => 'swapped')); }, + ]; + for (const mutate of mutations) { + const value = capture(); + mutate(value); + expect(claimSealedRequestToolReplayBinding({ binding: value.binding, facts: facts() })) + .toEqual({ valid: false, reason: 'assembly_drift' }); + } + }); + + it('binds all source handler slots, including handler-only off-intersection entries', () => { + const value = capture(); + const offIntersectionReplacement = vi.fn(async () => 'replacement') as ToolHandler; + value.globalHandlers.set('unrelated', offIntersectionReplacement); + + expect(value.binding.tools.map(({ name }) => name)).not.toContain('unrelated'); + expect(claimSealedRequestToolReplayBinding({ binding: value.binding, facts: facts() })) + .toEqual({ valid: false, reason: 'assembly_drift' }); + expect(offIntersectionReplacement).not.toHaveBeenCalled(); + for (const handler of [value.globalAlpha, value.globalBeta, value.localBeta, value.localGamma]) { + expect(handler).not.toHaveBeenCalled(); + } + }); + + it('rejects request-local accessors without invoking them during capture', () => { + const value = setup(); + let reads = 0; + const requestTools = { handlers: value.requestTools.handlers } as AddieRequestTools; + Object.defineProperty(requestTools, 'tools', { + enumerable: true, + get: () => { reads++; return value.requestTools.tools; }, + }); + value.requestTools = requestTools; + + expect(() => captureSealedRequestToolReplayBinding(captureInput(value))).toThrow(); + expect(reads).toBe(0); + }); + + it('rejects request-local accessors rather than reading them during capture', () => { + const value = setup(); + const requestTools = { handlers: value.requestTools.handlers } as AddieRequestTools; + Object.defineProperty(requestTools, 'tools', { + enumerable: true, + get: () => { throw new Error('must not read accessor'); }, + }); + Object.defineProperty(requestTools, 'handlers', { + enumerable: true, + value: value.requestTools.handlers, + }); + value.requestTools = requestTools; + + expect(() => captureSealedRequestToolReplayBinding(captureInput(value))) + .toThrow('requestTools.tools must be an enumerable own data property'); + }); + + it('rejects proxies at every supported input boundary without executing traps', () => { + const cases: Array<(value: ReturnType, proxy: (target: T) => T) => void> = [ + (value, proxy) => { value.facts = proxy(value.facts); }, + (value, proxy) => { value.globalTools = proxy(value.globalTools); }, + (value, proxy) => { value.globalHandlers = proxy(value.globalHandlers); }, + (value, proxy) => { value.requestTools = proxy(value.requestTools); }, + (value, proxy) => { value.requestTools.tools = proxy(value.requestTools.tools) as AddieTool[]; }, + (value, proxy) => { value.requestTools.handlers = proxy(value.requestTools.handlers); }, + (value, proxy) => { value.globalTools[0] = proxy(value.globalTools[0]!); }, + (value, proxy) => { value.requestTools.tools[0] = proxy(value.requestTools.tools[0]!); }, + (value, proxy) => { value.globalTools[0]!.input_schema = proxy(value.globalTools[0]!.input_schema); }, + (value, proxy) => { value.globalTools[0]!.input_schema.properties = proxy(value.globalTools[0]!.input_schema.properties); }, + (value, proxy) => { (value.globalTools[0]!.input_schema.properties.nested as { items: Array<{ safe: boolean }> }).items = proxy([{ safe: true }]); }, + (value, proxy) => { value.definitionOptions = proxy({ allowedToolNames: ['alpha'] }); }, + (value, proxy) => { value.definitionOptions = { allowedToolNames: proxy(['alpha']) as string[] }; }, + (value, proxy) => { value.handlerAllowedToolNames = proxy(new Set(['alpha'])); }, + ]; + for (const configure of cases) { + const value = setup(); + let trapCalls = 0; + const proxy = (target: T): T => new Proxy(target, { + get: () => { trapCalls++; throw new Error('proxy trap must not run'); }, + getOwnPropertyDescriptor: () => { trapCalls++; throw new Error('proxy trap must not run'); }, + getPrototypeOf: () => { trapCalls++; throw new Error('proxy trap must not run'); }, + ownKeys: () => { trapCalls++; throw new Error('proxy trap must not run'); }, + }); + configure(value, proxy); + + expect(() => captureSealedRequestToolReplayBinding(captureInput(value))).toThrow('must not be a Proxy'); + expect(trapCalls).toBe(0); + } + }); + + it('rejects proxy projections and claim facts without executing traps', () => { + const bindingValue = capture(); + const bindingCalls = { count: 0 }; + expect(claimSealedRequestToolReplayBinding({ + binding: trapProxy(bindingValue.binding, bindingCalls), facts: bindingValue.facts, + })).toEqual({ valid: false, reason: 'binding_unknown_or_forged' }); + expect(bindingCalls.count).toBe(0); + + const factsValue = capture(); + const factsCalls = { count: 0 }; + expect(claimSealedRequestToolReplayBinding({ + binding: factsValue.binding, facts: trapProxy(factsValue.facts, factsCalls), + })).toEqual({ valid: false, reason: 'request_facts_drift' }); + expect(factsCalls.count).toBe(0); + }); + + it('rejects capture and claim wrappers before any proxy trap or accessor can run', () => { + const captureValue = setup(); + const captureCalls = { count: 0 }; + expect(() => captureSealedRequestToolReplayBinding(trapProxy(captureInput(captureValue), captureCalls))) + .toThrow('capture input must not be a Proxy'); + expect(captureCalls.count).toBe(0); + + const getterValue = setup(); + let globalToolsReads = 0; + const accessorCapture = { ...captureInput(getterValue) } as CaptureSealedRequestToolReplayBindingInput; + Object.defineProperty(accessorCapture, 'globalTools', { + enumerable: true, + get: () => { globalToolsReads++; return getterValue.globalTools; }, + }); + expect(() => captureSealedRequestToolReplayBinding(accessorCapture)).toThrow(); + expect(globalToolsReads).toBe(0); + + const claimValue = capture(); + const claimCalls = { count: 0 }; + expect(claimSealedRequestToolReplayBinding(trapProxy({ + binding: claimValue.binding, facts: claimValue.facts, + }, claimCalls))).toEqual({ valid: false, reason: 'binding_unknown_or_forged' }); + expect(claimCalls.count).toBe(0); + + let bindingReads = 0; + const accessorClaim = { facts: claimValue.facts } as { binding: SealedRequestToolReplayBinding; facts: AddieRequestToolReplayFacts }; + Object.defineProperty(accessorClaim, 'binding', { + enumerable: true, + get: () => { bindingReads++; return claimValue.binding; }, + }); + expect(claimSealedRequestToolReplayBinding(accessorClaim)) + .toEqual({ valid: false, reason: 'binding_unknown_or_forged' }); + expect(bindingReads).toBe(0); + }); + + it('requires exact capture and claim wrapper fields and snapshots data fields once', () => { + const value = setup(); + expect(() => captureSealedRequestToolReplayBinding({ ...captureInput(value), extra: true } as CaptureSealedRequestToolReplayBindingInput)) + .toThrow('capture input has missing or unsupported fields'); + const missingCapture = { ...captureInput(value) } as Partial; + delete missingCapture.globalHandlers; + expect(() => captureSealedRequestToolReplayBinding(missingCapture as CaptureSealedRequestToolReplayBindingInput)) + .toThrow('capture input has missing or unsupported fields'); + + const { binding, facts: capturedFacts } = capture(); + expect(claimSealedRequestToolReplayBinding({ binding, facts: capturedFacts, extra: true } as never)) + .toEqual({ valid: false, reason: 'binding_unknown_or_forged' }); + expect(claimSealedRequestToolReplayBinding({ binding } as never)) + .toEqual({ valid: false, reason: 'binding_unknown_or_forged' }); + }); + + it('rejects proxy or accessor signals without reading them and accepts native abort signals', () => { + const proxyValue = capture(); + const proxyCalls = { count: 0 }; + expect(claimSealedRequestToolReplayBinding({ + binding: proxyValue.binding, + facts: proxyValue.facts, + signal: trapProxy(new AbortController().signal, proxyCalls), + })).toEqual({ valid: false, reason: 'binding_unknown_or_forged' }); + expect(proxyCalls.count).toBe(0); + expect(claimSealedRequestToolReplayBinding({ binding: proxyValue.binding, facts: proxyValue.facts })) + .toEqual({ valid: false, reason: 'binding_already_consumed' }); + + const accessorValue = capture(); + let abortedReads = 0; + const fakeSignal = {} as AbortSignal; + Object.defineProperty(fakeSignal, 'aborted', { + enumerable: true, + get: () => { abortedReads++; return false; }, + }); + expect(claimSealedRequestToolReplayBinding({ + binding: accessorValue.binding, facts: accessorValue.facts, signal: fakeSignal, + })).toEqual({ valid: false, reason: 'binding_unknown_or_forged' }); + expect(abortedReads).toBe(0); + expect(claimSealedRequestToolReplayBinding({ binding: accessorValue.binding, facts: accessorValue.facts })) + .toEqual({ valid: false, reason: 'binding_already_consumed' }); + }); + + it('binds definition and handler allowlist inputs that shape production assembly', () => { + const definitionOptions = { allowedToolNames: ['alpha'] }; + const definitionValue = setup(); + definitionValue.definitionOptions = definitionOptions; + const definitionBinding = captureSealedRequestToolReplayBinding(captureInput(definitionValue)); + definitionOptions.allowedToolNames.push('beta'); + expect(claimSealedRequestToolReplayBinding({ binding: definitionBinding, facts: definitionValue.facts })) + .toEqual({ valid: false, reason: 'assembly_drift' }); + + const allowedNames = new Set(['alpha']); + const handlerValue = setup(); + handlerValue.handlerAllowedToolNames = allowedNames; + const handlerBinding = captureSealedRequestToolReplayBinding(captureInput(handlerValue)); + allowedNames.add('beta'); + expect(claimSealedRequestToolReplayBinding({ binding: handlerBinding, facts: handlerValue.facts })) + .toEqual({ valid: false, reason: 'assembly_drift' }); + }); + + it('uses monotonic elapsed time at exact expiry boundaries despite wall-clock rollback', () => { + const beforeExpiry = capture(); + vi.advanceTimersByTime(ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS - 1); + expect(claimSealedRequestToolReplayBinding({ binding: beforeExpiry.binding, facts: facts({ requestTimeMs: beforeExpiry.facts.requestTimeMs }) })) + .toMatchObject({ valid: true }); + + const atExpiry = capture(); + vi.advanceTimersByTime(ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS); + expect(claimSealedRequestToolReplayBinding({ binding: atExpiry.binding, facts: facts({ requestTimeMs: atExpiry.facts.requestTimeMs }) })) + .toMatchObject({ valid: true }); + + const expired = capture(); + vi.advanceTimersByTime(ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS + 1); + expect(claimSealedRequestToolReplayBinding({ binding: expired.binding, facts: facts({ requestTimeMs: expired.facts.requestTimeMs }) })) + .toEqual({ valid: false, reason: 'binding_expired' }); + + const rollback = capture(); + vi.advanceTimersByTime(ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS + 1); + vi.spyOn(Date, 'now').mockReturnValue( + rollback.facts.requestTimeMs + ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS, + ); + expect(Date.now()).toBe(rollback.facts.requestTimeMs + ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS); + expect(claimSealedRequestToolReplayBinding({ binding: rollback.binding, facts: facts({ requestTimeMs: rollback.facts.requestTimeMs }) })) + .toEqual({ valid: false, reason: 'binding_expired' }); + expect(claimSealedRequestToolReplayBinding({ binding: rollback.binding, facts: facts({ requestTimeMs: rollback.facts.requestTimeMs }) })) + .toEqual({ valid: false, reason: 'binding_already_consumed' }); + }); + + it('accepts fractional monotonic timestamps and honors strict fractional TTL boundaries', () => { + const epsilon = 0.25; + const validateAt = (elapsedMs: number) => { + const startMs = 100.5; + vi.spyOn(performance, 'now').mockReturnValueOnce(startMs).mockReturnValue(startMs + elapsedMs); + const value = capture(); + const result = claimSealedRequestToolReplayBinding({ binding: value.binding, facts: value.facts }); + vi.restoreAllMocks(); + return result; + }; + + expect(validateAt(ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS - epsilon)).toMatchObject({ valid: true }); + expect(validateAt(ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS)).toMatchObject({ valid: true }); + expect(validateAt(ADDIE_REQUEST_TOOL_REPLAY_BINDING_TTL_MS + epsilon)) + .toEqual({ valid: false, reason: 'binding_expired' }); + }); + + it('rejects future, negative, non-finite, and unsafe request times before mint', () => { + const invalidRequestTimes = [ + Date.now() + 1, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER, + ]; + for (const requestTimeMs of invalidRequestTimes) { + const value = setup(); + value.facts = facts({ requestTimeMs }) as AddieRequestToolReplayFacts; + expect(() => captureSealedRequestToolReplayBinding(captureInput(value))).toThrow(); + } + }); + + it('rejects monotonic readings above Number.MAX_SAFE_INTEGER without a caller clock', () => { + const value = setup(); + vi.spyOn(performance, 'now').mockReturnValue(Number.MAX_SAFE_INTEGER + 1); + expect(() => captureSealedRequestToolReplayBinding(captureInput(value))).toThrow('monotonic clock is invalid'); + + vi.restoreAllMocks(); + const { binding, facts: capturedFacts } = capture(); + vi.spyOn(performance, 'now').mockReturnValue(Number.MAX_SAFE_INTEGER + 1); + expect(claimSealedRequestToolReplayBinding({ binding, facts: capturedFacts })) + .toEqual({ valid: false, reason: 'binding_clock_invalid' }); + }); + + it('is one-use and fails closed for abort before any real handler can run', () => { + const accepted = capture(); + expect(claimSealedRequestToolReplayBinding({ binding: accepted.binding, facts: facts() })) + .toMatchObject({ valid: true, tools: accepted.binding.tools }); + expect(claimSealedRequestToolReplayBinding({ binding: accepted.binding, facts: facts() })) + .toEqual({ valid: false, reason: 'binding_already_consumed' }); + + const aborted = capture(); + const controller = new AbortController(); + controller.abort(); + expect(claimSealedRequestToolReplayBinding({ + binding: aborted.binding, facts: facts(), signal: controller.signal, + })).toEqual({ valid: false, reason: 'binding_aborted' }); + + for (const value of [accepted, aborted]) { + for (const handler of [value.globalAlpha, value.globalBeta, value.localBeta, value.localGamma]) { + expect(handler).not.toHaveBeenCalled(); + } + } + }); + + it('has no fixture-oracle input, so routes, rubrics, and expected names cannot influence it', () => { + const fixtureOracle = { + routing: { toolSets: ['admin_billing'] }, expectedTools: ['invented_tool'], rubric: ['must disclose nothing'], + }; + const { binding } = capture(); + void fixtureOracle; + + expect(binding.tools.map(({ name }) => name)).toEqual(['alpha', 'beta', 'gamma']); + expect(claimSealedRequestToolReplayBinding({ binding, facts: facts() })) + .toMatchObject({ valid: true }); + }); +});