/** * 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 * record-keeping shape EU AI Act Art. 12 asks for (events the system * logged, in order, demonstrably unmodified since capture). * * 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. * * 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 — by default * (`payloadMode: 'bounded'`) record payloads NEVER carry raw runtime * values that can echo PII: * * - tool args → `'[keys: …]'` (top-level key NAMES only) * - tool results → `'[type: …]'` (typeof only) * - 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: …]'` * * Everything else is embedded as the registry payload (sanitized: * strings capped at 256 chars, lists at 32 items, cycles broken) — * those payloads are bounded by construction: identifiers, counts, * enums, decide() evidence (engine-bounded + redaction-aware), * validation issues (paths/TYPES per #9), credential events (no * secrets by contract). * * `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, and * remember the Agent sets NO footprintjs RedactionPolicy by default * (policies you do set redact the emit channel UPSTREAM of this * strategy, so redacted events arrive here already redacted). * * ## 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/observability-providers'; * * 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: } * ``` */ import { CANONICAL_JSON_VERSION } from '../../lib/canonicalJson.js'; import type { ObservabilityStrategy } from '../../strategies/types.js'; /** SHA-256 of "nothing" — the `prevHash` of the first record in a * chain and the `chainHead` of a chain's first segment. */ export declare const AUDIT_ZERO_HASH: string; /** `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). */ export declare const AUDIT_GENESIS_EVENT_TYPE = "audit.genesis"; /** Format identifier carried on every bundle header. */ export declare const AUDIT_BUNDLE_FORMAT = "agentfootprint.audit/1"; /** * One link of the hash chain. * * `hash` = SHA-256 hex over `canonicalJson` of the record WITHOUT the * `hash` field (i.e. `{ seq, timestamp, eventType, payload, meta, * prevHash }` — canonical key order makes field order irrelevant). * Because the preimage is "everything but `hash`", ADDING a field to a * record is detected exactly like mutating one. */ export interface AuditRecord { /** 0-based position in the chain (monotonic across drains). */ readonly seq: number; /** Wall-clock ms of the source event (`meta.wallClockMs`). */ readonly timestamp: number; /** Registry event name verbatim, or {@link AUDIT_GENESIS_EVENT_TYPE}. */ readonly eventType: string; /** Bounded / sanitized event payload (see module PII docs). */ readonly payload: unknown; /** Sanitized event meta (runId, runtimeStageId, paths, indices). */ readonly meta: { readonly runId: string; } & Readonly>; /** `hash` of the previous record ({@link AUDIT_ZERO_HASH} at chain start). */ readonly prevHash: string; /** SHA-256 hex of this record's canonical preimage. */ readonly hash: string; } export interface AuditBundleHeader { readonly format: typeof AUDIT_BUNDLE_FORMAT; readonly hashAlgorithm: 'sha-256'; readonly canonicalization: typeof CANONICAL_JSON_VERSION; /** `prevHash` of `records[0]` — {@link AUDIT_ZERO_HASH} for the first * segment, the previous segment's `finalHash` after a `drain()`. */ readonly chainHead: string; /** `seq` of `records[0]` (continues across drains). */ readonly firstSeq: number; readonly recordCount: number; /** Wall-clock ms when `bundle()` / `drain()` produced this export. */ readonly exportedAt: number; readonly library: { readonly name: 'agentfootprint'; readonly version: string; }; } /** JSON-serializable export of the chain (or one drained segment). */ export interface AuditBundle { readonly header: AuditBundleHeader; readonly records: readonly AuditRecord[]; /** `hash` of the last record (= `chainHead` when `records` is empty). * The next drained segment's `chainHead` equals this value. */ readonly finalHash: string; } export interface AuditVerifyResult { readonly valid: boolean; /** Records whose hashes were recomputed and matched. */ readonly recordsChecked: number; /** `seq` of the first record that fails (or the expected seq at the * failure point, when the stored seq itself was tampered). */ readonly brokenAt?: number; /** Human-readable cause — names the failed check. */ readonly reason?: string; } export interface AuditExportOptions { /** Agent identity recorded in every run's genesis record (service / * agent name as your compliance review knows it). */ readonly agent?: string; /** * `'bounded'` (default) — payloads pass the PII bounding layer (see * module docs). `'verbatim'` — full payloads, JSON-sanitized only. * * @remarks Verbatim bundles carry prompts, tool args/results and * model output. Treat the store as PII-bearing; the Agent applies NO * RedactionPolicy by default. */ readonly payloadMode?: 'bounded' | 'verbatim'; /** Include `stream.token` / `stream.thinking_delta` events (high * volume; content still bounded under `payloadMode: 'bounded'`). * Default `false`. */ readonly includeTokenEvents?: boolean; /** Extra version pins for the genesis record (your app, model * config revision, policy bundle hash, …). */ readonly versions?: Readonly>; } /** The strategy returned by {@link auditExport}. */ export interface AuditExportStrategy extends ObservabilityStrategy { /** Snapshot the retained records WITHOUT draining. Safe mid-run. */ bundle(): AuditBundle; /** Return the records accumulated since the last drain and clear * them from memory. Chain state persists — consecutive drained * segments re-verify end-to-end via `verifyAuditBundle([...])`. */ drain(): AuditBundle; /** Records currently retained (since last drain). */ recordCount(): number; } export declare function auditExport(opts?: AuditExportOptions): AuditExportStrategy; /** * 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 */ export declare function verifyAuditBundle(input: AuditBundle | readonly AuditBundle[]): AuditVerifyResult; //# sourceMappingURL=audit.d.ts.map