prae-cli 0.2.0 → 0.3.0
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 -2
- package/lib/{cli-6hDu8-nF.js → cli-DZ0FPdSr.js} +92 -173
- package/lib/index.js +3 -2
- package/lib/{instructions-ijuj4IP1.js → instructions-C6jwClPO.js} +1 -1
- package/lib/src-Cinsv7Wq.js +3 -0
- package/lib/src-DGWTvmRZ.js +225 -0
- package/package.json +4 -4
- /package/lib/{src-BrZXeEez.js → src-2pGbYeGU.js} +0 -0
- /package/lib/{src-Dd2_8m2g.js → src-CuHszf3f.js} +0 -0
- /package/lib/{views-BU0sOciR.js → views-CLLfJPWB.js} +0 -0
package/lib/bin.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as main } from "./cli-
|
|
3
|
-
import "./
|
|
2
|
+
import { t as main } from "./cli-DZ0FPdSr.js";
|
|
3
|
+
import "./src-DGWTvmRZ.js";
|
|
4
|
+
import "./views-CLLfJPWB.js";
|
|
4
5
|
|
|
5
6
|
//#region src/bin.ts
|
|
6
7
|
main(process.argv.slice(2)).catch((error) => {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { p as seal, t as Ledger } from "./src-DGWTvmRZ.js";
|
|
2
|
+
import { a as renderPacks, c as renderShell, i as renderOverview, l as VERDICT_LABEL$1, n as renderApprovals, o as esc, r as renderCompliance, s as renderNav, t as navFor } from "./views-CLLfJPWB.js";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
4
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import {
|
|
5
|
-
import { createHash } from "node:crypto";
|
|
5
|
+
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { composePatchFile, preflightOps, revisionOf, takeSnapshot } from "dsh-overlay-check";
|
|
8
8
|
import { createServer } from "node:http";
|
|
@@ -774,165 +774,6 @@ function parsePack(source) {
|
|
|
774
774
|
};
|
|
775
775
|
}
|
|
776
776
|
|
|
777
|
-
//#endregion
|
|
778
|
-
//#region ../ledger/src/entry.ts
|
|
779
|
-
/** The hash a chain starts from. */
|
|
780
|
-
const GENESIS = "0".repeat(64);
|
|
781
|
-
/**
|
|
782
|
-
* Deterministic serialization: object keys sorted at every depth.
|
|
783
|
-
*
|
|
784
|
-
* A hash over `JSON.stringify(entry)` would depend on key insertion order,
|
|
785
|
-
* which means a re-serialized entry could hash differently while saying
|
|
786
|
-
* exactly the same thing — and a verifier that reports tampering on a
|
|
787
|
-
* faithful copy trains its operators to ignore it.
|
|
788
|
-
*/
|
|
789
|
-
function canonical(value) {
|
|
790
|
-
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
791
|
-
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
792
|
-
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(",")}}`;
|
|
793
|
-
}
|
|
794
|
-
/** Hash one entry's content together with its position and predecessor. */
|
|
795
|
-
function hashEntry(record, seq, prev) {
|
|
796
|
-
return createHash("sha256").update(canonical({
|
|
797
|
-
...record,
|
|
798
|
-
seq,
|
|
799
|
-
prev
|
|
800
|
-
})).digest("hex");
|
|
801
|
-
}
|
|
802
|
-
/** Chain a record onto a predecessor. */
|
|
803
|
-
function chain(record, seq, prev) {
|
|
804
|
-
return {
|
|
805
|
-
...record,
|
|
806
|
-
seq,
|
|
807
|
-
prev,
|
|
808
|
-
hash: hashEntry(record, seq, prev)
|
|
809
|
-
};
|
|
810
|
-
}
|
|
811
|
-
/** The short form an auditor quotes from a screenshot. */
|
|
812
|
-
function seal(entry) {
|
|
813
|
-
return `${entry.hash.slice(0, 4)}…${entry.hash.slice(-4)}`;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
//#endregion
|
|
817
|
-
//#region ../ledger/src/ledger.ts
|
|
818
|
-
/**
|
|
819
|
-
* Read a ledger held as text.
|
|
820
|
-
*
|
|
821
|
-
* Split out from the class because a ledger is not always a file being
|
|
822
|
-
* appended to: a build step renders one it imported, and a reader pastes one
|
|
823
|
-
* in. Those callers need the same parsing and the same verification the CLI
|
|
824
|
-
* runs, and a second implementation of either is how two answers to "does this
|
|
825
|
-
* chain hold" come to exist.
|
|
826
|
-
*/
|
|
827
|
-
function readEntries(text) {
|
|
828
|
-
return text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
|
|
829
|
-
}
|
|
830
|
-
/** Re-derive every hash and confirm each entry names its predecessor. */
|
|
831
|
-
function verifyEntries(text) {
|
|
832
|
-
const problems = [];
|
|
833
|
-
let expectedPrev = GENESIS;
|
|
834
|
-
let expectedSeq = 0;
|
|
835
|
-
let count = 0;
|
|
836
|
-
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
|
837
|
-
for (const [index, line] of lines.entries()) {
|
|
838
|
-
const at = index + 1;
|
|
839
|
-
count += 1;
|
|
840
|
-
let entry;
|
|
841
|
-
try {
|
|
842
|
-
entry = JSON.parse(line);
|
|
843
|
-
} catch {
|
|
844
|
-
problems.push({
|
|
845
|
-
line: at,
|
|
846
|
-
message: "not valid JSON"
|
|
847
|
-
});
|
|
848
|
-
return {
|
|
849
|
-
ok: false,
|
|
850
|
-
entries: count,
|
|
851
|
-
problems
|
|
852
|
-
};
|
|
853
|
-
}
|
|
854
|
-
if (entry.seq !== expectedSeq) problems.push({
|
|
855
|
-
line: at,
|
|
856
|
-
message: `expected seq ${expectedSeq}, found ${entry.seq}`
|
|
857
|
-
});
|
|
858
|
-
if (entry.prev !== expectedPrev) problems.push({
|
|
859
|
-
line: at,
|
|
860
|
-
message: "does not name the previous entry; a row was removed or reordered"
|
|
861
|
-
});
|
|
862
|
-
const { seq, prev, hash,...record } = entry;
|
|
863
|
-
if (hashEntry(record, seq, prev) !== hash) problems.push({
|
|
864
|
-
line: at,
|
|
865
|
-
message: "contents do not match the sealed hash"
|
|
866
|
-
});
|
|
867
|
-
expectedPrev = entry.hash;
|
|
868
|
-
expectedSeq = entry.seq + 1;
|
|
869
|
-
}
|
|
870
|
-
return {
|
|
871
|
-
ok: problems.length === 0,
|
|
872
|
-
entries: count,
|
|
873
|
-
problems
|
|
874
|
-
};
|
|
875
|
-
}
|
|
876
|
-
var Ledger = class Ledger {
|
|
877
|
-
path;
|
|
878
|
-
seq;
|
|
879
|
-
last;
|
|
880
|
-
constructor(path, seq, last) {
|
|
881
|
-
this.path = path;
|
|
882
|
-
this.seq = seq;
|
|
883
|
-
this.last = last;
|
|
884
|
-
}
|
|
885
|
-
/**
|
|
886
|
-
* Open a ledger, reading the tail to continue an existing chain.
|
|
887
|
-
*
|
|
888
|
-
* A corrupt or truncated final line is a hard error rather than something to
|
|
889
|
-
* skip past: appending onto a chain whose end we could not read would bury
|
|
890
|
-
* the damage under valid-looking entries.
|
|
891
|
-
*/
|
|
892
|
-
static open(path) {
|
|
893
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
894
|
-
let text = "";
|
|
895
|
-
try {
|
|
896
|
-
text = readFileSync(path, "utf8");
|
|
897
|
-
} catch (error) {
|
|
898
|
-
if (error.code !== "ENOENT") throw error;
|
|
899
|
-
writeFileSync(path, "", { flag: "wx" });
|
|
900
|
-
return new Ledger(path, 0, GENESIS);
|
|
901
|
-
}
|
|
902
|
-
const tail = text.split("\n").filter((line) => line.trim() !== "").at(-1);
|
|
903
|
-
if (tail === void 0) return new Ledger(path, 0, GENESIS);
|
|
904
|
-
let entry;
|
|
905
|
-
try {
|
|
906
|
-
entry = JSON.parse(tail);
|
|
907
|
-
} catch {
|
|
908
|
-
throw new Error(`prae ledger: last line of ${path} is not readable; refusing to append onto a chain that cannot be continued`);
|
|
909
|
-
}
|
|
910
|
-
return new Ledger(path, entry.seq + 1, entry.hash);
|
|
911
|
-
}
|
|
912
|
-
/** Append one decision and return the entry as written. */
|
|
913
|
-
append(record) {
|
|
914
|
-
const entry = chain(record, this.seq, this.last);
|
|
915
|
-
appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
|
|
916
|
-
this.seq += 1;
|
|
917
|
-
this.last = entry.hash;
|
|
918
|
-
return entry;
|
|
919
|
-
}
|
|
920
|
-
/** Every entry, in order. */
|
|
921
|
-
read() {
|
|
922
|
-
return readEntries(readFileSync(this.path, "utf8"));
|
|
923
|
-
}
|
|
924
|
-
/**
|
|
925
|
-
* Re-derive every hash and confirm each entry names its predecessor.
|
|
926
|
-
*
|
|
927
|
-
* Catches a deleted row, an edited field, and a reordering. Does not catch a
|
|
928
|
-
* wholesale rewrite of the file by someone holding the same code — see the
|
|
929
|
-
* note in `entry.ts`.
|
|
930
|
-
*/
|
|
931
|
-
verify() {
|
|
932
|
-
return verifyEntries(readFileSync(this.path, "utf8"));
|
|
933
|
-
}
|
|
934
|
-
};
|
|
935
|
-
|
|
936
777
|
//#endregion
|
|
937
778
|
//#region src/console/ledger.ts
|
|
938
779
|
function ago(iso, now) {
|
|
@@ -1381,8 +1222,16 @@ const USAGE = `prae — operator policy for agent harnesses
|
|
|
1381
1222
|
prae explain <pack.json> <tool> [key=value ...]
|
|
1382
1223
|
Judge one call against the pack and print the trace. Nothing is executed.
|
|
1383
1224
|
|
|
1384
|
-
prae verify <ledger.jsonl>
|
|
1385
|
-
Re-derive every hash in a decision log and report any break.
|
|
1225
|
+
prae verify <ledger.jsonl> [--key <public.pem>]
|
|
1226
|
+
Re-derive every hash in a decision log and report any break. With
|
|
1227
|
+
--key, also verify each row's signature against that pinned key.
|
|
1228
|
+
|
|
1229
|
+
prae keygen [--out <dir>]
|
|
1230
|
+
Generate an Ed25519 keypair for signing ledger rows. Prints the
|
|
1231
|
+
fingerprint a counterparty pins.
|
|
1232
|
+
|
|
1233
|
+
prae receipt <ledger.jsonl> <seq> --key <private.pem> [--out <file>]
|
|
1234
|
+
Export one decision as a self-contained signed receipt.
|
|
1386
1235
|
|
|
1387
1236
|
prae report <ledger.jsonl> [--out <file.html>]
|
|
1388
1237
|
Render the decision record as a printable evidence report.
|
|
@@ -1472,7 +1321,52 @@ async function explain(path, tool, pairs) {
|
|
|
1472
1321
|
console.log(`\n${pack.pack} ${pack.version} · ${decision.evaluated} rules considered`);
|
|
1473
1322
|
if (capability === void 0) console.log(`\nnote: no capability claims \`${tool}\`, so capability rules did not apply`);
|
|
1474
1323
|
}
|
|
1475
|
-
|
|
1324
|
+
/**
|
|
1325
|
+
* Keys for signing rows.
|
|
1326
|
+
*
|
|
1327
|
+
* Written 0600 and never printed. What is printed is the fingerprint — the
|
|
1328
|
+
* thing a counterparty pins out-of-band, because a signature checked against
|
|
1329
|
+
* a key that arrived beside it is checked against nothing.
|
|
1330
|
+
*/
|
|
1331
|
+
async function keygen(dir) {
|
|
1332
|
+
const { generateKeypair, fingerprint } = await import("./src-Cinsv7Wq.js");
|
|
1333
|
+
const { writeFileSync: writeFileSync$1 } = await import("node:fs");
|
|
1334
|
+
const keys = generateKeypair();
|
|
1335
|
+
const priv = join(dir, "prae-signing.pem");
|
|
1336
|
+
const pub = join(dir, "prae-signing.pub.pem");
|
|
1337
|
+
writeFileSync$1(priv, keys.privateKey, { mode: 384 });
|
|
1338
|
+
writeFileSync$1(pub, keys.publicKey);
|
|
1339
|
+
console.log(`private ${priv} (0600 — this signs; guard it)`);
|
|
1340
|
+
console.log(`public ${pub}`);
|
|
1341
|
+
console.log(`fingerprint ${fingerprint(keys.publicKey)}`);
|
|
1342
|
+
console.log("");
|
|
1343
|
+
console.log("Give the PUBLIC key and fingerprint to whoever will verify");
|
|
1344
|
+
console.log("receipts, through a channel they trust. Point the plugin at the");
|
|
1345
|
+
console.log("private key with `signingKey` to sign every row.");
|
|
1346
|
+
}
|
|
1347
|
+
async function receipt(path, seq, keyPath, out) {
|
|
1348
|
+
const { Ledger: Ledger$1, makeReceipt, fingerprint } = await import("./src-Cinsv7Wq.js");
|
|
1349
|
+
const { readFileSync: readFileSync$1 } = await import("node:fs");
|
|
1350
|
+
const { createPublicKey } = await import("node:crypto");
|
|
1351
|
+
const privateKey = readFileSync$1(keyPath, "utf8");
|
|
1352
|
+
const publicKey = createPublicKey(privateKey).export({
|
|
1353
|
+
type: "spki",
|
|
1354
|
+
format: "pem"
|
|
1355
|
+
}).toString();
|
|
1356
|
+
const entry = Ledger$1.open(path).read().find((row$1) => row$1.seq === seq);
|
|
1357
|
+
if (entry === void 0) return fail(`no row with seq ${seq} in ${path}`);
|
|
1358
|
+
const packaged = makeReceipt(entry, privateKey, publicKey);
|
|
1359
|
+
const text = `${JSON.stringify(packaged, null, 2)}\n`;
|
|
1360
|
+
if (out === void 0) console.log(text);
|
|
1361
|
+
else {
|
|
1362
|
+
await writeFile(out, text);
|
|
1363
|
+
console.log(`${out} · seq ${seq} · ${entry.verdict} ${entry.tool} ${entry.subject ?? ""}`);
|
|
1364
|
+
}
|
|
1365
|
+
console.log(`fingerprint ${fingerprint(publicKey)}`);
|
|
1366
|
+
console.log("The verifier compares that fingerprint against the one they");
|
|
1367
|
+
console.log("pinned — a receipt is only as good as the key they trust.");
|
|
1368
|
+
}
|
|
1369
|
+
async function verify(path, keyPath) {
|
|
1476
1370
|
let ledger;
|
|
1477
1371
|
try {
|
|
1478
1372
|
ledger = Ledger.open(path);
|
|
@@ -1481,8 +1375,26 @@ async function verify(path) {
|
|
|
1481
1375
|
}
|
|
1482
1376
|
const result = ledger.verify();
|
|
1483
1377
|
if (result.ok) {
|
|
1484
|
-
const
|
|
1378
|
+
const entries = ledger.read();
|
|
1379
|
+
const last = entries.at(-1);
|
|
1485
1380
|
console.log(`${result.entries} entries · chain intact${last === void 0 ? "" : ` · seal ${seal(last)}`}`);
|
|
1381
|
+
if (keyPath !== void 0) {
|
|
1382
|
+
const { verifySignature, fingerprint } = await import("./src-Cinsv7Wq.js");
|
|
1383
|
+
const { readFileSync: readFileSync$1 } = await import("node:fs");
|
|
1384
|
+
const publicKey = readFileSync$1(keyPath, "utf8");
|
|
1385
|
+
let signed = 0;
|
|
1386
|
+
const bad = [];
|
|
1387
|
+
for (const entry of entries) {
|
|
1388
|
+
if (entry.sig === void 0) continue;
|
|
1389
|
+
signed += 1;
|
|
1390
|
+
if (!verifySignature(entry, entry.sig, publicKey)) bad.push(entry.seq);
|
|
1391
|
+
}
|
|
1392
|
+
if (bad.length > 0) {
|
|
1393
|
+
console.error(`signatures: ${bad.length} of ${signed} FAIL against ${fingerprint(publicKey)} — rows ${bad.join(", ")}`);
|
|
1394
|
+
process.exitCode = 1;
|
|
1395
|
+
} else if (signed === 0) console.log("signatures: none present — this ledger is not signed");
|
|
1396
|
+
else console.log(`signatures: ${signed}/${entries.length} rows verify against ${fingerprint(publicKey)}`);
|
|
1397
|
+
}
|
|
1486
1398
|
console.log("tamper-evident: a removed, edited, or reordered row breaks this chain.");
|
|
1487
1399
|
console.log("It does not prove against a rewrite of the whole file by someone holding this code.");
|
|
1488
1400
|
return;
|
|
@@ -1543,7 +1455,7 @@ async function report(path, out) {
|
|
|
1543
1455
|
* that step is a proposal a human approves, and it starts from this list.
|
|
1544
1456
|
*/
|
|
1545
1457
|
async function instructions(from, asJson) {
|
|
1546
|
-
const { ingest } = await import("./src-
|
|
1458
|
+
const { ingest } = await import("./src-2pGbYeGU.js");
|
|
1547
1459
|
const result = ingest(from);
|
|
1548
1460
|
if (asJson) {
|
|
1549
1461
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -1579,7 +1491,7 @@ async function instructions(from, asJson) {
|
|
|
1579
1491
|
* on purpose rather than something that happened to their policy.
|
|
1580
1492
|
*/
|
|
1581
1493
|
async function propose(from, options) {
|
|
1582
|
-
const [{ ingest }, { extract }] = await Promise.all([import("./src-
|
|
1494
|
+
const [{ ingest }, { extract }] = await Promise.all([import("./src-2pGbYeGU.js"), import("./src-CuHszf3f.js")]);
|
|
1583
1495
|
const read = ingest(from);
|
|
1584
1496
|
if (read.files.length === 0) {
|
|
1585
1497
|
console.log(`No instruction files found at or above ${from}.`);
|
|
@@ -1639,9 +1551,9 @@ async function consoleOut(path, argv) {
|
|
|
1639
1551
|
const instructionsDir = flag(argv, "instructions");
|
|
1640
1552
|
if (instructionsDir !== void 0) {
|
|
1641
1553
|
const [{ ingest }, { extract }, { renderInstructions }] = await Promise.all([
|
|
1642
|
-
import("./src-
|
|
1643
|
-
import("./src-
|
|
1644
|
-
import("./instructions-
|
|
1554
|
+
import("./src-2pGbYeGU.js"),
|
|
1555
|
+
import("./src-CuHszf3f.js"),
|
|
1556
|
+
import("./instructions-C6jwClPO.js")
|
|
1645
1557
|
]);
|
|
1646
1558
|
const read = ingest(instructionsDir);
|
|
1647
1559
|
instructionsView = renderInstructions(data, read, extract(read.statements));
|
|
@@ -1740,10 +1652,17 @@ async function main(argv) {
|
|
|
1740
1652
|
if (path === void 0 || tool === void 0) fail("explain needs a pack file and a tool name");
|
|
1741
1653
|
return explain(path, tool, pairs);
|
|
1742
1654
|
}
|
|
1655
|
+
case "keygen": return keygen(flag(rest, "out") ?? ".");
|
|
1656
|
+
case "receipt": {
|
|
1657
|
+
const [path, seq] = rest;
|
|
1658
|
+
const key = flag(rest, "key");
|
|
1659
|
+
if (path === void 0 || seq === void 0 || key === void 0) fail("receipt needs <ledger.jsonl> <seq> --key <private.pem>");
|
|
1660
|
+
return receipt(path, Number(seq), key, flag(rest, "out"));
|
|
1661
|
+
}
|
|
1743
1662
|
case "verify": {
|
|
1744
1663
|
const path = rest[0];
|
|
1745
1664
|
if (path === void 0) fail("verify needs a ledger file");
|
|
1746
|
-
return verify(path);
|
|
1665
|
+
return verify(path, flag(rest, "key"));
|
|
1747
1666
|
}
|
|
1748
1667
|
case "report": {
|
|
1749
1668
|
const path = rest[0];
|
package/lib/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-
|
|
2
|
-
import "./
|
|
1
|
+
import { a as planWrite, i as dshHome, n as applyWrite, o as praeRow, r as defaultProfile, s as profileAt, t as main } from "./cli-DZ0FPdSr.js";
|
|
2
|
+
import "./src-DGWTvmRZ.js";
|
|
3
|
+
import "./views-CLLfJPWB.js";
|
|
3
4
|
|
|
4
5
|
export { applyWrite, defaultProfile, dshHome, main, planWrite, praeRow, profileAt };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { a as generateKeypair, c as verifySignature, d as chain, f as hashEntry, i as fingerprint, l as GENESIS, n as readEntries, o as makeReceipt, p as seal, r as verifyEntries, s as signEntry, t as Ledger, u as canonical } from "./src-DGWTvmRZ.js";
|
|
2
|
+
|
|
3
|
+
export { GENESIS, Ledger, canonical, chain, fingerprint, generateKeypair, hashEntry, makeReceipt, readEntries, seal, signEntry, verifyEntries, verifySignature };
|
|
@@ -0,0 +1,225 @@
|
|
|
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
|
+
/**
|
|
80
|
+
* A short identifier for a public key, for pinning by eye.
|
|
81
|
+
*
|
|
82
|
+
* A counterparty checks a receipt against a key they already trust; comparing
|
|
83
|
+
* the whole PEM by hand invites skimming, and eight grouped hex bytes do not.
|
|
84
|
+
*/
|
|
85
|
+
function fingerprint(publicKeyPem) {
|
|
86
|
+
const der = createPublicKey(publicKeyPem).export({
|
|
87
|
+
type: "spki",
|
|
88
|
+
format: "der"
|
|
89
|
+
});
|
|
90
|
+
return createHash("sha256").update(der).digest("hex").slice(0, 16).replace(/(.{4})/g, "$1 ").trim();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region ../ledger/src/ledger.ts
|
|
95
|
+
/**
|
|
96
|
+
* Read a ledger held as text.
|
|
97
|
+
*
|
|
98
|
+
* Split out from the class because a ledger is not always a file being
|
|
99
|
+
* appended to: a build step renders one it imported, and a reader pastes one
|
|
100
|
+
* in. Those callers need the same parsing and the same verification the CLI
|
|
101
|
+
* runs, and a second implementation of either is how two answers to "does this
|
|
102
|
+
* chain hold" come to exist.
|
|
103
|
+
*/
|
|
104
|
+
function readEntries(text) {
|
|
105
|
+
return text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
|
|
106
|
+
}
|
|
107
|
+
/** Re-derive every hash and confirm each entry names its predecessor. */
|
|
108
|
+
function verifyEntries(text) {
|
|
109
|
+
const problems = [];
|
|
110
|
+
let expectedPrev = GENESIS;
|
|
111
|
+
let expectedSeq = 0;
|
|
112
|
+
let count = 0;
|
|
113
|
+
const lines = text.split("\n").filter((line) => line.trim() !== "");
|
|
114
|
+
for (const [index, line] of lines.entries()) {
|
|
115
|
+
const at = index + 1;
|
|
116
|
+
count += 1;
|
|
117
|
+
let entry;
|
|
118
|
+
try {
|
|
119
|
+
entry = JSON.parse(line);
|
|
120
|
+
} catch {
|
|
121
|
+
problems.push({
|
|
122
|
+
line: at,
|
|
123
|
+
message: "not valid JSON"
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
entries: count,
|
|
128
|
+
problems
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (entry.seq !== expectedSeq) problems.push({
|
|
132
|
+
line: at,
|
|
133
|
+
message: `expected seq ${expectedSeq}, found ${entry.seq}`
|
|
134
|
+
});
|
|
135
|
+
if (entry.prev !== expectedPrev) problems.push({
|
|
136
|
+
line: at,
|
|
137
|
+
message: "does not name the previous entry; a row was removed or reordered"
|
|
138
|
+
});
|
|
139
|
+
const { seq, prev, hash, sig: _sig,...record } = entry;
|
|
140
|
+
if (hashEntry(record, seq, prev) !== hash) problems.push({
|
|
141
|
+
line: at,
|
|
142
|
+
message: "contents do not match the sealed hash"
|
|
143
|
+
});
|
|
144
|
+
expectedPrev = entry.hash;
|
|
145
|
+
expectedSeq = entry.seq + 1;
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
ok: problems.length === 0,
|
|
149
|
+
entries: count,
|
|
150
|
+
problems
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
var Ledger = class Ledger {
|
|
154
|
+
path;
|
|
155
|
+
seq;
|
|
156
|
+
last;
|
|
157
|
+
constructor(path, seq, last) {
|
|
158
|
+
this.path = path;
|
|
159
|
+
this.seq = seq;
|
|
160
|
+
this.last = last;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Open a ledger, reading the tail to continue an existing chain.
|
|
164
|
+
*
|
|
165
|
+
* A corrupt or truncated final line is a hard error rather than something to
|
|
166
|
+
* skip past: appending onto a chain whose end we could not read would bury
|
|
167
|
+
* the damage under valid-looking entries.
|
|
168
|
+
*/
|
|
169
|
+
/** Sign every appended row with this Ed25519 private key (PEM). */
|
|
170
|
+
signWith;
|
|
171
|
+
static open(path, options = {}) {
|
|
172
|
+
const ledger = Ledger.openUnsigned(path);
|
|
173
|
+
ledger.signWith = options.signWith;
|
|
174
|
+
return ledger;
|
|
175
|
+
}
|
|
176
|
+
static openUnsigned(path) {
|
|
177
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
178
|
+
let text = "";
|
|
179
|
+
try {
|
|
180
|
+
text = readFileSync(path, "utf8");
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error.code !== "ENOENT") throw error;
|
|
183
|
+
writeFileSync(path, "", { flag: "wx" });
|
|
184
|
+
return new Ledger(path, 0, GENESIS);
|
|
185
|
+
}
|
|
186
|
+
const tail = text.split("\n").filter((line) => line.trim() !== "").at(-1);
|
|
187
|
+
if (tail === void 0) return new Ledger(path, 0, GENESIS);
|
|
188
|
+
let entry;
|
|
189
|
+
try {
|
|
190
|
+
entry = JSON.parse(tail);
|
|
191
|
+
} catch {
|
|
192
|
+
throw new Error(`prae ledger: last line of ${path} is not readable; refusing to append onto a chain that cannot be continued`);
|
|
193
|
+
}
|
|
194
|
+
return new Ledger(path, entry.seq + 1, entry.hash);
|
|
195
|
+
}
|
|
196
|
+
/** Append one decision and return the entry as written. */
|
|
197
|
+
append(record) {
|
|
198
|
+
const sealed = chain(record, this.seq, this.last);
|
|
199
|
+
const entry = this.signWith === void 0 ? sealed : {
|
|
200
|
+
...sealed,
|
|
201
|
+
sig: signEntry(sealed, this.signWith)
|
|
202
|
+
};
|
|
203
|
+
appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
|
|
204
|
+
this.seq += 1;
|
|
205
|
+
this.last = entry.hash;
|
|
206
|
+
return entry;
|
|
207
|
+
}
|
|
208
|
+
/** Every entry, in order. */
|
|
209
|
+
read() {
|
|
210
|
+
return readEntries(readFileSync(this.path, "utf8"));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Re-derive every hash and confirm each entry names its predecessor.
|
|
214
|
+
*
|
|
215
|
+
* Catches a deleted row, an edited field, and a reordering. Does not catch a
|
|
216
|
+
* wholesale rewrite of the file by someone holding the same code — see the
|
|
217
|
+
* note in `entry.ts`.
|
|
218
|
+
*/
|
|
219
|
+
verify() {
|
|
220
|
+
return verifyEntries(readFileSync(this.path, "utf8"));
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
//#endregion
|
|
225
|
+
export { generateKeypair as a, verifySignature as c, chain as d, hashEntry as f, fingerprint as i, GENESIS as l, readEntries as n, makeReceipt as o, seal as p, verifyEntries as r, signEntry as s, Ledger as t, canonical as u };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prae-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Operator policy for agent harnesses: validate packs, explain a decision, verify a decision log, and provision a governed DSH profile.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh-plugin",
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"typescript": "^5.9.2",
|
|
41
41
|
"vitest": "^3.2.4",
|
|
42
42
|
"@prae/controls": "0.1.0",
|
|
43
|
+
"@prae/extract": "0.1.0",
|
|
44
|
+
"@prae/instructions": "0.1.0",
|
|
43
45
|
"@prae/kernel": "0.1.0",
|
|
44
46
|
"@prae/ledger": "0.1.0",
|
|
45
|
-
"@prae/tokens": "0.1.0"
|
|
46
|
-
"@prae/instructions": "0.1.0",
|
|
47
|
-
"@prae/extract": "0.1.0"
|
|
47
|
+
"@prae/tokens": "0.1.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"test": "vitest run",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|