prae-cli 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bin.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import "./evaluate-wMPkjcIC.js";
3
- import "./views-CCwVXpo2.js";
4
- import { t as main } from "./cli-UAE968If.js";
5
- import "./src-Ciggx9ja.js";
3
+ import "./views-CIg2ZNG0.js";
4
+ import { t as main } from "./cli-7yLG89gV.js";
5
+ import "./src-D7g4lvaY.js";
6
6
 
7
7
  //#region src/bin.ts
8
8
  main(process.argv.slice(2)).catch((error) => {
@@ -1,4 +1,4 @@
1
- import { d as canonical, i as fingerprint } from "./src-Ciggx9ja.js";
1
+ import { m as canonical, s as fingerprint } from "./src-D7g4lvaY.js";
2
2
  import { createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
3
3
 
4
4
  //#region src/checkpoint.ts
@@ -1,6 +1,6 @@
1
1
  import { t as evaluate } from "./evaluate-wMPkjcIC.js";
2
- import { a as renderPacks, c as renderNav, d as FRAMEWORKS, f as cite, i as renderOverview, l as renderShell, n as renderApprovals, o as CONTROLS, r as renderCompliance, s as esc, t as navFor, u as VERDICT_LABEL$1 } from "./views-CCwVXpo2.js";
3
- import { f as chain, i as fingerprint, l as verifySignature, m as seal, n as readEntries, r as verifyEntries, t as Ledger, u as GENESIS } from "./src-Ciggx9ja.js";
2
+ import { a as renderPacks, c as renderNav, d as FRAMEWORKS, f as cite, i as renderOverview, l as renderShell, n as renderApprovals, o as CONTROLS, r as renderCompliance, s as esc, t as navFor, u as VERDICT_LABEL$1 } from "./views-CIg2ZNG0.js";
3
+ import { _ as seal, f as verifySignature, h as chain, n as readEntries, p as GENESIS, r as verifyEntries, s as fingerprint, t as Ledger } from "./src-D7g4lvaY.js";
4
4
  import { existsSync, readFileSync, readdirSync, statSync, watch } from "node:fs";
5
5
  import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import { dirname, extname, join, normalize, resolve, sep } from "node:path";
@@ -1004,12 +1004,14 @@ function complianceOf(record) {
1004
1004
  const found = Object.entries(FRAMEWORKS).find(([, f]) => control.id.startsWith(`${f.short} · `));
1005
1005
  const code = found === void 0 ? void 0 : control.id.split(" · ")[1];
1006
1006
  const named = found === void 0 || code === void 0 ? void 0 : found[1].controls[code];
1007
+ const standard = control.standard;
1008
+ const name = named?.name ?? control.name;
1007
1009
  return {
1008
- id: code ?? control.id,
1009
- ...named === void 0 ? {} : { name: named.name },
1010
+ id: code ?? (standard === void 0 ? control.id : control.id.split(" · ")[1] ?? control.id),
1011
+ ...name === void 0 ? {} : { name },
1010
1012
  asks: control.asks,
1011
- framework: found === void 0 ? "obligation" : found[0],
1012
- ...found === void 0 ? {} : { frameworkName: found[1].name },
1013
+ framework: found?.[0] ?? standard?.id ?? "obligation",
1014
+ ...found === void 0 ? standard === void 0 ? {} : { frameworkName: standard.name } : { frameworkName: found[1].name },
1013
1015
  ...control.evidence(data)
1014
1016
  };
1015
1017
  }) };
@@ -1209,6 +1211,20 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1209
1211
  Generate an Ed25519 keypair for signing ledger rows. Prints the
1210
1212
  fingerprint a counterparty pins.
1211
1213
 
1214
+ prae keygen --role operator|policy|ledger [--out <dir>]
1215
+ A keypair whose private half is sealed under a passphrase (asked for
1216
+ without echo, or PRAE_KEY_PASSPHRASE). The operator key signs answers
1217
+ to held calls; the policy key signs packs. Pin the public half in the
1218
+ gate (operator.publicKey / policyKey); the agent can read the sealed
1219
+ file and still cannot sign. The ledger key is for the signer that
1220
+ holds it on the harness's behalf (signerFd): prae-signing.*.
1221
+
1222
+ prae sign-pack <pack.json> --key <policy.key.sealed.pem>
1223
+ Sign a pack's exact bytes with the sealed policy key (passphrase asked
1224
+ without echo, or PRAE_KEY_PASSPHRASE), writing <pack.json>.sig. A gate
1225
+ with policyKey pinned governs only by a signed pack whose version
1226
+ rises; raise the version before signing.
1227
+
1212
1228
  prae receipt <ledger.jsonl> <seq> --key <private.pem> [--out <file>]
1213
1229
  Export one decision as a self-contained signed receipt.
1214
1230
 
