Spaces:
Running
Running
| import { createHash } from "node:crypto"; | |
| import type { ConsentPreview } from "@courtmitra/contracts"; | |
| /** | |
| * Canonical consent hashing (arch §30.4, CLAUDE.md #5). | |
| * | |
| * The hash binds a recorded consent grant to an EXACT outgoing payload. On | |
| * execution the ActionExecutor recomputes this from the real payload; a | |
| * mismatch blocks the send and forces re-approval. This prevents a mild | |
| * approved message from being silently replaced by a harsher one. | |
| * | |
| * Rules (copied verbatim from the spec): | |
| * 1. Normalize body line endings to "\n". | |
| * 2. Sort all object keys recursively. | |
| * 3. Sort attachments by id. | |
| * 4. EXCLUDE volatile fields (createdAt, UI labels, temp URLs). | |
| * 5. Include body, destination, attachment sha256s, public flag, piiDisclosed, risks. | |
| */ | |
| const HASH_PREFIX = "sha256:"; | |
| /** Fields that are part of the signed payload. Anything else is volatile. */ | |
| type CanonicalConsent = { | |
| actionType: string; | |
| destination: string; | |
| public: boolean; | |
| body: string; | |
| attachments: { id: string; sha256: string; redacted: boolean }[]; | |
| piiDisclosed: string[]; | |
| risks: string[]; | |
| }; | |
| function normalizeBody(body: string): string { | |
| return body.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); | |
| } | |
| /** Recursively sort object keys so JSON.stringify is order-independent. */ | |
| function sortKeysDeep(value: unknown): unknown { | |
| if (Array.isArray(value)) return value.map(sortKeysDeep); | |
| if (value && typeof value === "object") { | |
| const sorted: Record<string, unknown> = {}; | |
| for (const key of Object.keys(value as Record<string, unknown>).sort()) { | |
| sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]); | |
| } | |
| return sorted; | |
| } | |
| return value; | |
| } | |
| /** Build the canonical, volatile-free object that gets hashed. */ | |
| export function canonicalizeConsentPreview(preview: ConsentPreview): CanonicalConsent { | |
| return { | |
| actionType: preview.actionType, | |
| destination: preview.destination, | |
| public: preview.public, | |
| body: normalizeBody(preview.body), | |
| attachments: [...preview.attachments] | |
| .map((a) => ({ id: a.id, sha256: a.sha256, redacted: a.redacted })) | |
| .sort((a, b) => a.id.localeCompare(b.id)), | |
| piiDisclosed: [...preview.piiDisclosed].sort(), | |
| risks: [...preview.risks].sort(), | |
| }; | |
| } | |
| /** Canonical JSON string — stable across key ordering and line endings. */ | |
| export function canonicalConsentJson(preview: ConsentPreview): string { | |
| return JSON.stringify(sortKeysDeep(canonicalizeConsentPreview(preview))); | |
| } | |
| /** Compute the `action_preview_hash` for a consent preview. */ | |
| export function computeConsentHash(preview: ConsentPreview): string { | |
| const json = canonicalConsentJson(preview); | |
| return HASH_PREFIX + createHash("sha256").update(json, "utf8").digest("hex"); | |
| } | |
| /** | |
| * Gate used by the ActionExecutor before any side effect: the hash of the EXACT | |
| * outgoing payload must equal the hash recorded on the consent grant. | |
| */ | |
| export function consentHashMatches(approvedHash: string, outgoing: ConsentPreview): boolean { | |
| return approvedHash === computeConsentHash(outgoing); | |
| } | |