courtmitra / packages /domain /src /policies /consent-hash.ts
hetanshwaghela's picture
Day 1–2: monorepo scaffold + ingestion→intake→evidence loop
170b9b6
Raw
History Blame
3.07 kB
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);
}