@@ -1330,7 +1346,7 @@ async function mcp(root, packPath, ledgerPath) {
1330
1346
  const chosen = packPath ?? bundled;
1331
1347
  if (chosen === void 0) return fail("mcp needs --pack <file>; no bundled secret-catcher found");
1332
1348
  const pack = await loadPack(chosen);
1333
- const { Ledger: Ledger$1 } = await import("./src-C9L6NBvu.js");
1349
+ const { Ledger: Ledger$1 } = await import("./src-d82lSKVQ.js");
1334
1350
  const ledger = ledgerPath === void 0 ? void 0 : Ledger$1.open(ledgerPath);
1335
1351
  console.error(`prae mcp: serving ${resolve$1(root)} under ${pack.pack} ${pack.version}` + (ledgerPath === void 0 ? " (no ledger)" : ` · ledger ${ledgerPath}`));
1336
1352
  await serveMcp({
@@ -1382,7 +1398,7 @@ async function explain(path, tool, pairs) {
1382
1398
  * a key that arrived beside it is checked against nothing.
1383
1399
  */
1384
1400
  async function keygen(dir) {
1385
- const { generateKeypair, fingerprint: fingerprint$1 } = await import("./src-C9L6NBvu.js");
1401
+ const { generateKeypair, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
1386
1402
  const { writeFileSync: writeFileSync$1 } = await import("node:fs");
1387
1403
  const keys = generateKeypair();
1388
1404
  const priv = join(dir, "prae-signing.pem");
@@ -1397,8 +1413,73 @@ async function keygen(dir) {
1397
1413
  console.log("receipts, through a channel they trust. Point the plugin at the");
1398
1414
  console.log("private key with `signingKey` to sign every row.");
1399
1415
  }
1416
+ /**
1417
+ * A sealed keypair for a human authority (PRA-106/107): written as
1418
+ * `<role>.pub.pem` and `<role>.key.sealed.pem`. The passphrase is never an
1419
+ * argument — arguments land in process listings and shell history.
1420
+ */
1421
+ async function keygenSealed(role, dir) {
1422
+ if (role !== "operator" && role !== "policy" && role !== "ledger") fail("--role is operator, policy or ledger");
1423
+ const stem = role === "ledger" ? "prae-signing" : role;
1424
+ const { sealedKeypair, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
1425
+ const { writeFileSync: writeFileSync$1 } = await import("node:fs");
1426
+ const keys = sealedKeypair(process.env.PRAE_KEY_PASSPHRASE ?? await askHidden("passphrase: "));
1427
+ const pub = join(dir, `${stem}.pub.pem`);
1428
+ const sealed = join(dir, `${stem}.key.sealed.pem`);
1429
+ writeFileSync$1(sealed, keys.sealedPrivateKey, { mode: 384 });
1430
+ writeFileSync$1(pub, keys.publicKey);
1431
+ console.log(`sealed ${sealed} (useless without the passphrase)`);
1432
+ console.log(`public ${pub}`);
1433
+ console.log(`fingerprint ${fingerprint$1(keys.publicKey)}`);
1434
+ }
1435
+ /** A detached signature over a pack's bytes (PRA-107). */
1436
+ async function signPack(packPath, keyPath) {
1437
+ const { signBytes, unsealPrivateKey, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
1438
+ const { createPublicKey } = await import("node:crypto");
1439
+ const bytes = readFileSync(packPath);
1440
+ const parsed = parsePack(bytes.toString("utf8"));
1441
+ if (!parsed.ok) fail(`${packPath} does not parse; nothing signed`);
1442
+ const passphrase = process.env.PRAE_KEY_PASSPHRASE ?? await askHidden("passphrase: ");
1443
+ let key;
1444
+ try {
1445
+ key = unsealPrivateKey(readFileSync(keyPath, "utf8"), passphrase);
1446
+ } catch {
1447
+ fail("wrong passphrase, or not a sealed key");
1448
+ }
1449
+ await writeFile(`${packPath}.sig`, `${signBytes(bytes, key)}\n`);
1450
+ const pub = createPublicKey(key).export({
1451
+ type: "spki",
1452
+ format: "pem"
1453
+ });
1454
+ console.log(`signed ${packPath}.sig (${parsed.pack.pack} ${parsed.pack.version})`);
1455
+ console.log(`policy key ${fingerprint$1(pub.toString())}`);
1456
+ }
1457
+ /** Read a line from the terminal without echoing it. */
1458
+ function askHidden(prompt) {
1459
+ const input = process.stdin;
1460
+ if (!input.isTTY) fail("no terminal to ask on: set PRAE_KEY_PASSPHRASE");
1461
+ process.stdout.write(prompt);
1462
+ input.setRawMode(true);
1463
+ input.resume();
1464
+ input.setEncoding("utf8");
1465
+ return new Promise((done) => {
1466
+ let typed = "";
1467
+ const onData = (ch) => {
1468
+ if (ch === "\r" || ch === "\n") {
1469
+ input.setRawMode(false);
1470
+ input.pause();
1471
+ input.off("data", onData);
1472
+ process.stdout.write("\n");
1473
+ done(typed);
1474
+ } else if (ch === "") process.exit(130);
1475
+ else if (ch === "") typed = typed.slice(0, -1);
1476
+ else typed += ch;
1477
+ };
1478
+ input.on("data", onData);
1479
+ });
1480
+ }
1400
1481
  async function receipt(path, seq, keyPath, out) {
1401
- const { Ledger: Ledger$1, makeReceipt, fingerprint: fingerprint$1 } = await import("./src-C9L6NBvu.js");
1482
+ const { Ledger: Ledger$1, makeReceipt, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
1402
1483
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1403
1484
  const { createPublicKey } = await import("node:crypto");
1404
1485
  const privateKey = readFileSync$1(keyPath, "utf8");
@@ -1428,7 +1509,7 @@ async function receipt(path, seq, keyPath, out) {
1428
1509
  * file and sets the exit code, so scripts can branch on it.
1429
1510
  */
1430
1511
  async function verifyReceiptCmd(path) {
1431
- const { checkReceipt } = await import("./receipt-check-s3Aryyz-.js");
1512
+ const { checkReceipt } = await import("./receipt-check-CD-W1wXs.js");
1432
1513
  let parsed;
1433
1514
  try {
1434
1515
  parsed = JSON.parse(await readFile(path, "utf8"));
@@ -1499,14 +1580,14 @@ async function verify(path, keyPath, checkpointPath) {
1499
1580
  const last = entries.at(-1);
1500
1581
  console.log(`${result.entries} entries · chain intact${last === void 0 ? "" : ` · seal ${seal(last)}`}`);
1501
1582
  if (checkpointPath !== void 0) {
1502
- const { checkCheckpoint } = await import("./checkpoint-DiznOVpS.js");
1583
+ const { checkCheckpoint } = await import("./checkpoint-BWSuhZ5h.js");
1503
1584
  const publicKey = keyPath === void 0 ? void 0 : readFileSync(keyPath, "utf8");
1504
1585
  const verdict = checkCheckpoint(entries, JSON.parse(readFileSync(checkpointPath, "utf8")), publicKey);
1505
1586
  for (const line of verdict.lines) (verdict.ok ? console.log : console.error)(`checkpoint: ${line}`);
1506
1587
  if (!verdict.ok) process.exitCode = 1;
1507
1588
  }
1508
1589
  if (keyPath !== void 0) {
1509
- const { verifySignature: verifySignature$1, fingerprint: fingerprint$1 } = await import("./src-C9L6NBvu.js");
1590
+ const { verifySignature: verifySignature$1, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
1510
1591
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1511
1592
  const publicKey = readFileSync$1(keyPath, "utf8");
1512
1593
  let signed = 0;
@@ -1682,7 +1763,7 @@ async function consoleOut(path, argv) {
1682
1763
  const [{ ingest }, { extract }, { renderInstructions }] = await Promise.all([
1683
1764
  import("./src-d4c_n6wd.js"),
1684
1765
  import("./src-CBGZVvt7.js"),
1685
- import("./instructions-DulxSqFd.js")
1766
+ import("./instructions-Bg5puOen.js")
1686
1767
  ]);
1687
1768
  const read = ingest(instructionsDir);
1688
1769
  instructionsView = renderInstructions(data, read, extract(read.statements));
@@ -1781,7 +1862,7 @@ async function init(argv) {
1781
1862
  }
1782
1863
  }
1783
1864
  async function checkpoint(path, keyPath, out) {
1784
- const { makeCheckpoint } = await import("./checkpoint-DiznOVpS.js");
1865
+ const { makeCheckpoint } = await import("./checkpoint-BWSuhZ5h.js");
1785
1866
  let ledger;
1786
1867
  try {
1787
1868
  ledger = Ledger.open(path);
@@ -1857,7 +1938,17 @@ async function main(argv) {
1857
1938
  if (root === void 0) fail("mcp needs --root <dir>; serving without a boundary is not a gate");
1858
1939
  return mcp(root, flag(rest, "pack"), flag(rest, "ledger"));
1859
1940
  }
1860
- case "keygen": return keygen(flag(rest, "out") ?? ".");
1941
+ case "keygen": {
1942
+ const role = flag(rest, "role");
1943
+ if (role !== void 0) return keygenSealed(role, flag(rest, "out") ?? ".");
1944
+ return keygen(flag(rest, "out") ?? ".");
1945
+ }
1946
+ case "sign-pack": {
1947
+ const [path] = rest;
1948
+ const key = flag(rest, "key");
1949
+ if (path === void 0 || key === void 0) fail("sign-pack needs <pack.json> --key <policy.key.sealed.pem>");
1950
+ return signPack(path, key);
1951
+ }
1861
1952
  case "receipt": {
1862
1953
  const [path, seq] = rest;
1863
1954
  const key = flag(rest, "key");
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import "./evaluate-wMPkjcIC.js";
2
- import "./views-CCwVXpo2.js";
3
- import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-UAE968If.js";
4
- import "./src-Ciggx9ja.js";
2
+ import "./views-CIg2ZNG0.js";
3
+ import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-7yLG89gV.js";
4
+ import "./src-D7g4lvaY.js";
5
5
 
6
6
  export { applyWrite, defaultProfile, dshHome, main, planWrite, praeRow, profileAt };
@@ -1,4 +1,4 @@
1
- import { c as renderNav, l as renderShell, s as esc, t as navFor } from "./views-CCwVXpo2.js";
1
+ import { c as renderNav, l as renderShell, s as esc, t as navFor } from "./views-CIg2ZNG0.js";
2
2
 
3
3
  //#region src/console/instructions.ts
4
4
  const STYLE = `
@@ -1,4 +1,4 @@
1
- import { c as verifyReceipt, i as fingerprint } from "./src-Ciggx9ja.js";
1
+ import { d as verifyReceipt, s as fingerprint } from "./src-D7g4lvaY.js";
2
2
 
3
3
  //#region src/receipt-check.ts
4
4
  /** Shape we require before we will call it a receipt at all. */
@@ -0,0 +1,392 @@
1
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, scryptSync, sign, verify } from "node:crypto";
4
+
5
+ //#region ../ledger/src/entry.ts
6
+ /** The hash a chain starts from. */
7
+ const GENESIS = "0".repeat(64);
8
+ /**
9
+ * Deterministic serialization: object keys sorted at every depth.
10
+ *
11
+ * A hash over `JSON.stringify(entry)` would depend on key insertion order,
12
+ * which means a re-serialized entry could hash differently while saying
13
+ * exactly the same thing — and a verifier that reports tampering on a
14
+ * faithful copy trains its operators to ignore it.
15
+ */
16
+ function canonical(value) {
17
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
18
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
19
+ return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
20
+ }
21
+ /** Hash one entry's content together with its position and predecessor. */
22
+ function hashEntry(record, seq, prev) {
23
+ return createHash("sha256").update(canonical({
24
+ ...record,
25
+ seq,
26
+ prev
27
+ })).digest("hex");
28
+ }
29
+ /** Chain a record onto a predecessor. */
30
+ function chain(record, seq, prev) {
31
+ return {
32
+ ...record,
33
+ seq,
34
+ prev,
35
+ hash: hashEntry(record, seq, prev)
36
+ };
37
+ }
38
+ /** The short form an auditor quotes from a screenshot. */
39
+ function seal(entry) {
40
+ return `${entry.hash.slice(0, 4)}…${entry.hash.slice(-4)}`;
41
+ }
42
+
43
+ //#endregion
44
+ //#region ../ledger/src/receipt.ts
45
+ function generateKeypair() {
46
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
47
+ return {
48
+ publicKey: publicKey.export({
49
+ type: "spki",
50
+ format: "pem"
51
+ }).toString(),
52
+ privateKey: privateKey.export({
53
+ type: "pkcs8",
54
+ format: "pem"
55
+ }).toString()
56
+ };
57
+ }
58
+ /** Sign a row's seal. The seal already covers the whole canonical record. */
59
+ function signEntry(entry, privateKeyPem) {
60
+ const key = createPrivateKey(privateKeyPem);
61
+ return sign(null, Buffer.from(entry.hash, "hex"), key).toString("base64");
62
+ }
63
+ function verifySignature(entry, signature, publicKeyPem) {
64
+ try {
65
+ const key = createPublicKey(publicKeyPem);
66
+ return verify(null, Buffer.from(entry.hash, "hex"), key, Buffer.from(signature, "base64"));
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+ function makeReceipt(entry, privateKeyPem, publicKeyPem) {
72
+ return {
73
+ receipt: "prae/1",
74
+ entry,
75
+ signature: signEntry(entry, privateKeyPem),
76
+ publicKey: publicKeyPem
77
+ };
78
+ }
79
+ /** Verify a receipt from nothing but the receipt. */
80
+ function verifyReceipt(receipt) {
81
+ if (receipt.receipt !== "prae/1") return {
82
+ ok: false,
83
+ reason: `unknown receipt format ${String(receipt.receipt)}`
84
+ };
85
+ const { seq, prev, hash, sig,...record } = receipt.entry;
86
+ if (hashEntry(record, seq, prev) !== hash) return {
87
+ ok: false,
88
+ reason: "entry contents do not match their seal; the row was altered"
89
+ };
90
+ if (!verifySignature(receipt.entry, receipt.signature, receipt.publicKey)) return {
91
+ ok: false,
92
+ reason: "signature does not verify; not sealed by this key"
93
+ };
94
+ return { ok: true };
95
+ }
96
+ /**
97
+ * A short identifier for a public key, for pinning by eye.
98
+ *
99
+ * A counterparty checks a receipt against a key they already trust; comparing
100
+ * the whole PEM by hand invites skimming, and eight grouped hex bytes do not.
101
+ */
102
+ function fingerprint(publicKeyPem) {
103
+ const der = createPublicKey(publicKeyPem).export({
104
+ type: "spki",
105
+ format: "der"
106
+ });
107
+ return createHash("sha256").update(der).digest("hex").slice(0, 16).replace(/(.{4})/g, "$1 ").trim();
108
+ }
109
+
110
+ //#endregion
111
+ //#region ../ledger/src/authority.ts
112
+ const priv = (k) => typeof k === "string" ? createPrivateKey(k) : k;
113
+ /** How long a signed answer is good for, by default. */
114
+ const ANSWER_TTL_MS = 5 * 6e4;
115
+ /** A detached signature over exact bytes (a policy pack's file). */
116
+ function signBytes(bytes, privateKey) {
117
+ return sign(null, Buffer.from(bytes), priv(privateKey)).toString("base64");
118
+ }
119
+ /**
120
+ * How a private key is sealed at rest: scrypt (N = 2^17, r = 8, p = 1) over
121
+ * the passphrase, AES-256-GCM over the key. The sealed file can sit where an
122
+ * agent can read it; each guess at the passphrase costs ~128 MiB and a
123
+ * noticeable fraction of a second, so a strong passphrase holds against an
124
+ * offline attack. (PKCS#8 as Node writes it uses PBKDF2 at 2,048 rounds —
125
+ * too cheap for a file the agent can copy.)
126
+ */
127
+ const SCRYPT = {
128
+ N: 2 ** 17,
129
+ r: 8,
130
+ p: 1,
131
+ maxmem: 512 * 1024 * 1024
132
+ };
133
+ const MIN_PASSPHRASE = 12;
134
+ const SEALED = "PRAE SEALED KEY";
135
+ function sealPrivateKey(key, passphrase) {
136
+ if (passphrase.length < MIN_PASSPHRASE) throw new Error(`passphrase: ${MIN_PASSPHRASE} characters or more`);
137
+ const salt = randomBytes(16);
138
+ const iv = randomBytes(12);
139
+ const cipher = createCipheriv("aes-256-gcm", scryptSync(passphrase, salt, 32, SCRYPT), iv);
140
+ const der = key.export({
141
+ type: "pkcs8",
142
+ format: "der"
143
+ });
144
+ const data = Buffer.concat([cipher.update(der), cipher.final()]);
145
+ return `-----BEGIN ${SEALED}-----\n${(Buffer.from(JSON.stringify({
146
+ v: 1,
147
+ kdf: "scrypt",
148
+ N: SCRYPT.N,
149
+ r: SCRYPT.r,
150
+ p: SCRYPT.p,
151
+ salt: salt.toString("base64"),
152
+ iv: iv.toString("base64"),
153
+ tag: cipher.getAuthTag().toString("base64"),
154
+ data: data.toString("base64")
155
+ })).toString("base64").match(/.{1,64}/g) ?? []).join("\n")}\n-----END ${SEALED}-----\n`;
156
+ }
157
+ /** A keypair whose private half is sealed under a passphrase. */
158
+ function sealedKeypair(passphrase) {
159
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
160
+ return {
161
+ publicKey: publicKey.export({
162
+ type: "spki",
163
+ format: "pem"
164
+ }).toString(),
165
+ sealedPrivateKey: sealPrivateKey(privateKey, passphrase)
166
+ };
167
+ }
168
+ /** Unseal into memory. Throws on a wrong passphrase or a tampered file. */
169
+ function unsealPrivateKey(sealed, passphrase) {
170
+ const match = sealed.match(/* @__PURE__ */ new RegExp(`-----BEGIN ${SEALED}-----([\\s\\S]+?)-----END ${SEALED}-----`));
171
+ if (match === null) throw new Error("not a PRAE sealed key (an older format?): make a new one with prae keygen --role");
172
+ const box = JSON.parse(Buffer.from((match[1] ?? "").replace(/\s/g, ""), "base64").toString("utf8"));
173
+ if (box.v !== 1 || box.kdf !== "scrypt") throw new Error("unknown sealing");
174
+ const decipher = createDecipheriv("aes-256-gcm", scryptSync(passphrase, Buffer.from(String(box.salt), "base64"), 32, {
175
+ N: Number(box.N),
176
+ r: Number(box.r),
177
+ p: Number(box.p),
178
+ maxmem: SCRYPT.maxmem
179
+ }), Buffer.from(String(box.iv), "base64"));
180
+ decipher.setAuthTag(Buffer.from(String(box.tag), "base64"));
181
+ return createPrivateKey({
182
+ key: Buffer.concat([decipher.update(Buffer.from(String(box.data), "base64")), decipher.final()]),
183
+ format: "der",
184
+ type: "pkcs8"
185
+ });
186
+ }
187
+
188
+ //#endregion
189
+ //#region ../ledger/src/ledger.ts
190
+ /**
191
+ * Read a ledger held as text.
192
+ *
193
+ * Split out from the class because a ledger is not always a file being
194
+ * appended to: a build step renders one it imported, and a reader pastes one
195
+ * in. Those callers need the same parsing and the same verification the CLI
196
+ * runs, and a second implementation of either is how two answers to "does this
197
+ * chain hold" come to exist.
198
+ */
199
+ function readEntries(text) {
200
+ return text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
201
+ }
202
+ /** Re-derive every hash and confirm each entry names its predecessor. */
203
+ function verifyEntries(text) {
204
+ const problems = [];
205
+ let expectedPrev = GENESIS;
206
+ let expectedSeq = 0;
207
+ let count = 0;
208
+ const lines = text.split("\n").filter((line) => line.trim() !== "");
209
+ for (const [index, line] of lines.entries()) {
210
+ const at = index + 1;
211
+ count += 1;
212
+ let entry;
213
+ try {
214
+ entry = JSON.parse(line);
215
+ } catch {
216
+ problems.push({
217
+ line: at,
218
+ message: "not valid JSON"
219
+ });
220
+ return {
221
+ ok: false,
222
+ entries: count,
223
+ problems
224
+ };
225
+ }
226
+ if (entry.seq !== expectedSeq) problems.push({
227
+ line: at,
228
+ message: `expected seq ${expectedSeq}, found ${entry.seq}`
229
+ });
230
+ if (entry.prev !== expectedPrev) problems.push({
231
+ line: at,
232
+ message: "does not name the previous entry; a row was removed or reordered"
233
+ });
234
+ const { seq, prev, hash, sig: _sig,...record } = entry;
235
+ if (hashEntry(record, seq, prev) !== hash) problems.push({
236
+ line: at,
237
+ message: "contents do not match the sealed hash"
238
+ });
239
+ expectedPrev = entry.hash;
240
+ expectedSeq = entry.seq + 1;
241
+ }
242
+ return {
243
+ ok: problems.length === 0,
244
+ entries: count,
245
+ problems
246
+ };
247
+ }
248
+ /** The last complete line of a file, reading only its end when it can. */
249
+ function lastLine(path) {
250
+ const fd = openSync(path, "r");
251
+ try {
252
+ const size = fstatSync(fd).size;
253
+ for (let span = 64 * 1024;; span *= 4) {
254
+ const start = Math.max(0, size - span);
255
+ const buffer = Buffer.alloc(size - start);
256
+ readSync(fd, buffer, 0, buffer.length, start);
257
+ const lines = buffer.toString("utf8").split("\n").filter((line) => line.trim() !== "");
258
+ if (start === 0 || lines.length > 1) return lines.at(-1);
259
+ }
260
+ } finally {
261
+ closeSync(fd);
262
+ }
263
+ }
264
+ /** Where the chain ends now: the next seq and the hash to continue from. */
265
+ function tailOf(path) {
266
+ let tail;
267
+ try {
268
+ tail = lastLine(path);
269
+ } catch (error) {
270
+ if (error.code !== "ENOENT") throw error;
271
+ return {
272
+ seq: 0,
273
+ last: GENESIS
274
+ };
275
+ }
276
+ if (tail === void 0) return {
277
+ seq: 0,
278
+ last: GENESIS
279
+ };
280
+ let entry;
281
+ try {
282
+ entry = JSON.parse(tail);
283
+ } catch {
284
+ throw new Error(`prae ledger: last line of ${path} is not readable; refusing to append onto a chain that cannot be continued`);
285
+ }
286
+ return {
287
+ seq: entry.seq + 1,
288
+ last: entry.hash
289
+ };
290
+ }
291
+ const SLEEP = new Int32Array(new SharedArrayBuffer(4));
292
+ /** A lock older than this was left by a writer that died holding it. */
293
+ const STALE_LOCK_MS = 1e4;
294
+ /** Run `fn` holding an exclusive lock file; synchronous, like the append. */
295
+ function withLock(lock, fn) {
296
+ const deadline = Date.now() + 3e4;
297
+ for (;;) {
298
+ let fd;
299
+ try {
300
+ fd = openSync(lock, "wx");
301
+ } catch (error) {
302
+ if (error.code !== "EEXIST") throw error;
303
+ try {
304
+ if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) {
305
+ unlinkSync(lock);
306
+ continue;
307
+ }
308
+ } catch {}
309
+ if (Date.now() > deadline) throw new Error(`prae ledger: ${lock} has been held for 30 s; refusing to append without it`);
310
+ Atomics.wait(SLEEP, 0, 0, 2);
311
+ continue;
312
+ }
313
+ try {
314
+ return fn();
315
+ } finally {
316
+ closeSync(fd);
317
+ try {
318
+ unlinkSync(lock);
319
+ } catch {}
320
+ }
321
+ }
322
+ }
323
+ var Ledger = class Ledger {
324
+ path;
325
+ constructor(path) {
326
+ this.path = path;
327
+ }
328
+ /**
329
+ * Open a ledger, reading the tail to continue an existing chain.
330
+ *
331
+ * A corrupt or truncated final line is a hard error rather than something to
332
+ * skip past: appending onto a chain whose end we could not read would bury
333
+ * the damage under valid-looking entries.
334
+ */
335
+ /** Sign every appended row with this Ed25519 private key (PEM). */
336
+ signWith;
337
+ /** Or through a signing boundary that holds the key (PRA-108). */
338
+ signer;
339
+ static open(path, options = {}) {
340
+ const ledger = Ledger.openUnsigned(path);
341
+ ledger.signWith = options.signWith;
342
+ ledger.signer = options.signer;
343
+ return ledger;
344
+ }
345
+ static openUnsigned(path) {
346
+ mkdirSync(dirname(path), { recursive: true });
347
+ try {
348
+ writeFileSync(path, "", { flag: "wx" });
349
+ } catch (error) {
350
+ if (error.code !== "EEXIST") throw error;
351
+ }
352
+ tailOf(path);
353
+ return new Ledger(path);
354
+ }
355
+ /** Append one decision and return the entry as written. */
356
+ append(record) {
357
+ return withLock(`${this.path}.lock`, () => {
358
+ const { seq, last } = tailOf(this.path);
359
+ const sealed = chain(record, seq, last);
360
+ const entry = this.signer !== void 0 ? {
361
+ ...sealed,
362
+ sig: this.signer(sealed)
363
+ } : this.signWith === void 0 ? sealed : {
364
+ ...sealed,
365
+ sig: signEntry(sealed, this.signWith)
366
+ };
367
+ appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
368
+ return entry;
369
+ });
370
+ }
371
+ /** Every entry, in order. */
372
+ read() {
373
+ return readEntries(readFileSync(this.path, "utf8"));
374
+ }
375
+ /**
376
+ * Re-derive every hash and confirm each entry names its predecessor.
377
+ *
378
+ * Catches a deleted row, an edited field, and a reordering. Does not catch a
379
+ * wholesale rewrite of the file by someone holding the same code — see the
380
+ * note in `entry.ts`.
381
+ */
382
+ verify() {
383
+ return verifyEntries(readFileSync(this.path, "utf8"));
384
+ }
385
+ };
386
+
387
+ //#endregion
388
+ //#region ../ledger/src/signer.ts
389
+ const nap = new Int32Array(new SharedArrayBuffer(4));
390
+
391
+ //#endregion
392
+ export { seal as _, signBytes as a, generateKeypair as c, verifyReceipt as d, verifySignature as f, hashEntry as g, chain as h, sealedKeypair as i, makeReceipt as l, canonical as m, readEntries as n, unsealPrivateKey as o, GENESIS as p, verifyEntries as r, fingerprint as s, Ledger as t, signEntry as u };
@@ -0,0 +1,3 @@
1
+ import { _ as seal, a as signBytes, c as generateKeypair, d as verifyReceipt, f as verifySignature, g as hashEntry, h as chain, i as sealedKeypair, l as makeReceipt, m as canonical, n as readEntries, o as unsealPrivateKey, p as GENESIS, r as verifyEntries, s as fingerprint, t as Ledger, u as signEntry } from "./src-D7g4lvaY.js";
2
+
3
+ export { GENESIS, Ledger, canonical, chain, fingerprint, generateKeypair, hashEntry, makeReceipt, readEntries, seal, sealedKeypair, signBytes, signEntry, unsealPrivateKey, verifyEntries, verifyReceipt, verifySignature };
@@ -967,6 +967,157 @@ function frameworkRow(frameworkId, control) {
967
967
  };
968
968
  }
969
969
  const rowsOf = (frameworkId) => Object.keys(FRAMEWORKS[frameworkId]?.controls ?? {}).map((control) => frameworkRow(frameworkId, control));
970
+ const ISO_42001 = {
971
+ id: "iso-iec-42001-2023",
972
+ name: "ISO/IEC 42001:2023 — AI management system, Annex A"
973
+ };
974
+ const ISO_27001 = {
975
+ id: "iso-iec-27001-2022",
976
+ name: "ISO/IEC 27001:2022 — Annex A"
977
+ };
978
+ const isEvent = (e) => e.tool.startsWith("prae:") || e.tool === "system-prompt";
979
+ const decisionsOf = (data) => data.entries.filter((e) => !isEvent(e));
980
+ /** Rules in the pack whose `when` names a capability matching `test`. */
981
+ function rulesOn(pack, test) {
982
+ return (pack?.rules ?? []).filter((rule) => {
983
+ const cap = rule.when.capability;
984
+ return (cap === void 0 ? [] : typeof cap === "string" ? [cap] : cap).some(test);
985
+ });
986
+ }
987
+ /** The record itself: recorded, chained, and how much of it is signed. */
988
+ function recorded(data) {
989
+ const n = data.entries.length;
990
+ if (n === 0) return {
991
+ state: "partial",
992
+ note: "Recording is configured but nothing has been logged yet."
993
+ };
994
+ const signed = data.entries.filter((e) => e.sig !== void 0).length;
995
+ if (!data.verification.ok) return {
996
+ state: "partial",
997
+ note: `${n} rows recorded, but the chain does not verify: the log is present and its integrity is not.`
998
+ };
999
+ return {
1000
+ state: "covered",
1001
+ note: `${n} rows recorded, each hash-linked to the one before, chain intact${signed > 0 ? `, ${signed} of ${n} signed` : ", unsigned"} — an edited, removed or reordered row breaks it.`
1002
+ };
1003
+ }
1004
+ /** Refusals and holds, recorded with their clause. */
1005
+ function monitored(data) {
1006
+ const refused = decisionsOf(data).filter((e) => e.verdict === "deny").length;
1007
+ const held = decisionsOf(data).filter((e) => e.verdict === "ask").length;
1008
+ return refused + held > 0 ? {
1009
+ state: "covered",
1010
+ note: `Every call is judged as it happens; this record holds ${refused} refusal${refused === 1 ? "" : "s"} and ${held} hold${held === 1 ? "" : "s"} for a human, each with the clause that produced it.`
1011
+ } : {
1012
+ state: "partial",
1013
+ note: "Every call is judged as it happens, but this record contains no refusal or hold yet, so the monitoring is unproven in practice."
1014
+ };
1015
+ }
1016
+ const ISO_CONTROLS = [
1017
+ {
1018
+ id: "ISO/IEC 42001 · A.2.2",
1019
+ name: "AI policy",
1020
+ standard: ISO_42001,
1021
+ asks: "The organisation has a documented policy for developing or using AI systems.",
1022
+ evidence: (data) => data.pack === void 0 ? {
1023
+ state: "absent",
1024
+ note: "No pack supplied to this view."
1025
+ } : {
1026
+ state: "partial",
1027
+ note: `The executable part of an AI policy: ${data.pack.rules.length} rules declared as data and applied to every tool call. The policy document and its approval are the organisation's.`
1028
+ }
1029
+ },
1030
+ {
1031
+ id: "ISO/IEC 42001 · A.6.2.6",
1032
+ name: "AI system operation and monitoring",
1033
+ standard: ISO_42001,
1034
+ asks: "The AI system is monitored while it operates, and what is found is acted on.",
1035
+ evidence: monitored
1036
+ },
1037
+ {
1038
+ id: "ISO/IEC 42001 · A.6.2.8",
1039
+ name: "Recording of event logs",
1040
+ standard: ISO_42001,
1041
+ asks: "Event logs are kept while the AI system is in use.",
1042
+ evidence: recorded
1043
+ },
1044
+ {
1045
+ id: "ISO/IEC 42001 · A.9.4",
1046
+ name: "Intended use of the AI system",
1047
+ standard: ISO_42001,
1048
+ asks: "The AI system is used only for its intended uses.",
1049
+ evidence: (data) => {
1050
+ if (data.pack === void 0) return {
1051
+ state: "absent",
1052
+ note: "No pack supplied to this view."
1053
+ };
1054
+ const refused = decisionsOf(data).filter((e) => e.verdict === "deny").length;
1055
+ return refused > 0 ? {
1056
+ state: "covered",
1057
+ note: `Intended use is enforced as scope: ${refused} action${refused === 1 ? "" : "s"} outside it refused before execution in this record.`
1058
+ } : {
1059
+ state: "partial",
1060
+ note: "Intended use is enforced as scope by the pack; nothing outside it has been attempted in this record yet."
1061
+ };
1062
+ }
1063
+ },
1064
+ {
1065
+ id: "ISO/IEC 27001 · A.8.3",
1066
+ name: "Information access restriction",
1067
+ standard: ISO_27001,
1068
+ asks: "Access to information is restricted according to the access-control policy.",
1069
+ evidence: (data) => {
1070
+ const rules = rulesOn(data.pack, (c) => c.startsWith("fs:"));
1071
+ if (rules.length === 0) return {
1072
+ state: "absent",
1073
+ note: "No rule in this pack restricts file access."
1074
+ };
1075
+ const refused = decisionsOf(data).filter((e) => e.verdict === "deny" && e.capability?.startsWith("fs:")).length;
1076
+ return refused > 0 ? {
1077
+ state: "covered",
1078
+ note: `${rules.length} file-access rules restrict what the agent may read and write; ${refused} access${refused === 1 ? "" : "es"} refused in this record. Restriction is per agent, not per human.`
1079
+ } : {
1080
+ state: "partial",
1081
+ note: `${rules.length} file-access rules restrict what the agent may read and write; none has refused an access in this record yet.`
1082
+ };
1083
+ }
1084
+ },
1085
+ {
1086
+ id: "ISO/IEC 27001 · A.8.12",
1087
+ name: "Data leakage prevention",
1088
+ standard: ISO_27001,
1089
+ asks: "Leakage of sensitive information is prevented where it is processed or sent.",
1090
+ evidence: (data) => {
1091
+ const rules = [...rulesOn(data.pack, (c) => c === "net:fetch"), ...(data.pack?.rules ?? []).filter((r) => r.when.secrets !== void 0)];
1092
+ if (rules.length === 0) return {
1093
+ state: "absent",
1094
+ note: "No rule in this pack governs egress or credentials."
1095
+ };
1096
+ const stopped = decisionsOf(data).filter((e) => e.verdict === "deny" && (e.capability === "net:fetch" || e.secrets !== void 0 || e.phase === "output")).length;
1097
+ return stopped > 0 ? {
1098
+ state: "covered",
1099
+ note: `${new Set(rules.map((r) => r.id)).size} egress and credential rules; ${stopped} send${stopped === 1 ? "" : "s"} or credential read${stopped === 1 ? "" : "s"} stopped in this record before the data left.`
1100
+ } : {
1101
+ state: "partial",
1102
+ note: `${new Set(rules.map((r) => r.id)).size} egress and credential rules in force; none has stopped anything in this record yet.`
1103
+ };
1104
+ }
1105
+ },
1106
+ {
1107
+ id: "ISO/IEC 27001 · A.8.15",
1108
+ name: "Logging",
1109
+ standard: ISO_27001,
1110
+ asks: "Activities, exceptions and other relevant events are logged, and the logs are protected.",
1111
+ evidence: recorded
1112
+ },
1113
+ {
1114
+ id: "ISO/IEC 27001 · A.8.16",
1115
+ name: "Monitoring activities",
1116
+ standard: ISO_27001,
1117
+ asks: "Systems are monitored for anomalous behaviour, and potential incidents are evaluated.",
1118
+ evidence: monitored
1119
+ }
1120
+ ];
970
1121
  /**
971
1122
  * The obligations first, then the risks framework by framework. Two kinds of
972
1123
  * row in one table on purpose: an auditor reads down one list and finds the
@@ -976,7 +1127,8 @@ const rowsOf = (frameworkId) => Object.keys(FRAMEWORKS[frameworkId]?.controls ??
976
1127
  const CONTROLS = [
977
1128
  ...OBLIGATIONS,
978
1129
  ...rowsOf(OWASP),
979
- ...rowsOf(AGENTIC)
1130
+ ...rowsOf(AGENTIC),
1131
+ ...ISO_CONTROLS
980
1132
  ];
981
1133
 
982
1134
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prae-cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Operator policy for agent harnesses: validate packs, explain a decision, test a pack against security invariants, verify a decision log, and provision a governed DSH profile.",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -40,9 +40,9 @@
40
40
  "tsdown": "^0.15.6",
41
41
  "typescript": "^5.9.2",
42
42
  "vitest": "^3.2.4",
43
+ "@prae/controls": "0.1.0",
43
44
  "@prae/extract": "0.1.0",
44
45
  "@prae/inspect": "0.1.0",
45
- "@prae/controls": "0.1.0",
46
46
  "@prae/instructions": "0.1.0",
47
47
  "@prae/kernel": "0.1.0",
48
48
  "@prae/ledger": "0.1.0",
@@ -1,3 +0,0 @@
1
- import { a as generateKeypair, c as verifyReceipt, d as canonical, f as chain, i as fingerprint, l as verifySignature, m as seal, n as readEntries, o as makeReceipt, p as hashEntry, r as verifyEntries, s as signEntry, t as Ledger, u as GENESIS } from "./src-Ciggx9ja.js";
2
-
3
- export { GENESIS, Ledger, canonical, chain, fingerprint, generateKeypair, hashEntry, makeReceipt, readEntries, seal, signEntry, verifyEntries, verifyReceipt, verifySignature };
@@ -1,242 +0,0 @@
1
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { dirname } from "node:path";
3
- import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
4
-
5
- //#region ../ledger/src/entry.ts
6
- /** The hash a chain starts from. */
7
- const GENESIS = "0".repeat(64);
8
- /**
9
- * Deterministic serialization: object keys sorted at every depth.
10
- *
11
- * A hash over `JSON.stringify(entry)` would depend on key insertion order,
12
- * which means a re-serialized entry could hash differently while saying
13
- * exactly the same thing — and a verifier that reports tampering on a
14
- * faithful copy trains its operators to ignore it.
15
- */
16
- function canonical(value) {
17
- if (value === null || typeof value !== "object") return JSON.stringify(value);
18
- if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
19
- return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
20
- }
21
- /** Hash one entry's content together with its position and predecessor. */
22
- function hashEntry(record, seq, prev) {
23
- return createHash("sha256").update(canonical({
24
- ...record,
25
- seq,
26
- prev
27
- })).digest("hex");
28
- }
29
- /** Chain a record onto a predecessor. */
30
- function chain(record, seq, prev) {
31
- return {
32
- ...record,
33
- seq,
34
- prev,
35
- hash: hashEntry(record, seq, prev)
36
- };
37
- }
38
- /** The short form an auditor quotes from a screenshot. */
39
- function seal(entry) {
40
- return `${entry.hash.slice(0, 4)}…${entry.hash.slice(-4)}`;
41
- }
42
-
43
- //#endregion
44
- //#region ../ledger/src/receipt.ts
45
- function generateKeypair() {
46
- const { publicKey, privateKey } = generateKeyPairSync("ed25519");
47
- return {
48
- publicKey: publicKey.export({
49
- type: "spki",
50
- format: "pem"
51
- }).toString(),
52
- privateKey: privateKey.export({
53
- type: "pkcs8",
54
- format: "pem"
55
- }).toString()
56
- };
57
- }
58
- /** Sign a row's seal. The seal already covers the whole canonical record. */
59
- function signEntry(entry, privateKeyPem) {
60
- const key = createPrivateKey(privateKeyPem);
61
- return sign(null, Buffer.from(entry.hash, "hex"), key).toString("base64");
62
- }
63
- function verifySignature(entry, signature, publicKeyPem) {
64
- try {
65
- const key = createPublicKey(publicKeyPem);
66
- return verify(null, Buffer.from(entry.hash, "hex"), key, Buffer.from(signature, "base64"));
67
- } catch {
68
- return false;
69
- }
70
- }
71
- function makeReceipt(entry, privateKeyPem, publicKeyPem) {
72
- return {
73
- receipt: "prae/1",
74
- entry,
75
- signature: signEntry(entry, privateKeyPem),
76
- publicKey: publicKeyPem
77
- };
78
- }
79
- /** Verify a receipt from nothing but the receipt. */
80
- function verifyReceipt(receipt) {
81
- if (receipt.receipt !== "prae/1") return {
82
- ok: false,
83
- reason: `unknown receipt format ${String(receipt.receipt)}`
84
- };
85
- const { seq, prev, hash, sig,...record } = receipt.entry;
86
- if (hashEntry(record, seq, prev) !== hash) return {
87
- ok: false,
88
- reason: "entry contents do not match their seal; the row was altered"
89
- };
90
- if (!verifySignature(receipt.entry, receipt.signature, receipt.publicKey)) return {
91
- ok: false,
92
- reason: "signature does not verify; not sealed by this key"
93
- };
94
- return { ok: true };
95
- }
96
- /**
97
- * A short identifier for a public key, for pinning by eye.
98
- *
99
- * A counterparty checks a receipt against a key they already trust; comparing
100
- * the whole PEM by hand invites skimming, and eight grouped hex bytes do not.
101
- */
102
- function fingerprint(publicKeyPem) {
103
- const der = createPublicKey(publicKeyPem).export({
104
- type: "spki",
105
- format: "der"
106
- });
107
- return createHash("sha256").update(der).digest("hex").slice(0, 16).replace(/(.{4})/g, "$1 ").trim();
108
- }
109
-
110
- //#endregion
111
- //#region ../ledger/src/ledger.ts
112
- /**
113
- * Read a ledger held as text.
114
- *
115
- * Split out from the class because a ledger is not always a file being
116
- * appended to: a build step renders one it imported, and a reader pastes one
117
- * in. Those callers need the same parsing and the same verification the CLI
118
- * runs, and a second implementation of either is how two answers to "does this
119
- * chain hold" come to exist.
120
- */
121
- function readEntries(text) {
122
- return text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
123
- }
124
- /** Re-derive every hash and confirm each entry names its predecessor. */
125
- function verifyEntries(text) {
126
- const problems = [];
127
- let expectedPrev = GENESIS;
128
- let expectedSeq = 0;
129
- let count = 0;
130
- const lines = text.split("\n").filter((line) => line.trim() !== "");
131
- for (const [index, line] of lines.entries()) {
132
- const at = index + 1;
133
- count += 1;
134
- let entry;
135
- try {
136
- entry = JSON.parse(line);
137
- } catch {
138
- problems.push({
139
- line: at,
140
- message: "not valid JSON"
141
- });
142
- return {
143
- ok: false,
144
- entries: count,
145
- problems
146
- };
147
- }
148
- if (entry.seq !== expectedSeq) problems.push({
149
- line: at,
150
- message: `expected seq ${expectedSeq}, found ${entry.seq}`
151
- });
152
- if (entry.prev !== expectedPrev) problems.push({
153
- line: at,
154
- message: "does not name the previous entry; a row was removed or reordered"
155
- });
156
- const { seq, prev, hash, sig: _sig,...record } = entry;
157
- if (hashEntry(record, seq, prev) !== hash) problems.push({
158
- line: at,
159
- message: "contents do not match the sealed hash"
160
- });
161
- expectedPrev = entry.hash;
162
- expectedSeq = entry.seq + 1;
163
- }
164
- return {
165
- ok: problems.length === 0,
166
- entries: count,
167
- problems
168
- };
169
- }
170
- var Ledger = class Ledger {
171
- path;
172
- seq;
173
- last;
174
- constructor(path, seq, last) {
175
- this.path = path;
176
- this.seq = seq;
177
- this.last = last;
178
- }
179
- /**
180
- * Open a ledger, reading the tail to continue an existing chain.
181
- *
182
- * A corrupt or truncated final line is a hard error rather than something to
183
- * skip past: appending onto a chain whose end we could not read would bury
184
- * the damage under valid-looking entries.
185
- */
186
- /** Sign every appended row with this Ed25519 private key (PEM). */
187
- signWith;
188
- static open(path, options = {}) {
189
- const ledger = Ledger.openUnsigned(path);
190
- ledger.signWith = options.signWith;
191
- return ledger;
192
- }
193
- static openUnsigned(path) {
194
- mkdirSync(dirname(path), { recursive: true });
195
- let text = "";
196
- try {
197
- text = readFileSync(path, "utf8");
198
- } catch (error) {
199
- if (error.code !== "ENOENT") throw error;
200
- writeFileSync(path, "", { flag: "wx" });
201
- return new Ledger(path, 0, GENESIS);
202
- }
203
- const tail = text.split("\n").filter((line) => line.trim() !== "").at(-1);
204
- if (tail === void 0) return new Ledger(path, 0, GENESIS);
205
- let entry;
206
- try {
207
- entry = JSON.parse(tail);
208
- } catch {
209
- throw new Error(`prae ledger: last line of ${path} is not readable; refusing to append onto a chain that cannot be continued`);
210
- }
211
- return new Ledger(path, entry.seq + 1, entry.hash);
212
- }
213
- /** Append one decision and return the entry as written. */
214
- append(record) {
215
- const sealed = chain(record, this.seq, this.last);
216
- const entry = this.signWith === void 0 ? sealed : {
217
- ...sealed,
218
- sig: signEntry(sealed, this.signWith)
219
- };
220
- appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
221
- this.seq += 1;
222
- this.last = entry.hash;
223
- return entry;
224
- }
225
- /** Every entry, in order. */
226
- read() {
227
- return readEntries(readFileSync(this.path, "utf8"));
228
- }
229
- /**
230
- * Re-derive every hash and confirm each entry names its predecessor.
231
- *
232
- * Catches a deleted row, an edited field, and a reordering. Does not catch a
233
- * wholesale rewrite of the file by someone holding the same code — see the
234
- * note in `entry.ts`.
235
- */
236
- verify() {
237
- return verifyEntries(readFileSync(this.path, "utf8"));
238
- }
239
- };
240
-
241
- //#endregion
242
- export { generateKeypair as a, verifyReceipt as c, canonical as d, chain as f, fingerprint as i, verifySignature as l, seal as m, readEntries as n, makeReceipt as o, hashEntry as p, verifyEntries as r, signEntry as s, Ledger as t, GENESIS as u };