/** * Structured audit logging for sensitive tool calls. * * design.md §2.5 / §7.3 (Audit row): every `finance:*` (and any future * `physical:*`) tool call must emit a structured audit record carrying the * user `sub`, the tool name, a HASH of the args (never the raw args), the * authorization decision, and the result. Secrets must never appear in the * record — args are hashed precisely so raw values and tokens cannot leak into * CloudWatch. * * The dispatcher (dispatch.ts) is the single emission point. An `AuditLogger` * is injected so production writes structured JSON to stdout (→ CloudWatch in * Lambda) while tests can assert against a capturing logger and never spam * stdout. */ import { createHash } from 'node:crypto'; /** * One structured audit record. Shape is asserted by the audit test * (design.md §7.3): no raw arg values, no secrets — only a hash. */ export interface AuditRecord { /** The user's Google-federated identity (Cognito `sub`). */ sub: string; /** The tool that was invoked. */ tool: string; /** SHA-256 hash (hex) of the canonicalized raw input — never the raw args. */ argsHash: string; /** The authorization decision that gated the call. */ decision: 'allow' | 'deny'; /** Whether the handler completed or threw. */ result: 'ok' | 'error'; /** ISO-8601 emission timestamp. */ ts: string; } /** * Sink for {@link AuditRecord}s. Injected into the dispatcher so the transport * layer never logs directly. */ export interface AuditLogger { log(record: AuditRecord): void; } /** * Default logger: one structured JSON line per record to stdout. In Lambda this * lands in CloudWatch Logs, satisfying design.md §2.5's audit requirement. */ export class ConsoleAuditLogger implements AuditLogger { log(record: AuditRecord): void { // A single JSON line keeps CloudWatch Insights queries simple. console.log(JSON.stringify({ kind: 'audit', ...record })); } } /** No-op logger for tests that don't assert on audit output. */ export class NoopAuditLogger implements AuditLogger { log(_record: AuditRecord): void { // intentionally empty } } /** * Capturing logger for tests: records are pushed to `records` so a test can * assert the audit shape and that no raw arg value / secret appears. */ export class MemoryAuditLogger implements AuditLogger { readonly records: AuditRecord[] = []; log(record: AuditRecord): void { this.records.push(record); } } /** * Hash a tool's raw input into a stable hex digest for the audit record. * * The input is canonicalized (object keys sorted) before hashing so that two * logically-equal inputs produce the same hash. The RAW value never appears in * the output — this is what keeps secrets and PII out of the audit log. */ export function hashArgs(rawInput: unknown): string { const canonical = canonicalize(rawInput); return createHash('sha256').update(canonical).digest('hex'); } /** Deterministic JSON serialization with sorted object keys. */ function canonicalize(value: unknown): string { return JSON.stringify(sortKeys(value)); } function sortKeys(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeys); if (value !== null && typeof value === 'object') { const out: Record = {}; for (const key of Object.keys(value as Record).sort()) { out[key] = sortKeys((value as Record)[key]); } return out; } return value; }