Spaces:
Running
Running
File size: 3,072 Bytes
170b9b6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 | 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);
}
|