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 +3 -3
- package/lib/{checkpoint-DiznOVpS.js → checkpoint-BWSuhZ5h.js} +1 -1
- package/lib/{cli-UAE968If.js → cli-7yLG89gV.js} +106 -15
- package/lib/index.js +3 -3
- package/lib/{instructions-DulxSqFd.js → instructions-Bg5puOen.js} +1 -1
- package/lib/{receipt-check-s3Aryyz-.js → receipt-check-CD-W1wXs.js} +1 -1
- package/lib/src-D7g4lvaY.js +392 -0
- package/lib/src-d82lSKVQ.js +3 -0
- package/lib/{views-CCwVXpo2.js → views-CIg2ZNG0.js} +153 -1
- package/package.json +2 -2
- package/lib/src-C9L6NBvu.js +0 -3
- package/lib/src-Ciggx9ja.js +0 -242
package/lib/bin.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "./evaluate-wMPkjcIC.js";
|
|
3
|
-
import "./views-
|
|
4
|
-
import { t as main } from "./cli-
|
|
5
|
-
import "./src-
|
|
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,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-
|
|
3
|
-
import {
|
|
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
|
-
...
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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":
|
|
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-
|
|
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-
|
|
4
|
-
import "./src-
|
|
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 };
|
|
@@ -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.
|
|
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",
|
package/lib/src-C9L6NBvu.js
DELETED
|
@@ -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 };
|
package/lib/src-Ciggx9ja.js
DELETED
|
@@ -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 };
|