import { createHash } from 'node:crypto'; import { normalizeSecurityAuditRecord, type SecurityAuditRecord, } from '../security/audit/securityAudit'; export const TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA = 'qinglong/tool-execution-trace-anchor@v1' as const; export const TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA = 'qinglong/tool-execution-audit-receipt@v1' as const; export const TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA = 'qinglong/tool-execution-evidence-bundle@v1' as const; export const TOOL_EXECUTION_START_AUDIT_OPERATION = 'tool.invoke.start' as const; export const MAX_TOOL_EXECUTION_EVIDENCE_BYTES = 16 * 1024; export const MAX_TOOL_EXECUTION_EVIDENCE_PAGE_SIZE = 128; export interface ToolExecutionTraceAnchor { readonly schema: typeof TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA; readonly traceId: string; readonly spanId: string; readonly parentSpanId: string | null; readonly projectId: string; readonly runId: string; readonly stepRunId: string; readonly invocationPlanDigest: string; readonly bindingDigest: string; readonly adapterDigest: string; readonly redactionContractDigest: string; readonly auditContractDigest: string; readonly createdAtMs: number; readonly traceDigest: string; } export interface ToolExecutionAuditReceipt { readonly schema: typeof TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA; readonly eventId: string; readonly projectId: string; readonly runId: string; readonly stepRunId: string; readonly traceId: string; readonly spanId: string; readonly traceDigest: string; readonly invocationPlanDigest: string; readonly bindingDigest: string; readonly auditRecordDigest: string; readonly createdAtMs: number; readonly receiptDigest: string; } export interface ToolExecutionEvidenceBundle { readonly schema: typeof TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA; readonly trace: Readonly; readonly audit: Readonly; readonly receipt: Readonly; } export interface CreateToolExecutionEvidenceInput { readonly traceId: string; readonly spanId: string; readonly parentSpanId?: string; readonly projectId: string; readonly runId: string; readonly stepRunId: string; readonly invocationPlanDigest: string; readonly bindingDigest: string; readonly adapterDigest: string; readonly redactionContractDigest: string; readonly auditContractDigest: string; readonly audit: SecurityAuditRecord; readonly createdAtMs: number; } export interface ToolExecutionEvidenceCursor { readonly createdAtMs: number; readonly traceId: string; readonly spanId: string; } export interface ListToolExecutionEvidenceQuery { readonly runId: string; readonly limit: number; readonly after?: ToolExecutionEvidenceCursor; } export interface ListToolExecutionEvidenceResult { readonly bundles: readonly Readonly[]; readonly truncated: boolean; readonly next?: Readonly; } export interface PrepareToolExecutionEvidenceResult { readonly status: 'created' | 'existing'; readonly bundle: Readonly; } export interface ToolExecutionEvidenceRepository { findByTrace( traceId: string, spanId: string, ): Promise | null>; findByAuditEventId( eventId: string, ): Promise | null>; listByRun( query: ListToolExecutionEvidenceQuery, ): Promise; prepare( bundle: ToolExecutionEvidenceBundle, ): Promise>; } export class InvalidToolExecutionEvidenceError extends TypeError { readonly code = 'TOOL_EXECUTION_EVIDENCE_INVALID'; constructor(message: string) { super(`Tool execution evidence is invalid: ${message}`); this.name = 'InvalidToolExecutionEvidenceError'; } } export class ToolExecutionEvidenceConflictError extends Error { readonly code = 'TOOL_EXECUTION_EVIDENCE_CONFLICT'; constructor() { super('Tool execution evidence identity is bound to different content'); this.name = 'ToolExecutionEvidenceConflictError'; } } export class ToolExecutionEvidenceUnavailableError extends Error { readonly code = 'TOOL_EXECUTION_EVIDENCE_UNAVAILABLE'; constructor(options?: ErrorOptions) { super('Tool execution evidence repository is unavailable', options); this.name = 'ToolExecutionEvidenceUnavailableError'; } } const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/; const SPAN_ID_PATTERN = /^[0-9a-f]{16}$/; const DIGEST_PATTERN = /^[0-9a-f]{64}$/; const TRACE_DIGEST_DOMAIN = Buffer.from( 'qinglong/tool-execution-trace-anchor-digest@v1\0', 'utf8', ); const AUDIT_RECORD_DIGEST_DOMAIN = Buffer.from( 'qinglong/tool-execution-audit-record-digest@v1\0', 'utf8', ); const AUDIT_RECEIPT_DIGEST_DOMAIN = Buffer.from( 'qinglong/tool-execution-audit-receipt-digest@v1\0', 'utf8', ); function invalid(message: string): never { throw new InvalidToolExecutionEvidenceError(message); } function dataRecord(value: unknown, label: string): Record { if ( !value || typeof value !== 'object' || Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) ) { return invalid(`${label} must be an object`); } const descriptors = Object.getOwnPropertyDescriptors(value); if ( Object.values(descriptors).some( (descriptor) => descriptor.get !== undefined || descriptor.set !== undefined || descriptor.enumerable !== true, ) ) { return invalid(`${label} must contain enumerable data properties`); } return value as Record; } function exactKeys( value: object, required: readonly string[], optional: readonly string[], label: string, ): void { const keys = Object.keys(value).sort(); const requiredKeys = [...required].sort(); const allowed = new Set([...required, ...optional]); if ( requiredKeys.some((key) => !keys.includes(key)) || keys.some((key) => !allowed.has(key)) ) { invalid(`${label} shape is invalid`); } } function identifier(value: unknown, label: string): string { if (typeof value !== 'string' || !IDENTIFIER_PATTERN.test(value)) { return invalid(`${label} is invalid`); } return value; } function traceId(value: unknown): string { if (typeof value !== 'string' || !TRACE_ID_PATTERN.test(value)) { return invalid('traceId is invalid'); } return value; } function spanId(value: unknown, label = 'spanId'): string { if (typeof value !== 'string' || !SPAN_ID_PATTERN.test(value)) { return invalid(`${label} is invalid`); } return value; } function digest(value: unknown, label: string): string { if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { return invalid(`${label} is invalid`); } return value; } function timestamp(value: unknown, label: string): number { if (!Number.isSafeInteger(value) || (value as number) < 0) { return invalid(`${label} is invalid`); } return value as number; } function canonicalJson(value: unknown): string { if ( value === null || typeof value === 'boolean' || typeof value === 'string' ) { return JSON.stringify(value); } if (typeof value === 'number') { if (!Number.isSafeInteger(value)) invalid('canonical value is invalid'); return JSON.stringify(value); } if (Array.isArray(value)) { return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`; } const record = dataRecord(value, 'canonical value'); return `{${Object.keys(record) .sort() .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) .join(',')}}`; } function hash(domain: Buffer, value: unknown): string { return createHash('sha256') .update(domain) .update(canonicalJson(value), 'utf8') .digest('hex'); } function traceUnsigned( value: Readonly, ): Omit { const { traceDigest: _traceDigest, ...unsigned } = value; return unsigned; } function receiptUnsigned( value: Readonly, ): Omit { const { receiptDigest: _receiptDigest, ...unsigned } = value; return unsigned; } export function toolExecutionAuditRecordDigest( value: SecurityAuditRecord, ): string { return hash( AUDIT_RECORD_DIGEST_DOMAIN, normalizeSecurityAuditRecord(value), ); } export function normalizeToolExecutionTraceAnchor( value: ToolExecutionTraceAnchor, ): Readonly { const record = dataRecord(value, 'trace anchor'); exactKeys( record, [ 'adapterDigest', 'auditContractDigest', 'bindingDigest', 'createdAtMs', 'invocationPlanDigest', 'parentSpanId', 'projectId', 'redactionContractDigest', 'runId', 'schema', 'spanId', 'stepRunId', 'traceDigest', 'traceId', ], [], 'trace anchor', ); if (value.schema !== TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA) { return invalid('trace anchor schema is invalid'); } const normalized = Object.freeze({ schema: TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA, traceId: traceId(value.traceId), spanId: spanId(value.spanId), parentSpanId: value.parentSpanId === null ? null : spanId(value.parentSpanId, 'parentSpanId'), projectId: identifier(value.projectId, 'projectId'), runId: identifier(value.runId, 'runId'), stepRunId: identifier(value.stepRunId, 'stepRunId'), invocationPlanDigest: digest( value.invocationPlanDigest, 'invocationPlanDigest', ), bindingDigest: digest(value.bindingDigest, 'bindingDigest'), adapterDigest: digest(value.adapterDigest, 'adapterDigest'), redactionContractDigest: digest( value.redactionContractDigest, 'redactionContractDigest', ), auditContractDigest: digest( value.auditContractDigest, 'auditContractDigest', ), createdAtMs: timestamp(value.createdAtMs, 'createdAtMs'), traceDigest: digest(value.traceDigest, 'traceDigest'), }); if ( normalized.parentSpanId === normalized.spanId || hash(TRACE_DIGEST_DOMAIN, traceUnsigned(normalized)) !== normalized.traceDigest || Buffer.byteLength(canonicalJson(normalized), 'utf8') > MAX_TOOL_EXECUTION_EVIDENCE_BYTES ) { return invalid('trace anchor semantic digest is invalid'); } return normalized; } export function normalizeToolExecutionAuditReceipt( value: ToolExecutionAuditReceipt, ): Readonly { const record = dataRecord(value, 'audit receipt'); exactKeys( record, [ 'auditRecordDigest', 'bindingDigest', 'createdAtMs', 'eventId', 'invocationPlanDigest', 'projectId', 'receiptDigest', 'runId', 'schema', 'spanId', 'stepRunId', 'traceDigest', 'traceId', ], [], 'audit receipt', ); if (value.schema !== TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA) { return invalid('audit receipt schema is invalid'); } const normalized = Object.freeze({ schema: TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA, eventId: identifier(value.eventId, 'eventId'), projectId: identifier(value.projectId, 'projectId'), runId: identifier(value.runId, 'runId'), stepRunId: identifier(value.stepRunId, 'stepRunId'), traceId: traceId(value.traceId), spanId: spanId(value.spanId), traceDigest: digest(value.traceDigest, 'traceDigest'), invocationPlanDigest: digest( value.invocationPlanDigest, 'invocationPlanDigest', ), bindingDigest: digest(value.bindingDigest, 'bindingDigest'), auditRecordDigest: digest( value.auditRecordDigest, 'auditRecordDigest', ), createdAtMs: timestamp(value.createdAtMs, 'createdAtMs'), receiptDigest: digest(value.receiptDigest, 'receiptDigest'), }); if ( hash(AUDIT_RECEIPT_DIGEST_DOMAIN, receiptUnsigned(normalized)) !== normalized.receiptDigest || Buffer.byteLength(canonicalJson(normalized), 'utf8') > MAX_TOOL_EXECUTION_EVIDENCE_BYTES ) { return invalid('audit receipt semantic digest is invalid'); } return normalized; } export function normalizeToolExecutionEvidenceBundle( value: ToolExecutionEvidenceBundle, ): Readonly { const record = dataRecord(value, 'evidence bundle'); exactKeys(record, ['audit', 'receipt', 'schema', 'trace'], [], 'evidence bundle'); if (value.schema !== TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA) { return invalid('evidence bundle schema is invalid'); } let audit: Readonly; try { audit = normalizeSecurityAuditRecord(value.audit); } catch { return invalid('security audit record is invalid'); } const trace = normalizeToolExecutionTraceAnchor(value.trace); const receipt = normalizeToolExecutionAuditReceipt(value.receipt); if ( audit.operationId !== TOOL_EXECUTION_START_AUDIT_OPERATION || audit.outcome !== 'allowed' || audit.projectId !== trace.projectId || audit.fence === null || audit.occurredAtMs !== trace.createdAtMs || receipt.eventId !== audit.eventId || receipt.projectId !== trace.projectId || receipt.runId !== trace.runId || receipt.stepRunId !== trace.stepRunId || receipt.traceId !== trace.traceId || receipt.spanId !== trace.spanId || receipt.traceDigest !== trace.traceDigest || receipt.invocationPlanDigest !== trace.invocationPlanDigest || receipt.bindingDigest !== trace.bindingDigest || receipt.auditRecordDigest !== toolExecutionAuditRecordDigest(audit) || receipt.createdAtMs !== trace.createdAtMs ) { return invalid('evidence bundle bindings are inconsistent'); } const bundle = Object.freeze({ schema: TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA, trace, audit, receipt, }); if ( Buffer.byteLength(canonicalJson(bundle), 'utf8') > MAX_TOOL_EXECUTION_EVIDENCE_BYTES ) { return invalid('evidence bundle exceeds the byte limit'); } return bundle; } export function createToolExecutionEvidenceBundle( inputValue: CreateToolExecutionEvidenceInput, ): Readonly { const input = dataRecord(inputValue, 'create input'); exactKeys( input, [ 'adapterDigest', 'audit', 'auditContractDigest', 'bindingDigest', 'createdAtMs', 'invocationPlanDigest', 'projectId', 'redactionContractDigest', 'runId', 'spanId', 'stepRunId', 'traceId', ], ['parentSpanId'], 'create input', ); let audit: Readonly; try { audit = normalizeSecurityAuditRecord(inputValue.audit); } catch { return invalid('security audit record is invalid'); } const traceWithoutDigest = Object.freeze({ schema: TOOL_EXECUTION_TRACE_ANCHOR_SCHEMA, traceId: traceId(inputValue.traceId), spanId: spanId(inputValue.spanId), parentSpanId: inputValue.parentSpanId === undefined ? null : spanId(inputValue.parentSpanId, 'parentSpanId'), projectId: identifier(inputValue.projectId, 'projectId'), runId: identifier(inputValue.runId, 'runId'), stepRunId: identifier(inputValue.stepRunId, 'stepRunId'), invocationPlanDigest: digest( inputValue.invocationPlanDigest, 'invocationPlanDigest', ), bindingDigest: digest(inputValue.bindingDigest, 'bindingDigest'), adapterDigest: digest(inputValue.adapterDigest, 'adapterDigest'), redactionContractDigest: digest( inputValue.redactionContractDigest, 'redactionContractDigest', ), auditContractDigest: digest( inputValue.auditContractDigest, 'auditContractDigest', ), createdAtMs: timestamp(inputValue.createdAtMs, 'createdAtMs'), }); const trace = normalizeToolExecutionTraceAnchor({ ...traceWithoutDigest, traceDigest: hash(TRACE_DIGEST_DOMAIN, traceWithoutDigest), }); const receiptWithoutDigest = Object.freeze({ schema: TOOL_EXECUTION_AUDIT_RECEIPT_SCHEMA, eventId: audit.eventId, projectId: trace.projectId, runId: trace.runId, stepRunId: trace.stepRunId, traceId: trace.traceId, spanId: trace.spanId, traceDigest: trace.traceDigest, invocationPlanDigest: trace.invocationPlanDigest, bindingDigest: trace.bindingDigest, auditRecordDigest: toolExecutionAuditRecordDigest(audit), createdAtMs: trace.createdAtMs, }); const receipt = normalizeToolExecutionAuditReceipt({ ...receiptWithoutDigest, receiptDigest: hash( AUDIT_RECEIPT_DIGEST_DOMAIN, receiptWithoutDigest, ), }); return normalizeToolExecutionEvidenceBundle({ schema: TOOL_EXECUTION_EVIDENCE_BUNDLE_SCHEMA, trace, audit, receipt, }); } export function toolExecutionAdmissionEvidence( value: ToolExecutionEvidenceBundle, ): Readonly<{ trace: Readonly<{ traceId: string; spanId: string; digest: string }>; audit: Readonly<{ eventId: string; digest: string }>; }> { const bundle = normalizeToolExecutionEvidenceBundle(value); return Object.freeze({ trace: Object.freeze({ traceId: bundle.trace.traceId, spanId: bundle.trace.spanId, digest: bundle.trace.traceDigest, }), audit: Object.freeze({ eventId: bundle.receipt.eventId, digest: bundle.receipt.receiptDigest, }), }); } export function normalizeListToolExecutionEvidenceQuery( value: ListToolExecutionEvidenceQuery, ): Readonly { const query = dataRecord(value, 'list query'); exactKeys(query, ['limit', 'runId'], ['after'], 'list query'); const limit = value.limit; if ( !Number.isSafeInteger(limit) || limit < 1 || limit > MAX_TOOL_EXECUTION_EVIDENCE_PAGE_SIZE ) { return invalid('list limit is invalid'); } let after: Readonly | undefined; if (value.after !== undefined) { const cursor = dataRecord(value.after, 'list cursor'); exactKeys( cursor, ['createdAtMs', 'spanId', 'traceId'], [], 'list cursor', ); after = Object.freeze({ createdAtMs: timestamp(value.after.createdAtMs, 'cursor createdAtMs'), traceId: traceId(value.after.traceId), spanId: spanId(value.after.spanId), }); } return Object.freeze({ runId: identifier(value.runId, 'runId'), limit, ...(after ? { after } : {}), }); } export function normalizeListToolExecutionEvidenceResult( value: ListToolExecutionEvidenceResult, queryValue: ListToolExecutionEvidenceQuery, ): Readonly { const query = normalizeListToolExecutionEvidenceQuery(queryValue); const result = dataRecord(value, 'list result'); exactKeys(result, ['bundles', 'truncated'], ['next'], 'list result'); if ( !Array.isArray(value.bundles) || value.bundles.length > query.limit || typeof value.truncated !== 'boolean' ) { return invalid('list result is invalid'); } const bundles = value.bundles.map(normalizeToolExecutionEvidenceBundle); if ( bundles.some((bundle) => bundle.trace.runId !== query.runId) || bundles.some( (bundle, index) => index > 0 && (bundle.trace.createdAtMs < bundles[index - 1]!.trace.createdAtMs || (bundle.trace.createdAtMs === bundles[index - 1]!.trace.createdAtMs && `${bundle.trace.traceId}:${bundle.trace.spanId}` <= `${bundles[index - 1]!.trace.traceId}:${ bundles[index - 1]!.trace.spanId }`)), ) ) { return invalid('list result ordering is invalid'); } const last = bundles.at(-1); const expectedNext = value.truncated && last ? Object.freeze({ createdAtMs: last.trace.createdAtMs, traceId: last.trace.traceId, spanId: last.trace.spanId, }) : undefined; if ( (expectedNext === undefined) !== (value.next === undefined) || (expectedNext && (value.next!.createdAtMs !== expectedNext.createdAtMs || value.next!.traceId !== expectedNext.traceId || value.next!.spanId !== expectedNext.spanId)) ) { return invalid('list continuation is invalid'); } return Object.freeze({ bundles: Object.freeze(bundles), truncated: value.truncated, ...(expectedNext ? { next: expectedNext } : {}), }); }