UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

569 lines • 30.8 kB
"use strict"; /** * auditExport — tamper-evident audit bundle (backlog #20, compliance * wedge item 2; pairs with #19's `otelObservability` GenAI spans). * * Consumes the typed `agentfootprint.*` event stream and accumulates an * append-only, HASH-CHAINED record log: every record carries the SHA-256 * of its own canonical serialization plus the hash of the previous * record. Flipping a single byte anywhere in an exported bundle makes * `verifyAuditBundle` name the exact record that broke. The log serves * the automatic event recording EU AI Act Art. 12 asks of high-risk * systems (the events the system logged, in order). The tamper evidence * — showing the log is unmodified since capture — is this library's * addition: Art. 12 does not require it. * * Pattern: Observability strategy (one purpose — chain accumulation) * + pure offline verifier. * Role: Outer ring (Hexagonal). Attach via * `agent.enable.observability({ strategy: auditExport() })`. * Emits: nothing — terminal sink. * * ## What lands in the chain * * One record per typed event, in dispatch order: decisions * (`agent.route_decided`, `composition.route_decided` incl. decide() * evidence), tool calls (`stream.tool_start/_end`), validation * rejections (#9), permission verdicts and halts, credential lifecycle, * costs, errors, skill/memory/context activity. Each new `meta.runId` * is anchored by a GENESIS record (`audit.genesis`) carrying the runId, * the agent identity, and library versions — runs chain back-to-back in * one log, so silently DROPPING a whole run breaks the chain too. * * A call a paused batch never dispatched (9.113.0) lands as its two * brackets, each carrying `notDispatched: { pausedCall: { toolCallId, * toolName } }` and neither carrying `error` — identifiers only. Bounded mode * checks each field by its declared shape; verbatim mode sanitizes the payload. * The chained record therefore says the call never ran — neither that it * succeeded nor that it failed; the bounded `result` (`[type: string]`) never * had to say it. * * High-volume content deltas (`stream.token`, `stream.thinking_delta`) * are excluded by default (`includeTokenEvents: true` to include). * * ## Record / bundle schema * * ``` * AuditRecord = { seq, timestamp, eventType, payload, meta, prevHash, hash } * hash = SHA-256 hex over canonicalJson(record minus `hash`) * prevHash = previous record's `hash` (ZERO_HASH at chain start) * AuditBundle = { header, records, finalHash } * header = { format, hashAlgorithm, canonicalization, chainHead, * firstSeq, recordCount, exportedAt, library } * ``` * * Canonicalization is `afp-cjson/1` (see `lib/canonicalJson.ts` — those * rules ARE the contract; the header names them so independent * verifiers can re-implement byte-exactly). * * ## Persistence + long runs * * Persistence is the CONSUMER's job — the bundle is plain JSON * (`JSON.stringify(strategy.bundle())`, store anywhere). For long runs, * `drain()` returns the records accumulated since the last drain while * keeping the chain intact ACROSS drains: each segment's * `header.chainHead` equals the previous segment's `finalHash`, so * `verifyAuditBundle([seg1, seg2, ...])` re-verifies the concatenation * end-to-end. * * ## PII discipline (mirrors #19's otelObservability) * * Payloads enter records through a bounding layer: every registered event * has a shape that names every field of its payload (`auditShapes.ts`, total * over the event registry and exhaustive by type, built from the combinators * in `auditBounding.ts`). In the default `payloadMode: 'bounded'` a record * payload keeps the run's STRUCTURE and never its CONTENT — every string or * value the run composed from what it read or produced, whoever wrote it — * except what is named below under "What a bounded record still carries". * Bounding is CLOSED by default: a field a shape does not name, a value that * is not the declared shape, and every field of an event type the registry * does not declare (a consumer's own `emit`), is a marker. Some of what that * bounds: * * - tool args → `'[keys: …]'` (top-level key NAMES only); * `tool_end`'s `changedArgKeys` is names already * - tool results → `'[type: …]'` (typeof only) — `result` and, * when a rule changed it, `modelResult` alike * - an attached script's program (`turn_end.scripts.shown[].text`) * → `'[N chars]'`; checked labels, ids, counts and * rendering-boundary flags retain their shapes * - an evaluation's `explanation` → `'[N chars]'` (a judge's reason can * quote the answer it graded) * - userPrompt / LLM content / thinking blocks / history * → `'[N chars]'` / `'[N messages]'` markers * - content PREVIEWS (`contentSummary` on context/memory events, * `rawContent`, `resultSummary`, `droppedSummaries`) → markers * (for short content a preview IS the content; `contentHash` * stays — it links identical content without echoing it) * - error MESSAGE strings (`error`, `errorMessage`, `lastError`, * `rawOutput`) → `'[N chars]'` (messages can echo values) * - free-form Records (`questionPayload`, `resumeInput`, risk/eval * `evidence`, memory `scoreEvidence`) → `'[keys: …]'` * - a typed answer's limits and negatives on `turn_end` (`answerCoverage`, * `answerNegatives`) → the same shape, every sentence and value a * `'[N chars]'` marker: a coverage item's `what` / `why` / `short`, a * declared period's instants, an in-progress item's prose, an assumed * `value`, a negative's `statement` and `about`. The tool and argument * names, call ids, counts, kinds, codes and verdicts stay; a field the * shape does not name is a marker too, so one added later is bounded * until it is classified * - every other event whose content sits beside structure keeps its * shape, each content leaf a marker: a tool's absence, coverage * and described-result words and data (`tools.absent`, * `tools.coverage_declared`, `tools.semantics_declared`), the evidence * gate's values, an external ground's value, the message text a routing * match quotes (`map.engaged`, `map.parked`, `skill.turn_routed`, * `context.evaluated`), a validation issue's quoted value, parser and * fallback messages and previews, a tool's progress payload and a * check-in's evidence pack, a person's note, a middleware's reason or * question, the model's own words (`skill.step_skipped`, `agent.handoff`), * the opt-in assembled system prompt, a retrieved passage's heading, an * integrity finding's witnessed values and message, a tool effect's * reason, a pause's own reason, an artifact's label and refusal detail, a * fail-fast reason, a check's `rationale` and `tellLLM`, a fallback's or a * credential failure's message, and a router's own reason * * What the shapes keep is checked structure: a number, boolean or string * must arrive as that kind, and a closed enum must be one of its declared * words. Every container is classified field by field, even if its declared * type contains only counts. Unknown children and wrong kinds are markers. * Strings are capped at 256 characters and lists at 32 items. This retains * ids, names, kinds, codes, telemetry tags, verdicts, counts, library times * and the words the app declared up front (labels, descriptions, step notes, * hints, display names, rule ids). The type walker and runtime damage sweep * check both halves; `auditKeptByContract.ts` names the exceptions below. * * What a bounded record still carries, by contract: * * - each record's `meta`: run and stage ids, paths, turn and iteration * indices and — when the caller named them — `sessionId`, `principal` * and `tenant`, the who of an audit record; * - decision evidence as the engine summarized it: `decide()` evidence on * `composition.route_decided` and a skill guard's conditions on * `context.evaluated` — rule labels, operators, thresholds and a summary * of each value a rule read. It is redaction-aware (under an agent's * `redact` a selected key is the placeholder); otherwise the summary * shows the value; * - a policy's telemetry `reason` tag, and the reason word an app attaches * to a lifecycle event it emits itself (`skill.deactivated`, * `tools.offered`, `permission.gate_closed`): the app's own words, as * written — an app that writes a run's value into one carries it; * - a `tryInsteadTool` name, only once this strategy has seen that tool run. * * Sanitizing caps a string's length; it does not hide what the string says. * * `payloadMode: 'verbatim'` embeds full payloads (still * JSON-sanitized). For Art. 12 completeness on an access-controlled * store that is often the point — but the bundle then carries prompts, * tool args/results and model output. Treat it as PII-bearing. An agent * built with `Agent.create({ redact })` serves every event before any * strategy sees it, so this one receives — and chains — the placeholder * wherever the policy selected a value (a selected key at any depth of a * payload; a whole payload whose event NAME `emitPatterns` selects). The * policy selects by name, never by content: with no policy, or under a name * it does not select, the value arrives as it was. * * ## Tamper-EVIDENT, not tamper-PROOF (honest threat model) * * The chain proves INTERNAL consistency: any partial modification — * edit, insert, delete, reorder, drop-a-run — is detected and named. * It does NOT prove provenance: an adversary holding the only copy can * recompute every hash from the mutation onward and present a * self-consistent forgery. For non-repudiation, anchor `finalHash` * externally as part of your retention process (write-once/WORM store, * signed log, RFC 3161 timestamping, or simply a second party) — then * a whole-suffix recomputation no longer matches the anchor. * * ## Runtime requirements * * Hashing uses `node:crypto` (`createHash('sha256')`) — zero new * dependencies, imported lazily at first use (same gating as the * optional vendor SDKs in this folder, so merely importing this module * stays browser-safe). `auditExport` and `verifyAuditBundle` therefore * run anywhere `node:crypto` exists: Node ≥ 20, Bun, Deno, * edge runtimes with Node compat (e.g. Cloudflare `nodejs_compat`). * In a browser there is no SYNC SHA-256 (WebCrypto is async-only), so * both throw a descriptive error — verify server-side, or re-implement * verification from the documented contract (it is pure: recompute * SHA-256 over `afp-cjson/1` canonicalization and walk the chain). * * @example Capture → export → verify * ```ts * import { auditExport, verifyAuditBundle } from 'agentfootprint/observe'; * * const audit = auditExport({ agent: 'loan-officer' }); * const stop = agent.enable.observability({ strategy: audit }); * await agent.run({ message: 'assess application A-17' }); * stop(); * * const bundle = audit.bundle(); // JSON-serializable * await fs.writeFile('run.audit.json', JSON.stringify(bundle)); * * const check = verifyAuditBundle(bundle); // offline — no agent needed * // check.valid === true; tamper with one byte → { valid: false, brokenAt: <seq> } * ``` */ Object.defineProperty(exports, "__esModule", { value: true }); exports.verifyAuditBundle = exports.auditExport = exports.boundedContentLeaves = exports.boundedFieldRules = exports.boundedContentFieldNames = exports.AUDIT_BUNDLE_FORMAT = exports.AUDIT_GENESIS_EVENT_TYPE = exports.AUDIT_ZERO_HASH = void 0; const canonicalJson_js_1 = require("../../lib/canonicalJson.js"); const lazyRequire_js_1 = require("../../lib/lazyRequire.js"); const libraryVersion_js_1 = require("../../lib/libraryVersion.js"); const auditBounding_js_1 = require("./auditBounding.js"); const auditShapes_js_1 = require("./auditShapes.js"); // ─── Public types ───────────────────────────────────────────────────── /** SHA-256 of "nothing" — the `prevHash` of the first record in a * chain and the `chainHead` of a chain's first segment. */ exports.AUDIT_ZERO_HASH = '0'.repeat(64); /** `eventType` of the per-run genesis record. Deliberately OUTSIDE the * `agentfootprint.*` registry namespace — it is a chain-level record, * not a dispatched event (#20 ships zero new typed events). */ exports.AUDIT_GENESIS_EVENT_TYPE = 'audit.genesis'; /** Format identifier carried on every bundle header. */ exports.AUDIT_BUNDLE_FORMAT = 'agentfootprint.audit/1'; let cryptoModule; function resolveCrypto() { if (cryptoModule) return cryptoModule; try { cryptoModule = (0, lazyRequire_js_1.lazyRequire)('node:crypto'); } catch { throw new Error('auditExport/verifyAuditBundle require `node:crypto` for synchronous SHA-256 ' + '(available in Node ≥ 20, Bun, Deno, and edge runtimes with Node compat). ' + 'Browsers expose only async WebCrypto — capture/verify audit bundles server-side.'); } if (typeof cryptoModule.createHash !== 'function') { throw new Error('auditExport: `node:crypto` resolved but `createHash` is missing.'); } return cryptoModule; } function sha256Hex(text) { return resolveCrypto().createHash('sha256').update(text, 'utf8').digest('hex'); } // ─── Bounding layer (payloadMode: 'bounded') ───────────────────────── /** * The conversation fields the bounded mode replaces WHOLE, per event type — a * DECLARATION. Bounding itself is every event's shape (`auditShapes.ts` · * `eventShapes`), which applies these same rule functions to these same fields * (pinned by `test/type-regressions/AuditBoundingDepth.test.ts`); this table * names them so the redaction vocabulary can be pinned against them * (`boundedContentFieldNames`). */ const BOUND_FIELDS = { 'agentfootprint.agent.turn_start': { userPrompt: auditBounding_js_1.charsMarker }, // A typed answer's limits and negatives hold tool-written sentences, assumed // values and the populations a search covered BESIDE the record's own words: // each is bounded at its content leaves and keeps its shape. 'agentfootprint.agent.turn_end': { finalContent: auditBounding_js_1.charsMarker, answerCoverage: auditShapes_js_1.boundAnswerCoverage, answerNegatives: auditShapes_js_1.boundAnswerNegatives, }, 'agentfootprint.agent.iteration_end': { history: auditBounding_js_1.messagesMarker }, 'agentfootprint.agent.output_schema_validation_failed': { rawOutput: auditBounding_js_1.charsMarker }, 'agentfootprint.stream.llm_end': { content: auditBounding_js_1.charsMarker }, 'agentfootprint.stream.token': { content: auditBounding_js_1.charsMarker }, 'agentfootprint.stream.thinking_delta': { content: auditBounding_js_1.charsMarker }, 'agentfootprint.stream.thinking_end': { blocks: auditBounding_js_1.blocksMarker }, 'agentfootprint.stream.tool_start': { args: auditBounding_js_1.keysMarker }, // `modelResult` (what a rule let the model read, or the envelope without // its record-only `short` / `kind`) is the same content as `result`, from // the other side of a rule or that strip — bounded the same way. // `changedArgKeys` is names only and rides verbatim, like `args`' keys. 'agentfootprint.stream.tool_end': { result: auditBounding_js_1.typeMarker, modelResult: auditBounding_js_1.typeMarker }, // contentSummary is a raw-content PREVIEW (not a redacted summary) — // for short content it IS the content, so it is bounded like content. // `contentHash` stays verbatim: it links identical injections across // records without echoing a byte of them. 'agentfootprint.context.injected': { rawContent: auditBounding_js_1.charsMarker, contentSummary: auditBounding_js_1.charsMarker }, 'agentfootprint.context.slot_composed': { droppedSummaries: auditBounding_js_1.summariesMarker }, 'agentfootprint.composition.merge_end': { resultSummary: auditBounding_js_1.charsMarker }, 'agentfootprint.memory.attached': { contentSummary: auditBounding_js_1.charsMarker }, 'agentfootprint.memory.written': { contentSummary: auditBounding_js_1.charsMarker }, 'agentfootprint.pause.request': { questionPayload: auditBounding_js_1.keysMarker }, 'agentfootprint.pause.resume': { resumeInput: auditBounding_js_1.keysMarker }, // `explanation` is a judge's reason — it can quote the answer it graded // (`EvalScorePayload.explanation` says CONTENT). `label` is a vocabulary // word by contract and stays verbatim. 'agentfootprint.eval.score': { evidence: auditBounding_js_1.keysMarker, explanation: auditBounding_js_1.charsMarker }, 'agentfootprint.memory.strategy_applied': { scoreEvidence: auditBounding_js_1.keysMarker }, }; /** * The content field names the bounded mode replaces, across every event type. * Pinned against the agent's redaction vocabulary * (`redaction/conversation.ts` · `conversationRedaction`): a field this table * calls content is a name the conversation travels under, and the test fails * when the two disagree. * * @internal */ function boundedContentFieldNames() { return [...new Set(Object.values(BOUND_FIELDS).flatMap((rules) => Object.keys(rules)))]; } exports.boundedContentFieldNames = boundedContentFieldNames; /** * The rules `BOUND_FIELDS` declares, by event type and field — what the pin * holds every event's shape to. * * @internal */ function boundedFieldRules() { return BOUND_FIELDS; } exports.boundedFieldRules = boundedFieldRules; /** Content leaves inside a field that also carries labels, ids and counts. * The function is the same rule the event shape applies, never a second sanitizer. */ const BOUND_LEAVES = { 'agentfootprint.agent.turn_end': { scripts: { bound: auditShapes_js_1.boundScripts, leaves: ['shown[].text'] }, }, }; /** The bounded content leaves, pinned against the redaction vocabulary's value-kind rule. * @internal */ function boundedContentLeaves() { return Object.entries(BOUND_LEAVES).flatMap(([eventType, fields]) => Object.entries(fields).flatMap(([field, rule]) => rule.leaves.map((leaf) => `${eventType} · ${field}.${leaf}`))); } exports.boundedContentLeaves = boundedContentLeaves; // ─── Strategy factory ──────────────────────────────────────────────── /** Excluded by default — high-volume content deltas. */ const TOKEN_EVENT_TYPES = new Set([ 'agentfootprint.stream.token', 'agentfootprint.stream.thinking_delta', ]); function auditExport(opts = {}) { const payloadMode = opts.payloadMode ?? 'bounded'; const includeTokenEvents = opts.includeTokenEvents === true; const version = (0, libraryVersion_js_1.libraryVersion)(); // Chain state — survives drains; one chain per strategy instance. let nextSeq = 0; let lastHash = exports.AUDIT_ZERO_HASH; let segmentHead = exports.AUDIT_ZERO_HASH; let segmentFirstSeq = 0; let retained = []; const seenRunIds = new Set(); let stopped = false; // Bounded mode: the tools this strategy has seen run (a `tryInsteadTool` // name is kept only for one of them) and the whole-event shapes — a `Map`, // because an event's name is the emitter's to choose. const toolRuns = (0, auditBounding_js_1.createToolRunTracker)(); const shapes = (0, auditShapes_js_1.eventShapeTable)(toolRuns.ran); /** An event's payload in bounded mode: its shape — closed by default, an * event type the registry does not declare (any name at all) included. */ function boundEvent(eventType, payload) { const shape = shapes.get(eventType) ?? auditBounding_js_1.unknownEventShape; return shape(payload); } /** A record's meta: its shape in bounded mode (`EventMeta`, each field * checked), sanitized in verbatim mode; anything but an object, none. */ function recordMetaOf(metaRest) { const bounded = payloadMode === 'bounded' ? (0, auditShapes_js_1.boundMeta)(metaRest) : (0, auditBounding_js_1.sanitizeJson)(metaRest); return bounded !== null && typeof bounded === 'object' && !Array.isArray(bounded) ? bounded : {}; } function append(eventType, timestamp, payload, meta) { const preimage = { seq: nextSeq, timestamp, eventType, payload, meta, prevHash: lastHash }; const hash = sha256Hex((0, canonicalJson_js_1.canonicalJson)(preimage)); retained.push({ ...preimage, hash }); lastHash = hash; nextSeq += 1; } function appendGenesis(runId, timestamp) { append(exports.AUDIT_GENESIS_EVENT_TYPE, timestamp, { runId, ...(opts.agent !== undefined && { agent: opts.agent }), library: { name: 'agentfootprint', version }, ...(opts.versions !== undefined && { versions: (0, auditBounding_js_1.sanitizeJson)(opts.versions) }), payloadMode, }, { runId }); } function makeBundle() { return { header: { format: exports.AUDIT_BUNDLE_FORMAT, hashAlgorithm: 'sha-256', canonicalization: canonicalJson_js_1.CANONICAL_JSON_VERSION, chainHead: segmentHead, firstSeq: segmentFirstSeq, recordCount: retained.length, exportedAt: Date.now(), library: { name: 'agentfootprint', version }, }, records: [...retained], finalHash: lastHash, }; } return { name: 'audit', capabilities: { events: true }, /** Attach-time check (the `validate()` contract) — fail fast where * `node:crypto` is unavailable instead of on the first event. */ validate() { resolveCrypto(); }, exportEvent(event) { if (stopped) return; if (!includeTokenEvents && TOKEN_EVENT_TYPES.has(event.type)) return; // Real dispatcher envelopes carry meta (bridge/eventMeta.ts). // Hand-fed events without it are still recorded — an audit sink // must never silently drop — under the 'unattributed' run anchor. const meta = event.meta; const runId = typeof meta?.runId === 'string' ? meta.runId : 'unattributed'; const timestamp = typeof meta?.wallClockMs === 'number' ? meta.wallClockMs : Date.now(); if (!seenRunIds.has(runId)) { seenRunIds.add(runId); appendGenesis(runId, timestamp); } // wallClockMs lives on `timestamp`; the rest of the meta rides along // (runtimeStageId, paths, turn/iter indices, …) — by its shape in bounded // mode, sanitized in verbatim mode. const { wallClockMs: _lifted, ...metaRest } = meta ?? {}; void _lifted; const recordMeta = { ...recordMetaOf(metaRest), runId }; toolRuns.observe(event.type, event.payload); const payload = payloadMode === 'bounded' ? boundEvent(event.type, event.payload) : (0, auditBounding_js_1.sanitizeJson)(event.payload); append(event.type, timestamp, payload, recordMeta); }, flush() { // In-memory sink — nothing to flush. Export via bundle()/drain(). }, stop() { // Stop OBSERVING; never destroy collected evidence — consumers // drain/bundle after stop. stopped = true; }, bundle() { return makeBundle(); }, drain() { const segment = makeBundle(); segmentHead = lastHash; segmentFirstSeq = nextSeq; retained = []; return segment; }, recordCount() { return retained.length; }, }; } exports.auditExport = auditExport; // ─── Offline verification (pure — no agent, no strategy) ───────────── /** * Recompute the hash chain of a bundle (or of consecutive drained * segments, in order) and report the exact record where integrity * breaks. Pure function over JSON data — runs offline, long after the * run, with no agent and no strategy instance. * * Checks, in order, per segment: * 1. header format / algorithm / canonicalization are supported * 2. `recordCount` matches `records.length` * 3. segment continuity (`chainHead`/`firstSeq` extend the previous * segment's `finalHash`/seq range) * 4. per record: `seq` is contiguous, `prevHash` links the previous * record, and SHA-256 over the canonical preimage (the record * minus `hash` — so ADDED fields are caught too) matches `hash` * 5. `finalHash` equals the last record's hash */ function verifyAuditBundle(input) { const segments = Array.isArray(input) ? input : [input]; let recordsChecked = 0; let expectedChainHead; let expectedFirstSeq; const fail = (reason, brokenAt) => ({ valid: false, recordsChecked, ...(brokenAt !== undefined && { brokenAt }), reason, }); if (segments.length === 0) return fail('no bundles supplied'); for (let s = 0; s < segments.length; s++) { const segment = segments[s]; const where = segments.length > 1 ? ` (segment ${s})` : ''; if (segment === undefined || typeof segment !== 'object') { return fail(`bundle is not an object${where}`); } const { header, records, finalHash } = segment; if (header === undefined || !Array.isArray(records) || typeof finalHash !== 'string') { return fail(`bundle missing header/records/finalHash${where}`); } if (header.format !== exports.AUDIT_BUNDLE_FORMAT) { return fail(`unsupported format '${String(header.format)}'${where}`); } if (header.hashAlgorithm !== 'sha-256') { return fail(`unsupported hashAlgorithm '${String(header.hashAlgorithm)}'${where}`); } if (header.canonicalization !== canonicalJson_js_1.CANONICAL_JSON_VERSION) { return fail(`unsupported canonicalization '${String(header.canonicalization)}'${where}`); } if (header.recordCount !== records.length) { return fail(`recordCount ${String(header.recordCount)} does not match ${records.length} records${where}`); } if (expectedChainHead !== undefined && header.chainHead !== expectedChainHead) { return fail(`segment discontinuity — chainHead does not match previous segment's finalHash${where}`, header.firstSeq); } if (expectedFirstSeq !== undefined && header.firstSeq !== expectedFirstSeq) { return fail(`segment discontinuity — firstSeq ${String(header.firstSeq)}, expected ${expectedFirstSeq}${where}`, expectedFirstSeq); } // Head-truncation invariant (adversarial-review finding): a segment // claiming to be the chain START (firstSeq 0) MUST anchor on the zero // hash, and a segment anchored on the zero hash MUST claim seq 0 — // both directions. Without this, an attacker could drop the first K // records (genesis included — agent identity, versions, payload-mode // declaration) and forge `chainHead` from the survivor's prevHash; // `finalHash` is untouched by that attack, so external finalHash // anchoring alone would NOT catch it. With the invariant, a head- // truncated forgery must declare firstSeq > 0 — visibly NOT a chain // start — and auditors reject leading segments missing seq 0. if (header.firstSeq === 0 && header.chainHead !== exports.AUDIT_ZERO_HASH) { return fail(`head-truncation guard — firstSeq 0 requires chainHead to be the zero hash${where}`, 0); } if (header.chainHead === exports.AUDIT_ZERO_HASH && header.firstSeq !== 0) { return fail(`head-truncation guard — zero-hash chainHead requires firstSeq 0, got ${String(header.firstSeq)}${where}`, header.firstSeq); } let prevHash = header.chainHead; for (let i = 0; i < records.length; i++) { const record = records[i]; const expectedSeq = header.firstSeq + i; if (record === null || typeof record !== 'object') { return fail(`record at seq ${expectedSeq} is not an object`, expectedSeq); } if (record.seq !== expectedSeq) { return fail(`sequence mismatch — record carries seq ${String(record.seq)}, expected ${expectedSeq}`, expectedSeq); } if (record.prevHash !== prevHash) { return fail(`chain broken — prevHash does not match the preceding record's hash`, expectedSeq); } let recomputed; try { const { hash: _stored, ...preimage } = record; void _stored; recomputed = sha256Hex((0, canonicalJson_js_1.canonicalJson)(preimage)); } catch (err) { return fail(`record not canonicalizable (${err instanceof Error ? err.message : String(err)})`, expectedSeq); } if (recomputed !== record.hash) { return fail(`hash mismatch — record content does not match its hash (tampered or corrupted)`, expectedSeq); } prevHash = record.hash; recordsChecked += 1; } if (finalHash !== prevHash) { const lastSeq = header.firstSeq + records.length - 1; return fail(`finalHash does not match the last record's hash${where}`, records.length > 0 ? lastSeq : header.firstSeq); } expectedChainHead = finalHash; expectedFirstSeq = header.firstSeq + records.length; } return { valid: true, recordsChecked }; } exports.verifyAuditBundle = verifyAuditBundle; //# sourceMappingURL=audit.js.map