import { createHash } from "node:crypto"; import { join } from "node:path"; import { appendRecord, readTail } from "../../platform/fs-jsonl.ts"; import { projectStateDir } from "../../platform/paths.ts"; /** * A reviewer asked to trust agent-written code has nothing to read. The harness, meanwhile, holds every input: * the policy hash at session start, whether policy moved out of band, every decision with its rule, every gate * outcome. An attestation is that knowledge written down in a form a rewrite cannot hide * ([/decisions/ad-028.md](/decisions/ad-028.md)). * * invariant: every field is something the harness observed. There is no claim about the code being correct, about * the agent having behaved well, or about a human having approved anything — the harness cannot see any of those, * and an attestation that implied them would be worse than none. * * why: hash-chained rather than signed. A key would mean key management, which is a different problem with its own * failure modes. Chaining detects a rewritten or removed middle record, which is the tampering an on-disk ledger * is actually exposed to, and it needs nothing but `node:crypto`. */ export type AttestationRecord = { schema: "harness.attestation.v1"; ts: string; provider: string; session: string; /** Hash of the policy sources at session start. Two sessions with the same value ran the same rules. */ policyFingerprint: string; /** True when policy changed mid-session without a harness command behind it. */ policyDiverged: boolean; /** Rails that were enabled for this session, by name. */ railsActive: string[]; /** Refusals by the rule that produced them. */ decisionsByRule: Record; gates: { pass: number; fail: number }; /** Hash of the previous record, or the empty-chain marker for the first one. */ prev: string; /** Hash of this record's own content, excluding this field. */ self: string; }; export const CHAIN_ROOT = "genesis"; export function attestationPath(root: string): string { return join(projectStateDir(root), "attestation.jsonl"); } function contentHash(record: Omit): string { // why: a stable key order, so the same content always hashes the same. `JSON.stringify` over an object literal // is insertion-ordered, and an insertion order that varies would make every verification fail. const ordered = [ record.schema, record.ts, record.provider, record.session, record.policyFingerprint, String(record.policyDiverged), record.railsActive.join(","), Object.entries(record.decisionsByRule) .sort((a, b) => a[0].localeCompare(b[0])) .map(([rule, count]) => `${rule}=${count}`) .join(","), `${record.gates.pass}/${record.gates.fail}`, record.prev, ].join(""); return createHash("sha256").update(ordered).digest("hex"); } export function readAttestations(root: string): AttestationRecord[] { return readTail(attestationPath(root), Number.MAX_SAFE_INTEGER); } export function appendAttestation( root: string, body: Omit, ): AttestationRecord { const existing = readAttestations(root); const prev = existing.at(-1)?.self ?? CHAIN_ROOT; const withoutSelf: Omit = { schema: "harness.attestation.v1", ...body, prev, }; const record: AttestationRecord = { ...withoutSelf, self: contentHash(withoutSelf) }; appendRecord(attestationPath(root), record); return record; } export type ChainVerdict = { ok: true; length: number } | { ok: false; brokenAt: number; reason: string }; /** * why: reports the index rather than a bare boolean. "The chain is broken" sends a reviewer to read the whole * file; "record 4 does not match its own content" sends them to one line. * * invariant: an absent file is an empty valid chain, not a broken one. A repository that has never attested has * nothing to have tampered with, and reporting that as tampering would train a reviewer to ignore the check. */ export function verifyChain(records: AttestationRecord[]): ChainVerdict { let expectedPrev = CHAIN_ROOT; for (const [index, record] of records.entries()) { if (record.prev !== expectedPrev) { return { ok: false, brokenAt: index, reason: "previous-hash-mismatch" }; } const { self, ...rest } = record; if (contentHash(rest) !== self) { return { ok: false, brokenAt: index, reason: "content-hash-mismatch" }; } expectedPrev = self; } return { ok: true, length: records.length }; } /** * why: one hash over every policy source, so two sessions carrying the same value provably ran the same rules. The * per-source hashes already exist for the integrity check; this collapses them into something a reviewer can * compare at a glance. */ export function fingerprintOf(sources: readonly { path: string; hash: string }[]): string { const ordered = [...sources] .sort((a, b) => a.path.localeCompare(b.path)) .map((source) => `${source.path}:${source.hash}`) .join("|"); return createHash("sha256").update(ordered).digest("hex").slice(0, 32); }