diff --git a/src/index.ts b/src/index.ts index 9aa8fffe..bbb56918 100644 --- a/src/index.ts +++ b/src/index.ts @@ -77,6 +77,14 @@ export { type VerifiedRejectedPayload, } from "./preference-admission.js"; +export { + orderCheckpointReceipts, + readPrivateCheckpoint, + writePrivateCheckpoint, + type PrivateCheckpointRecord, + type PublicCheckpointReceipt, +} from "./private-checkpoint-cas.js"; + export { compileTraceFoundry, createTraceReplayPlan, importTraceReviews, runTraceReplays } from "./trace-foundry.js"; export { serveTraceFoundry } from "./trace-foundry-server.js"; export { buildRejectionGuidance, classifyRejection, computeRecoveryOverJournals, computeRecoveryRates, loadGuidanceFile, readRolloutJournals, synthesizeMinimalExample } from "./rejection-guidance.js"; diff --git a/src/private-checkpoint-cas.ts b/src/private-checkpoint-cas.ts new file mode 100644 index 00000000..bf4048f9 --- /dev/null +++ b/src/private-checkpoint-cas.ts @@ -0,0 +1,66 @@ +import { createHash } from "node:crypto"; +import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export type PrivateCheckpointRecord = { + checkpointId: string; + restorePath: string; + step: number; + metadata?: Record; +}; + +export type PublicCheckpointReceipt = { + checkpointId: string; + restorePathSha256: string; + step: number; +}; + +function digest(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function privatePath(root: string, checkpointId: string): string { + return join(root, `${checkpointId}.json`); +} + +export function writePrivateCheckpoint( + root: string, + record: PrivateCheckpointRecord, +): PublicCheckpointReceipt { + mkdirSync(root, { recursive: true, mode: 0o700 }); + chmodSync(root, 0o700); + const payload = JSON.stringify(record); + const path = privatePath(root, record.checkpointId); + const fd = openSync(path, "wx", 0o600); + try { + writeFileSync(fd, payload, "utf8"); + } finally { + closeSync(fd); + } + chmodSync(path, 0o600); + return { + checkpointId: record.checkpointId, + restorePathSha256: digest(record.restorePath), + step: record.step, + }; +} + +export function readPrivateCheckpoint( + root: string, + receipt: PublicCheckpointReceipt, +): PrivateCheckpointRecord { + const record = JSON.parse(readFileSync(privatePath(root, receipt.checkpointId), "utf8")) as PrivateCheckpointRecord; + if (record.checkpointId !== receipt.checkpointId || record.step !== receipt.step) { + throw new Error("private checkpoint identity mismatch"); + } + if (digest(record.restorePath) !== receipt.restorePathSha256) { + throw new Error("private checkpoint restore-path hash mismatch"); + } + return record; +} + +export function orderCheckpointReceipts( + receipts: readonly PublicCheckpointReceipt[], +): PublicCheckpointReceipt[] { + return [...receipts].sort((left, right) => left.step - right.step); +} diff --git a/tests/private-checkpoint-cas.test.mjs b/tests/private-checkpoint-cas.test.mjs new file mode 100644 index 00000000..4587d7a9 --- /dev/null +++ b/tests/private-checkpoint-cas.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; + +import { + orderCheckpointReceipts, + readPrivateCheckpoint, + writePrivateCheckpoint, +} from "../dist/private-checkpoint-cas.js"; + +describe("private checkpoint CAS", () => { + it("stores restore preimages privately while receipts expose hashes only", () => { + const root = mkdtempSync(join(tmpdir(), "understudy-checkpoint-cas-")); + try { + const receipt = writePrivateCheckpoint(root, { + checkpointId: "step-14", + restorePath: "tinker://private/opaque/step-14", + step: 14, + }); + assert.equal(Object.hasOwn(receipt, "restorePath"), false); + assert.equal(readPrivateCheckpoint(root, receipt).restorePath, "tinker://private/opaque/step-14"); + assert.equal(statSync(root).mode & 0o777, 0o700); + assert.equal(statSync(join(root, "step-14.json")).mode & 0o777, 0o600); + assert.equal(readFileSync(join(root, "step-14.json"), "utf8").includes(receipt.restorePathSha256), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("supports zero-network ordered ladder resume and detects tampering", () => { + const root = mkdtempSync(join(tmpdir(), "understudy-checkpoint-ladder-")); + try { + const receipts = [1, 14, 25, 41].map((step) => writePrivateCheckpoint(root, { + checkpointId: `step-${step}`, + restorePath: `tinker://private/opaque/step-${step}`, + step, + })); + assert.deepEqual(orderCheckpointReceipts([...receipts].reverse()).map((item) => item.step), [1, 14, 25, 41]); + const tampered = { ...receipts[0], restorePathSha256: "0".repeat(64) }; + assert.throws(() => readPrivateCheckpoint(root, tampered), /hash mismatch/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +});