prae-cli 0.8.0 → 0.9.2

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.
@@ -1,9 +1,11 @@
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";
4
- import { existsSync, readFileSync, readdirSync, statSync, watch } from "node:fs";
1
+ import { n as hostOf, r as withinAny, t as evaluate } from "./evaluate-BqLxhYiP.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-D4UEfbzt.js";
3
+ import { c as fingerprint, g as chain, m as GENESIS, n as readEntries, p as verifySignature, r as verifyEntries, s as verifyBytes, t as Ledger, v as seal } from "./src-LA7NdLKb.js";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
5
5
  import { mkdir, readFile, writeFile } from "node:fs/promises";
6
- import { dirname, extname, join, normalize, resolve, sep } from "node:path";
6
+ import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import { parse } from "yaml";
7
9
  import { homedir } from "node:os";
8
10
  import { composePatchFile, preflightOps, revisionOf, takeSnapshot } from "dsh-overlay-check";
9
11
  import { createServer } from "node:http";
@@ -275,9 +277,9 @@ function readEffect(check$1, path, value) {
275
277
  check$1.fail(path, "expected an object");
276
278
  return;
277
279
  }
278
- const kind = value.kind;
279
- if (kind !== "deny" && kind !== "ask" && kind !== "correct" && kind !== "warn") {
280
- check$1.fail(`${path}.kind`, "expected `deny`, `ask`, `correct`, or `warn`");
280
+ const kind = value.kind === "hold" ? "ask" : value.kind;
281
+ if (kind !== "deny" && kind !== "ask" && kind !== "correct" && kind !== "warn" && kind !== "allow") {
282
+ check$1.fail(`${path}.kind`, "expected `allow`, `deny`, `hold` (or `ask`), `correct`, or `warn`");
281
283
  return;
282
284
  }
283
285
  check$1.keys(path, value, kind === "correct" ? [
@@ -311,6 +313,7 @@ function readRule(check$1, path, value) {
311
313
  check$1.keys(path, value, [
312
314
  "id",
313
315
  "title",
316
+ "description",
314
317
  "when",
315
318
  "effect",
316
319
  "frameworks"
@@ -329,6 +332,10 @@ function readRule(check$1, path, value) {
329
332
  const title = check$1.text(`${path}.title`, value.title);
330
333
  if (title !== void 0) rule.title = title;
331
334
  }
335
+ if (value.description !== void 0) {
336
+ const description = check$1.text(`${path}.description`, value.description);
337
+ if (description !== void 0) rule.description = description;
338
+ }
332
339
  if (value.frameworks !== void 0) {
333
340
  const frameworks = readFrameworks(check$1, `${path}.frameworks`, value.frameworks);
334
341
  if (frameworks !== void 0) rule.frameworks = frameworks;
@@ -389,12 +396,16 @@ function parsePack(source) {
389
396
  "prae",
390
397
  "pack",
391
398
  "version",
399
+ "description",
400
+ "owner",
392
401
  "baseline",
393
402
  "rules"
394
403
  ]);
395
404
  if (raw.prae !== "0.1") check$1.fail(".prae", "expected `\"0.1\"`; this is the only pack version this build reads");
396
405
  const name = check$1.text(".pack", raw.pack);
397
406
  const version = check$1.text(".version", raw.version);
407
+ const description = raw.description === void 0 ? void 0 : check$1.text(".description", raw.description);
408
+ const owner = raw.owner === void 0 ? void 0 : check$1.text(".owner", raw.owner);
398
409
  let baseline;
399
410
  if (raw.baseline !== void 0) if (!isObject(raw.baseline)) check$1.fail(".baseline", "expected an object");
400
411
  else {
@@ -431,12 +442,387 @@ function parsePack(source) {
431
442
  prae: "0.1",
432
443
  pack: name,
433
444
  version,
445
+ ...description === void 0 ? {} : { description },
446
+ ...owner === void 0 ? {} : { owner },
434
447
  ...baseline === void 0 ? {} : { baseline },
435
448
  rules
436
449
  }
437
450
  };
438
451
  }
439
452
 
453
+ //#endregion
454
+ //#region ../dsh/src/chain.ts
455
+ const STAGES = [
456
+ {
457
+ id: "untrusted-input",
458
+ step: 1,
459
+ name: "UNTRUSTED INPUT",
460
+ severity: "INFO",
461
+ definition: "The agent took in content from outside the trust boundary: a read of a path the classifier treats as customer-supplied, or a tool result screening found carrying instructions.",
462
+ rule: "fs:read of a path under the untrusted globs (default **/tickets/**), or a row whose screening outcome is steer or hold.",
463
+ owasp: ["ASI01"],
464
+ atlas: ["AML.T0051.001"],
465
+ alignment: "Where an indirect prompt injection (ATLAS AML.T0051.001) can enter; the read alone does not show that one did. Unless a screening row says so, nothing here detected an injection."
466
+ },
467
+ {
468
+ id: "credential-access",
469
+ step: 2,
470
+ name: "CREDENTIAL ACCESS",
471
+ severity: "HIGH",
472
+ definition: "The agent tried to reach a credential: a file that is one by its path, or content the inspector found holding one.",
473
+ rule: "The deciding or a matched rule has a secrets matcher, or the row carries content findings, or the target is under the credential globs (/etc/shadow, **/.ssh/**, **/*.pem, **/.env, …).",
474
+ owasp: ["ASI03"],
475
+ atlas: [
476
+ "AML.TA0013",
477
+ "AML.T0055",
478
+ "AML.T0098"
479
+ ],
480
+ alignment: "ATLAS tactic Credential Access, with Unsecured Credentials (a credential in a file) and AI Agent Tool Credential Harvesting (an agent tool used to collect one)."
481
+ },
482
+ {
483
+ id: "exfiltration",
484
+ step: 3,
485
+ name: "EXFILTRATION ATTEMPT",
486
+ severity: "CRITICAL",
487
+ definition: "The agent tried to send a request to a destination outside the egress list: through the fetch tool, or a network client in the shell.",
488
+ rule: "A matched rule has a host matcher on a net:fetch call, or a command matcher naming a network client on a proc:spawn call, and the request is not stage 4.",
489
+ owasp: ["ASI02"],
490
+ atlas: ["AML.TA0010", "AML.T0086"],
491
+ alignment: "ATLAS Exfiltration via AI Agent Tool Invocation. The row shows an outbound request to an unapproved host was attempted; it does not show what data the request carried."
492
+ },
493
+ {
494
+ id: "token-abuse",
495
+ step: 4,
496
+ name: "TOKEN ABUSE",
497
+ severity: "CRITICAL",
498
+ definition: "A stage-3 request whose destination path is a token-issuance endpoint: an attempt to obtain or register a credential outside the trust boundary.",
499
+ rule: "Stage 3, and the URL path has a segment token, oauth, oauth2 or sts.",
500
+ owasp: ["ASI03"],
501
+ atlas: ["AML.TA0013", "AML.TA0010"],
502
+ alignment: "PRAE-specific: no ATLAS technique is this one-to-one. It is classified from the request; it is enforced by whichever egress rule refused it."
503
+ },
504
+ {
505
+ id: "runaway",
506
+ step: 5,
507
+ name: "RUNAWAY BEHAVIOUR",
508
+ severity: "HIGH",
509
+ definition: "The agent kept calling past a budget the pack sets for the session.",
510
+ rule: "The deciding or a matched rule has a count matcher.",
511
+ owasp: ["ASI08"],
512
+ atlas: ["AML.T0034.002"],
513
+ alignment: "ATLAS Agentic Resource Consumption. OWASP ASI08 (Cascading Failures) is the nearest Agentic entry, not an equivalent."
514
+ }
515
+ ];
516
+ /** Paths whose content the classifier treats as from outside the boundary. */
517
+ const UNTRUSTED_GLOBS = ["**/tickets/**", "./tickets/**"];
518
+ /** Paths that are credentials by location, whatever they contain. */
519
+ const CREDENTIAL_GLOBS = [
520
+ "/etc/shadow",
521
+ "/etc/gshadow",
522
+ "/etc/sudoers",
523
+ "**/.ssh/**",
524
+ "**/.aws/**",
525
+ "**/.gnupg/**",
526
+ "**/*.pem",
527
+ "**/*.key",
528
+ "**/.env",
529
+ "**/.env.*",
530
+ "**/credentials/**",
531
+ "**/.netrc"
532
+ ];
533
+ const NETWORK_CLIENTS = new Set([
534
+ "curl",
535
+ "wget",
536
+ "nc",
537
+ "ncat",
538
+ "netcat",
539
+ "ssh",
540
+ "scp",
541
+ "sftp",
542
+ "ftp",
543
+ "telnet",
544
+ "openssl"
545
+ ]);
546
+ const TOKEN_SEGMENT = new Set([
547
+ "token",
548
+ "oauth",
549
+ "oauth2",
550
+ "sts"
551
+ ]);
552
+ function rulesOf(row$1, pack) {
553
+ if (pack === void 0) return [];
554
+ const ids = [row$1.rule, ...(row$1.trace ?? []).map((t) => t.rule)].filter((id) => id !== void 0);
555
+ return [...new Set(ids)].map((id) => pack.rules.find((r) => r.id === id)).filter((r) => r !== void 0);
556
+ }
557
+ function pathOf(url) {
558
+ try {
559
+ return new URL(url).pathname.toLowerCase().split("/").filter(Boolean);
560
+ } catch {
561
+ return [];
562
+ }
563
+ }
564
+ const stage = (id) => STAGES.find((s) => s.id === id);
565
+ /**
566
+ * The stage of one row, or undefined when the row is not an attack-chain
567
+ * step. Reads only the sealed row and the pack; the same inputs give the
568
+ * same answer every time. Checked in order: budget, credential, egress,
569
+ * untrusted input. The first that holds decides.
570
+ */
571
+ function classify(row$1, pack) {
572
+ if (row$1.tool.startsWith("prae:")) return void 0;
573
+ const rules = rulesOf(row$1, pack);
574
+ const target = row$1.subject ?? "";
575
+ const budget = rules.find((r) => r.when.count !== void 0);
576
+ if (budget !== void 0) return {
577
+ stage: stage("runaway"),
578
+ basis: `${budget.id} is a session budget (count above ${budget.when.count?.above})`
579
+ };
580
+ const secrets = rules.find((r) => r.when.secrets !== void 0);
581
+ if (secrets !== void 0 || (row$1.secrets?.length ?? 0) > 0) return {
582
+ stage: stage("credential-access"),
583
+ basis: secrets !== void 0 ? `${secrets.id} inspects content for credentials, and matched` : `content inspection found ${row$1.secrets?.length} credential finding(s)`
584
+ };
585
+ if (target !== "" && (row$1.capability === "fs:read" || row$1.capability === "fs:write" || row$1.capability === "proc:spawn") && withinAny(target, CREDENTIAL_GLOBS)) return {
586
+ stage: stage("credential-access"),
587
+ basis: `the target ${target} is a credential by its path`
588
+ };
589
+ const host = rules.find((r) => r.when.host !== void 0);
590
+ const shell = row$1.capability === "proc:spawn" && row$1.command !== void 0 && NETWORK_CLIENTS.has(row$1.command.program) ? rules.find((r) => r.when.command !== void 0) : void 0;
591
+ const egress = row$1.capability === "net:fetch" ? host : shell;
592
+ if (egress !== void 0) {
593
+ const token = row$1.capability === "net:fetch" ? pathOf(target).find((s) => TOKEN_SEGMENT.has(s)) : void 0;
594
+ const where = row$1.capability === "net:fetch" ? `the destination ${hostOf(target) ?? target} is outside the egress list` : `the shell ran the network client ${row$1.command?.program}`;
595
+ return token !== void 0 ? {
596
+ stage: stage("token-abuse"),
597
+ basis: `${where} (${egress.id}), and the path names a token endpoint (/${token})`
598
+ } : {
599
+ stage: stage("exfiltration"),
600
+ basis: `${where} (${egress.id})`
601
+ };
602
+ }
603
+ if (row$1.screening !== void 0 && (row$1.screening.outcome === "steer" || row$1.screening.outcome === "hold")) return {
604
+ stage: stage("untrusted-input"),
605
+ basis: `screening found instructions in the content (${row$1.screening.outcome})`
606
+ };
607
+ if (row$1.capability === "fs:read" && target !== "" && withinAny(target, UNTRUSTED_GLOBS)) return {
608
+ stage: stage("untrusted-input"),
609
+ basis: `${target} is customer-supplied content`
610
+ };
611
+ }
612
+ const EXECUTION_LABEL = {
613
+ "blocked-before-execution": "BLOCKED BEFORE EXECUTION",
614
+ "held-before-execution": "HELD BEFORE EXECUTION",
615
+ "executed-output-withheld": "EXECUTED · DETECTED AFTER EXECUTION",
616
+ "executed-shadow": "EXECUTED · SHADOW (WOULD HAVE BEEN REFUSED)",
617
+ executed: "EXECUTED",
618
+ permitted: "PERMITTED"
619
+ };
620
+ /**
621
+ * Whether the call ran, from the row alone. A refusal before execution and
622
+ * a result withheld after it are different claims, and the row says which:
623
+ * `phase: output` is the second.
624
+ */
625
+ function executionOf(row$1) {
626
+ if (row$1.tool.startsWith("prae:")) return void 0;
627
+ const refused = row$1.verdict === "deny" || row$1.verdict === "correct";
628
+ if (row$1.shadow === true && (refused || row$1.verdict === "ask")) return "executed-shadow";
629
+ if (row$1.phase === "output") return refused ? "executed-output-withheld" : "executed";
630
+ if (refused) return "blocked-before-execution";
631
+ if (row$1.verdict === "ask") return "held-before-execution";
632
+ return row$1.executed === true ? "executed" : "permitted";
633
+ }
634
+ /**
635
+ * The Attack Chain Integrity Matrix: every classified row, in ledger order,
636
+ * from attempted action to the seal that proves it. The Guardian comes from
637
+ * the catalogue the caller passes; the rest is the row's own.
638
+ */
639
+ function integrityMatrix(entries, pack, guardianOf = () => void 0) {
640
+ const out = [];
641
+ for (const row$1 of entries) {
642
+ const c = classify(row$1, pack);
643
+ const execution = executionOf(row$1);
644
+ if (c === void 0 || execution === void 0) continue;
645
+ const rule = row$1.rule ?? row$1.trace?.[0]?.rule;
646
+ const mismatch = entries.find((e) => e.rule === "skill.capability_mismatch" && e.about === row$1.seq)?.seq;
647
+ out.push({
648
+ seq: row$1.seq,
649
+ at: row$1.at,
650
+ action: row$1.tool,
651
+ ...row$1.subject === void 0 ? {} : { target: row$1.subject },
652
+ ...row$1.capability === void 0 ? {} : { capability: row$1.capability },
653
+ ...guardianOf(rule) === void 0 ? {} : { guardian: guardianOf(rule) },
654
+ ...rule === void 0 ? {} : {
655
+ rule,
656
+ ruleRole: row$1.rule === void 0 ? "recorded" : "decided"
657
+ },
658
+ decision: row$1.verdict.toUpperCase(),
659
+ ...row$1.reason === void 0 ? {} : { reason: row$1.reason },
660
+ execution: EXECUTION_LABEL[execution],
661
+ stage: `${c.stage.step}/5 ${c.stage.name}`,
662
+ basis: c.basis,
663
+ ...mismatch === void 0 ? {} : { mismatch },
664
+ hash: row$1.hash,
665
+ prev: row$1.prev,
666
+ signed: row$1.sig !== void 0
667
+ });
668
+ }
669
+ return out;
670
+ }
671
+
672
+ //#endregion
673
+ //#region ../dsh/src/packset.ts
674
+ const PACK_FILE = /\.prae\.(json|ya?ml)$/;
675
+ /** Read one pack file: its parsed pack, or why it cannot be one. */
676
+ function readPackFile(file, policyKey, origin = "workspace") {
677
+ let bytes;
678
+ try {
679
+ bytes = readFileSync(file);
680
+ } catch (e) {
681
+ return {
682
+ file,
683
+ reason: `unreadable: ${e instanceof Error ? e.message : String(e)}`
684
+ };
685
+ }
686
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
687
+ if (policyKey !== void 0) {
688
+ let signature = "";
689
+ try {
690
+ signature = readFileSync(`${file}.sig`, "utf8");
691
+ } catch {}
692
+ if (signature === "") return {
693
+ file,
694
+ reason: `unsigned: no ${file.split("/").at(-1)}.sig beside it, and a policy key is pinned`
695
+ };
696
+ if (!verifyBytes(bytes, signature, policyKey)) return {
697
+ file,
698
+ reason: `not signed by the policy key ${fingerprint(policyKey)} (edited after signing, or signed by another key)`
699
+ };
700
+ }
701
+ let json = bytes.toString("utf8");
702
+ if (/\.ya?ml$/.test(file)) try {
703
+ json = JSON.stringify(parse(json));
704
+ } catch (e) {
705
+ return {
706
+ file,
707
+ reason: `invalid YAML: ${e instanceof Error ? e.message : String(e)}`
708
+ };
709
+ }
710
+ const parsed = parsePack(json);
711
+ if (!parsed.ok) return {
712
+ file,
713
+ reason: parsed.errors.map((e) => `${e.path || "<root>"}: ${e.message}`).join("; ")
714
+ };
715
+ return {
716
+ file,
717
+ pack: parsed.pack,
718
+ origin,
719
+ sha256,
720
+ ...policyKey === void 0 ? {} : { signedBy: fingerprint(policyKey) }
721
+ };
722
+ }
723
+ /** Load, validate and merge every pack in a directory. Never throws. */
724
+ function loadPackDir(dir, policyKey, accept, builtin) {
725
+ const packs = [];
726
+ const rejected = [];
727
+ const ids = /* @__PURE__ */ new Map();
728
+ let baselineFrom;
729
+ let names = [];
730
+ try {
731
+ names = readdirSync(resolve(dir)).filter((n) => PACK_FILE.test(n)).sort();
732
+ } catch (e) {
733
+ rejected.push({
734
+ file: dir,
735
+ reason: `no such directory: ${e instanceof Error ? e.message : String(e)}`
736
+ });
737
+ }
738
+ const candidates = [...builtin === void 0 ? [] : [builtin], ...names.map((name) => readPackFile(join(resolve(dir), name), policyKey))];
739
+ for (const read of candidates) {
740
+ const file = read.file;
741
+ const name = file.split("/").at(-1) ?? file;
742
+ if (!("pack" in read)) {
743
+ rejected.push(read);
744
+ continue;
745
+ }
746
+ const refused = accept?.(read);
747
+ if (refused !== void 0) {
748
+ rejected.push({
749
+ file,
750
+ reason: refused
751
+ });
752
+ continue;
753
+ }
754
+ const foreign = read.origin === "workspace" ? read.pack.rules.find((r) => !r.id.startsWith("ORG-")) : void 0;
755
+ if (foreign !== void 0) {
756
+ rejected.push({
757
+ file,
758
+ reason: `rule id ${foreign.id} must start with ORG-; workspace rules are the organisation's own, and other ids belong to built-in packs`
759
+ });
760
+ continue;
761
+ }
762
+ const clash = read.pack.rules.find((r) => ids.has(r.id));
763
+ if (clash !== void 0) {
764
+ rejected.push({
765
+ file,
766
+ reason: `rule id ${clash.id} is already defined by ${ids.get(clash.id)}; rule ids must be unique across the workspace`
767
+ });
768
+ continue;
769
+ }
770
+ if (read.pack.baseline !== void 0 && baselineFrom !== void 0) {
771
+ rejected.push({
772
+ file,
773
+ reason: `declares a baseline, and ${baselineFrom} already does; only one pack in a workspace may, so a grant is never widened`
774
+ });
775
+ continue;
776
+ }
777
+ if (read.pack.baseline !== void 0) baselineFrom = name;
778
+ for (const r of read.pack.rules) ids.set(r.id, name);
779
+ packs.push(read);
780
+ }
781
+ return merge(packs, rejected);
782
+ }
783
+ /** The packs as one policy, each rule remembering where it came from. */
784
+ function merge(packs, rejected) {
785
+ const origins = /* @__PURE__ */ new Map();
786
+ for (const p of packs) for (const r of p.pack.rules) origins.set(r.id, {
787
+ name: p.pack.pack,
788
+ version: p.pack.version,
789
+ sha256: p.sha256,
790
+ origin: p.origin
791
+ });
792
+ if (packs.length === 0) return {
793
+ packs,
794
+ rejected,
795
+ origins,
796
+ effective: {
797
+ prae: "0.1",
798
+ pack: "no-valid-pack",
799
+ version: "0",
800
+ rules: [{
801
+ id: "PRAE-NO-PACK",
802
+ when: {},
803
+ effect: {
804
+ kind: "deny",
805
+ because: `no valid policy pack is loaded, so nothing runs (${rejected.length === 0 ? "no policy pack found" : rejected.map((r) => `${r.file.split("/").at(-1)}: ${r.reason}`).join(" · ")})`
806
+ }
807
+ }]
808
+ }
809
+ };
810
+ const baseline = packs.find((p) => p.pack.baseline !== void 0)?.pack.baseline;
811
+ const only = packs.length === 1 ? packs[0] : void 0;
812
+ return {
813
+ packs,
814
+ rejected,
815
+ origins,
816
+ effective: {
817
+ prae: "0.1",
818
+ pack: only?.pack.pack ?? packs.map((p) => p.pack.pack).join("+"),
819
+ version: only?.pack.version ?? createHash("sha256").update(packs.map((p) => p.sha256).join("")).digest("hex").slice(0, 12),
820
+ ...baseline === void 0 ? {} : { baseline },
821
+ rules: packs.flatMap((p) => p.pack.rules)
822
+ }
823
+ };
824
+ }
825
+
440
826
  //#endregion
441
827
  //#region src/console/ledger.ts
442
828
  function ago(iso, now) {
@@ -763,6 +1149,7 @@ function praeRow(options) {
763
1149
  if (options.ledger !== void 0) lines.push(` ledger: ${JSON.stringify(options.ledger)}`);
764
1150
  if (options.approver !== void 0) lines.push(` approver: ${JSON.stringify(options.approver)}`);
765
1151
  if (options.mode !== void 0) lines.push(` mode: ${options.mode}`);
1152
+ if (options.packDir !== void 0) lines.push(` packDir: ${JSON.stringify(options.packDir)}`);
766
1153
  if (options.packFile !== void 0) lines.push(` packFile: ${JSON.stringify(options.packFile)}`);
767
1154
  else lines.push(" pack: |", indent(options.pack ?? "", 10));
768
1155
  return lines.join("\n");
@@ -832,6 +1219,7 @@ const DEFAULT_FILES = {
832
1219
  report: "security-report.json",
833
1220
  guardians: "guardians.json",
834
1221
  pack: "papaya.prae.json",
1222
+ packs: ".prae/packs",
835
1223
  publicKey: "prae-signing.pub.pem",
836
1224
  redteam: "../redteam/results"
837
1225
  };
@@ -849,12 +1237,358 @@ function readRedteam(dir, files) {
849
1237
  const root = resolve(dir, files.redteam);
850
1238
  if (!existsSync(root)) return {
851
1239
  campaign: null,
852
- runs: []
1240
+ runs: [],
1241
+ steering: []
853
1242
  };
854
1243
  const runs = readdirSync(root).filter((n) => n.endsWith(".json")).sort().filter((n) => n !== "campaign.json").map((n) => jsonOrNull(resolve(root, n))).filter((r) => r !== null);
1244
+ const steeringDir = resolve(root, "steering");
1245
+ const steering = existsSync(steeringDir) ? readdirSync(steeringDir).filter((n) => n.endsWith(".json")).sort().map((n) => jsonOrNull(resolve(steeringDir, n))).filter((r) => r !== null && typeof r === "object" && "objective" in r) : [];
855
1246
  return {
856
1247
  campaign: jsonOrNull(resolve(root, "campaign.json")),
857
- runs
1248
+ runs,
1249
+ steering
1250
+ };
1251
+ }
1252
+
1253
+ //#endregion
1254
+ //#region ../controls/src/scope.ts
1255
+ const ROW_LIMIT = 50;
1256
+ /** A framework's edition, read from its versioned id (`…-2026`). */
1257
+ const versionOf = (id) => /(\d{4})$/.exec(id)?.[1];
1258
+ /** Where a built-in control belongs, and its code within it. */
1259
+ function placeOf(control) {
1260
+ const found = Object.entries(FRAMEWORKS).find(([, f]) => control.id.startsWith(`${f.short} · `));
1261
+ if (found !== void 0) return {
1262
+ framework: {
1263
+ id: found[0],
1264
+ name: found[1].name
1265
+ },
1266
+ id: control.id.slice(found[1].short.length + 3)
1267
+ };
1268
+ return {
1269
+ framework: control.standard ?? {
1270
+ id: "obligations",
1271
+ name: "Obligations"
1272
+ },
1273
+ id: control.code ?? control.id.split(" · ")[1] ?? control.id
1274
+ };
1275
+ }
1276
+ /** A control defined as data: evidence is the rows under its mapped rules. */
1277
+ function fromData(def) {
1278
+ const types = new Set(def.evidence_types ?? [
1279
+ "allow",
1280
+ "deny",
1281
+ "ask"
1282
+ ]);
1283
+ const trace = (data) => {
1284
+ const ids = new Set(def.prae_controls);
1285
+ return {
1286
+ rules: [...def.prae_controls],
1287
+ rows: data.entries.filter((e) => e.rule !== void 0 && ids.has(e.rule) && types.has(e.verdict))
1288
+ };
1289
+ };
1290
+ return {
1291
+ id: def.id,
1292
+ ...def.name === void 0 ? {} : { name: def.name },
1293
+ asks: def.asks,
1294
+ trace,
1295
+ evidence: (data) => {
1296
+ const inPack = new Set((data.pack?.rules ?? []).map((r) => r.id));
1297
+ const present = def.prae_controls.filter((id) => inPack.has(id));
1298
+ const rows = trace(data).rows.length;
1299
+ if (rows > 0) return {
1300
+ state: "covered",
1301
+ note: `${def.prae_controls.length} PRAE control${def.prae_controls.length === 1 ? "" : "s"} mapped (${def.prae_controls.join(", ")}) · ${rows} sealed decision${rows === 1 ? "" : "s"} under them in scope.`
1302
+ };
1303
+ if (present.length > 0) return {
1304
+ state: "partial",
1305
+ note: `${present.length} mapped PRAE control${present.length === 1 ? " is" : "s are"} in the pack (${present.join(", ")}); none has decided a call in scope, so the mapping is declared, not exercised.`
1306
+ };
1307
+ return {
1308
+ state: "absent",
1309
+ note: def.prae_controls.length === 0 ? "No PRAE control is mapped to it." : `The mapped PRAE controls (${def.prae_controls.join(", ")}) are not in this pack.`
1310
+ };
1311
+ }
1312
+ };
1313
+ }
1314
+ /** The built-in catalog, plus a record's own frameworks when it has any. */
1315
+ function catalogOf(file) {
1316
+ const frameworks = /* @__PURE__ */ new Map();
1317
+ const controls = /* @__PURE__ */ new Map();
1318
+ const add = (framework, id, control) => {
1319
+ const key = `${framework.id}:${id}`;
1320
+ if (controls.has(key)) return;
1321
+ controls.set(key, control);
1322
+ let f = frameworks.get(framework.id);
1323
+ if (f === void 0) {
1324
+ f = {
1325
+ ...framework,
1326
+ controls: []
1327
+ };
1328
+ frameworks.set(framework.id, f);
1329
+ }
1330
+ f.controls.push({
1331
+ key,
1332
+ id,
1333
+ ...control.name === void 0 ? {} : { name: control.name },
1334
+ asks: control.asks
1335
+ });
1336
+ };
1337
+ for (const control of CONTROLS) {
1338
+ const place = placeOf(control);
1339
+ const version = versionOf(place.framework.id);
1340
+ add({
1341
+ id: place.framework.id,
1342
+ name: place.framework.name,
1343
+ ...version === void 0 ? {} : { version }
1344
+ }, place.id, control);
1345
+ }
1346
+ for (const f of file?.frameworks ?? []) for (const c of f.controls) add({
1347
+ id: f.id,
1348
+ name: f.name,
1349
+ ...f.version === void 0 ? {} : { version: f.version },
1350
+ ...f.description === void 0 ? {} : { description: f.description },
1351
+ custom: true
1352
+ }, c.id, fromData(c));
1353
+ return {
1354
+ frameworks: [...frameworks.values()],
1355
+ controls
1356
+ };
1357
+ }
1358
+ /** A catalog file, checked: what is wrong with it, or the file. */
1359
+ function parseCatalog(raw) {
1360
+ const text = (v) => typeof v === "string" && v.trim() !== "";
1361
+ if (typeof raw !== "object" || raw === null) return "not an object";
1362
+ const frameworks = raw.frameworks;
1363
+ if (!Array.isArray(frameworks)) return "frameworks: expected an array";
1364
+ for (const [i, f] of frameworks.entries()) {
1365
+ if (!text(f?.id) || !/^[a-z0-9][a-z0-9-]*$/.test(f.id)) return `frameworks[${i}].id: lowercase letters, digits and dashes`;
1366
+ if (!text(f.name)) return `frameworks[${i}].name: required`;
1367
+ if (!Array.isArray(f.controls)) return `frameworks[${i}].controls: expected an array`;
1368
+ for (const [j, c] of f.controls.entries()) {
1369
+ const at = `frameworks[${i}].controls[${j}]`;
1370
+ if (!text(c?.id)) return `${at}.id: required`;
1371
+ if (!text(c.asks)) return `${at}.asks: required`;
1372
+ if (!Array.isArray(c.prae_controls) || !c.prae_controls.every((r) => text(r))) return `${at}.prae_controls: expected an array of PRAE rule ids`;
1373
+ if (c.evidence_types !== void 0 && (!Array.isArray(c.evidence_types) || !c.evidence_types.every((t) => [
1374
+ "allow",
1375
+ "deny",
1376
+ "ask"
1377
+ ].includes(t)))) return `${at}.evidence_types: allow, deny or ask`;
1378
+ }
1379
+ }
1380
+ return raw;
1381
+ }
1382
+ /** Everything built in, every agent and session, the whole record. */
1383
+ function defaultScope(catalog) {
1384
+ const builtIn = catalog.frameworks.filter((f) => f.custom !== true);
1385
+ return {
1386
+ frameworks: builtIn.map((f) => f.id),
1387
+ controls: builtIn.flatMap((f) => f.controls.map((c) => c.key)),
1388
+ agents: "all",
1389
+ sessions: "all",
1390
+ period: { kind: "all" }
1391
+ };
1392
+ }
1393
+ /** A scope as saved or sent, checked: what is wrong with it, or the scope. */
1394
+ function parseScope(raw) {
1395
+ if (typeof raw !== "object" || raw === null) return "not an object";
1396
+ const r = raw;
1397
+ const strings = (v) => Array.isArray(v) && v.every((s) => typeof s === "string" && s.length <= 200);
1398
+ if (!strings(r.frameworks)) return "frameworks: expected an array of ids";
1399
+ if (!strings(r.controls)) return "controls: expected an array of keys";
1400
+ for (const d of ["agents", "sessions"]) if (r[d] !== "all" && !strings(r[d])) return `${d}: "all" or an array`;
1401
+ const p = r.period;
1402
+ const period = p?.kind === "all" ? { kind: "all" } : p?.kind === "days" && typeof p.days === "number" && Number.isInteger(p.days) && p.days > 0 && p.days <= 3650 ? {
1403
+ kind: "days",
1404
+ days: p.days
1405
+ } : p?.kind === "custom" && typeof p.from === "string" && typeof p.to === "string" && !Number.isNaN(Date.parse(p.from)) && !Number.isNaN(Date.parse(p.to)) && Date.parse(p.from) <= Date.parse(p.to) ? {
1406
+ kind: "custom",
1407
+ from: p.from,
1408
+ to: p.to
1409
+ } : "period: all, the last N days, or a custom from–to range";
1410
+ if (typeof period === "string") return period;
1411
+ return {
1412
+ frameworks: r.frameworks,
1413
+ controls: r.controls,
1414
+ agents: r.agents,
1415
+ sessions: r.sessions,
1416
+ period
1417
+ };
1418
+ }
1419
+ /** Whether a row falls in the scope's period. */
1420
+ function inPeriod(at, period, now) {
1421
+ const t = Date.parse(at);
1422
+ if (period.kind === "all") return true;
1423
+ if (Number.isNaN(t)) return false;
1424
+ if (period.kind === "days") return t >= now - period.days * 864e5;
1425
+ return t >= Date.parse(period.from) && t < Date.parse(period.to) + 864e5;
1426
+ }
1427
+ /**
1428
+ * Assess the scoped controls against the record. The rows are narrowed to
1429
+ * the scope's agents, sessions and period before any control reads them;
1430
+ * the chain's verification stays the whole record's, since integrity is a
1431
+ * property of the chain, not of a slice of it.
1432
+ */
1433
+ function assess(data, scope, catalog, options) {
1434
+ const now = options.now ?? Date.now();
1435
+ const agentOf$1 = (e) => e.agent ?? options.agent;
1436
+ const admitted = data.entries.filter((e) => (scope.agents === "all" || scope.agents.includes(agentOf$1(e))) && (scope.sessions === "all" || e.session !== void 0 && scope.sessions.includes(e.session)) && inPeriod(e.at, scope.period, now));
1437
+ const scoped = {
1438
+ ...data,
1439
+ entries: admitted
1440
+ };
1441
+ const frameworksOn = new Set(scope.frameworks);
1442
+ const controlsOn = new Set(scope.controls);
1443
+ const row$1 = (e) => ({
1444
+ seq: e.seq,
1445
+ at: e.at,
1446
+ tool: e.tool,
1447
+ ...e.subject === void 0 ? {} : { subject: e.subject },
1448
+ verdict: e.verdict,
1449
+ ...e.rule === void 0 ? {} : { rule: e.rule },
1450
+ ...e.session === void 0 ? {} : { session: e.session },
1451
+ agent: agentOf$1(e),
1452
+ hash: e.hash
1453
+ });
1454
+ const frameworks = catalog.frameworks.map((f) => {
1455
+ const selected = frameworksOn.has(f.id);
1456
+ const controls = f.controls.map((c) => {
1457
+ const control = catalog.controls.get(c.key);
1458
+ if (!(selected && controlsOn.has(c.key)) || control === void 0) return {
1459
+ ...c,
1460
+ inScope: false,
1461
+ state: "not-in-scope",
1462
+ note: "Not in the selected scope, so not assessed.",
1463
+ rules: [],
1464
+ evidence: {
1465
+ count: 0,
1466
+ rows: []
1467
+ }
1468
+ };
1469
+ const found = control.evidence(scoped);
1470
+ const trace = control.trace?.(scoped) ?? {
1471
+ rules: [],
1472
+ rows: []
1473
+ };
1474
+ return {
1475
+ ...c,
1476
+ inScope: true,
1477
+ state: found.state,
1478
+ note: found.note,
1479
+ rules: trace.rules,
1480
+ evidence: {
1481
+ count: trace.rows.length,
1482
+ rows: trace.rows.slice(-ROW_LIMIT).reverse().map(row$1)
1483
+ }
1484
+ };
1485
+ });
1486
+ const on$1 = controls.filter((c) => c.inScope);
1487
+ const count = (s) => on$1.filter((c) => c.state === s).length;
1488
+ const { controls: _,...meta } = f;
1489
+ return {
1490
+ ...meta,
1491
+ selected,
1492
+ total: controls.length,
1493
+ inScope: on$1.length,
1494
+ covered: count("covered"),
1495
+ partial: count("partial"),
1496
+ absent: count("absent"),
1497
+ controls
1498
+ };
1499
+ });
1500
+ const on = frameworks.flatMap((f) => f.controls.filter((c) => c.inScope));
1501
+ const sessions = /* @__PURE__ */ new Map();
1502
+ for (const e of data.entries) {
1503
+ if (e.session === void 0) continue;
1504
+ const s = sessions.get(e.session);
1505
+ if (s === void 0) sessions.set(e.session, {
1506
+ rows: 1,
1507
+ first: e.at,
1508
+ last: e.at
1509
+ });
1510
+ else {
1511
+ s.rows += 1;
1512
+ s.last = e.at;
1513
+ }
1514
+ }
1515
+ return {
1516
+ scope,
1517
+ frameworks,
1518
+ totals: {
1519
+ frameworks: frameworks.filter((f) => f.inScope > 0).length,
1520
+ controls: on.length,
1521
+ withEvidence: on.filter((c) => c.state !== "absent").length,
1522
+ covered: on.filter((c) => c.state === "covered").length,
1523
+ partial: on.filter((c) => c.state === "partial").length,
1524
+ absent: on.filter((c) => c.state === "absent").length
1525
+ },
1526
+ dimensions: {
1527
+ agents: [...new Set([options.agent, ...data.entries.map(agentOf$1)])],
1528
+ sessions: [...sessions.entries()].map(([id, s]) => ({
1529
+ id,
1530
+ ...s
1531
+ })),
1532
+ environment: options.environment
1533
+ },
1534
+ rows: {
1535
+ admitted: admitted.length,
1536
+ total: data.entries.length
1537
+ }
1538
+ };
1539
+ }
1540
+
1541
+ //#endregion
1542
+ //#region src/compliance.ts
1543
+ const SCOPE_FILE = ".prae-compliance/scope.json";
1544
+ const CATALOG_FILE = "compliance-catalog.json";
1545
+ /** The record's catalog: built in, plus its own file when it parses. */
1546
+ function catalogFor(dir) {
1547
+ const path = resolve(dir, CATALOG_FILE);
1548
+ if (!existsSync(path)) return { catalog: catalogOf() };
1549
+ let raw;
1550
+ try {
1551
+ raw = JSON.parse(readFileSync(path, "utf8"));
1552
+ } catch {
1553
+ return {
1554
+ catalog: catalogOf(),
1555
+ catalogError: `${CATALOG_FILE}: not JSON`
1556
+ };
1557
+ }
1558
+ const file = parseCatalog(raw);
1559
+ return typeof file === "string" ? {
1560
+ catalog: catalogOf(),
1561
+ catalogError: `${CATALOG_FILE}: ${file}`
1562
+ } : { catalog: catalogOf(file) };
1563
+ }
1564
+ /** The saved scope, or everything built in when none is saved yet. */
1565
+ function scopeFor(dir, catalog) {
1566
+ const path = resolve(dir, SCOPE_FILE);
1567
+ if (!existsSync(path)) return defaultScope(catalog);
1568
+ try {
1569
+ const scope = parseScope(JSON.parse(readFileSync(path, "utf8")));
1570
+ return typeof scope === "string" ? defaultScope(catalog) : scope;
1571
+ } catch {
1572
+ return defaultScope(catalog);
1573
+ }
1574
+ }
1575
+ /** The agent the record's discovery names, for rows that name none. */
1576
+ function agentOf(dir) {
1577
+ try {
1578
+ const discovery = JSON.parse(readFileSync(resolve(dir, "discovery.json"), "utf8"));
1579
+ if (typeof discovery.agent?.name === "string") return discovery.agent.name;
1580
+ } catch {}
1581
+ return "this agent";
1582
+ }
1583
+ function assessCompliance(record, data) {
1584
+ const { catalog, catalogError } = catalogFor(record.dir);
1585
+ return {
1586
+ ...assess(data, scopeFor(record.dir, catalog), catalog, {
1587
+ agent: agentOf(record.dir),
1588
+ environment: basename(resolve(record.dir))
1589
+ }),
1590
+ saved: existsSync(resolve(record.dir, SCOPE_FILE)),
1591
+ ...catalogError === void 0 ? {} : { catalogError }
858
1592
  };
859
1593
  }
860
1594
 
@@ -972,6 +1706,45 @@ function verifyRecord(record, tamper, repair = false) {
972
1706
  signatures
973
1707
  };
974
1708
  }
1709
+ /** The record's built-in pack and workspace packs, as the gate loads them. */
1710
+ function packSetOf(record) {
1711
+ const { dir, files } = record;
1712
+ const packsDir = resolve(dir, files.packs);
1713
+ const builtinPath = resolve(dir, files.pack);
1714
+ return {
1715
+ packsDir,
1716
+ set: loadPackDir(packsDir, void 0, void 0, existsSync(builtinPath) ? readPackFile(builtinPath, void 0, "builtin") : void 0)
1717
+ };
1718
+ }
1719
+ /**
1720
+ * The packs in force for a record (PRA-127): the built-in pack and the
1721
+ * workspace's own, read by the gate's loader — each with its origin, version,
1722
+ * sha256 and rules — and every workspace pack the loader rejects, with why.
1723
+ */
1724
+ function packsOf(record) {
1725
+ const { packsDir, set } = packSetOf(record);
1726
+ return {
1727
+ packs: set.packs.map((p) => ({
1728
+ file: p.file.split("/").at(-1),
1729
+ name: p.pack.pack,
1730
+ version: p.pack.version,
1731
+ ...p.pack.description === void 0 ? {} : { description: p.pack.description },
1732
+ ...p.pack.owner === void 0 ? {} : { owner: p.pack.owner },
1733
+ sha256: p.sha256,
1734
+ origin: p.origin,
1735
+ rules: p.pack.rules.map((r) => ({
1736
+ id: r.id,
1737
+ ...r.title === void 0 ? {} : { title: r.title },
1738
+ kind: r.effect.kind,
1739
+ because: r.effect.because
1740
+ }))
1741
+ })),
1742
+ rejected: set.rejected.filter((r) => r.file !== packsDir).map((r) => ({
1743
+ file: r.file.split("/").at(-1),
1744
+ reason: r.reason
1745
+ }))
1746
+ };
1747
+ }
975
1748
  /**
976
1749
  * Evidence toward each control, from the pack and the ledger as they are
977
1750
  * now — the rows the controls package defines, so the console, the exporter and
@@ -988,31 +1761,32 @@ function complianceOf(record) {
988
1761
  } catch {
989
1762
  entries = [];
990
1763
  }
991
- const packPath = resolve(dir, files.pack);
992
- let pack;
993
- if (existsSync(packPath)) {
994
- const parsed = parsePack(readFileSync(packPath, "utf8"));
995
- if (parsed.ok) pack = parsed.pack;
996
- }
1764
+ const { set } = packSetOf(record);
1765
+ const pack = set.packs.length > 0 ? set.effective : void 0;
997
1766
  const data = {
998
1767
  ...pack === void 0 ? {} : { pack },
999
1768
  entries,
1000
1769
  verification: verifyEntries(text),
1001
1770
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1002
1771
  };
1003
- return { controls: CONTROLS.map((control) => {
1004
- const found = Object.entries(FRAMEWORKS).find(([, f]) => control.id.startsWith(`${f.short} · `));
1005
- const code = found === void 0 ? void 0 : control.id.split(" · ")[1];
1006
- const named = found === void 0 || code === void 0 ? void 0 : found[1].controls[code];
1007
- return {
1008
- id: code ?? control.id,
1009
- ...named === void 0 ? {} : { name: named.name },
1010
- asks: control.asks,
1011
- framework: found === void 0 ? "obligation" : found[0],
1012
- ...found === void 0 ? {} : { frameworkName: found[1].name },
1013
- ...control.evidence(data)
1014
- };
1015
- }) };
1772
+ return {
1773
+ assessment: assessCompliance(record, data),
1774
+ controls: CONTROLS.map((control) => {
1775
+ const found = Object.entries(FRAMEWORKS).find(([, f]) => control.id.startsWith(`${f.short} · `));
1776
+ const code = found === void 0 ? void 0 : control.id.split(" · ")[1];
1777
+ const named = found === void 0 || code === void 0 ? void 0 : found[1].controls[code];
1778
+ const standard = control.standard;
1779
+ const name = named?.name ?? control.name;
1780
+ return {
1781
+ id: code ?? (standard === void 0 ? control.id : control.id.split(" · ")[1] ?? control.id),
1782
+ ...name === void 0 ? {} : { name },
1783
+ asks: control.asks,
1784
+ framework: found?.[0] ?? standard?.id ?? "obligation",
1785
+ ...found === void 0 ? standard === void 0 ? {} : { frameworkName: standard.name } : { frameworkName: found[1].name },
1786
+ ...control.evidence(data)
1787
+ };
1788
+ })
1789
+ };
1016
1790
  }
1017
1791
  /**
1018
1792
  * The record over HTTP, for the Command Center (apps/console): the same
@@ -1069,6 +1843,10 @@ function handleApi(req, res, record) {
1069
1843
  json(complianceOf(record));
1070
1844
  return true;
1071
1845
  }
1846
+ if (route === "/api/packs") {
1847
+ json(packsOf(record));
1848
+ return true;
1849
+ }
1072
1850
  if (route === "/api/redteam") {
1073
1851
  json(readRedteam(dir, files));
1074
1852
  return true;
@@ -1167,6 +1945,12 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1167
1945
  prae check <pack.json>
1168
1946
  Validate a policy pack and summarise what it enforces.
1169
1947
 
1948
+ prae check <dir> [--policy-key <policy.pub.pem>]
1949
+ Validate a workspace's packs (e.g. .prae/packs) exactly as the gate
1950
+ loads them: every *.prae.json / .yaml in name order, each rejection
1951
+ with its reason, and, with --policy-key, each .sig checked. Exits
1952
+ non-zero if any pack would be rejected.
1953
+
1170
1954
  prae explain <pack.json> <tool> [key=value ...]
1171
1955
  Judge one call against the pack and print the trace. Nothing is executed.
1172
1956
 
@@ -1193,6 +1977,14 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1193
1977
  --checkpoint, also check that nothing before that checkpoint's head
1194
1978
  has been removed or rewritten since it was taken.
1195
1979
 
1980
+ prae chain <ledger.jsonl> --pack <pack.json> [--guardians <guardians.json>]
1981
+ [--key <public.pem>] [--json]
1982
+ The Attack Chain Integrity Matrix: each attack-chain row from the
1983
+ attempted action to its seal — capability, Guardian, rule, decision,
1984
+ whether it ran, the stage and why, the hash and the signature. The
1985
+ stage is classified from the sealed row and the pack, never typed in.
1986
+ Exits non-zero if the chain or (with --key) any signature fails.
1987
+
1196
1988
  prae checkpoint <ledger.jsonl> [--key <private.pem>] [--out <file>]
1197
1989
  Record the ledger's length and head, signed, to keep somewhere the
1198
1990
  ledger's machine cannot write. A chain proves no row inside the file
@@ -1209,6 +2001,20 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1209
2001
  Generate an Ed25519 keypair for signing ledger rows. Prints the
1210
2002
  fingerprint a counterparty pins.
1211
2003
 
2004
+ prae keygen --role operator|policy|ledger [--out <dir>]
2005
+ A keypair whose private half is sealed under a passphrase (asked for
2006
+ without echo, or PRAE_KEY_PASSPHRASE). The operator key signs answers
2007
+ to held calls; the policy key signs packs. Pin the public half in the
2008
+ gate (operator.publicKey / policyKey); the agent can read the sealed
2009
+ file and still cannot sign. The ledger key is for the signer that
2010
+ holds it on the harness's behalf (signerFd): prae-signing.*.
2011
+
2012
+ prae sign-pack <pack.json> --key <policy.key.sealed.pem>
2013
+ Sign a pack's exact bytes with the sealed policy key (passphrase asked
2014
+ without echo, or PRAE_KEY_PASSPHRASE), writing <pack.json>.sig. A gate
2015
+ with policyKey pinned governs only by a signed pack whose version
2016
+ rises; raise the version before signing.
2017
+
1212
2018
  prae receipt <ledger.jsonl> <seq> --key <private.pem> [--out <file>]
1213
2019
  Export one decision as a self-contained signed receipt.
1214
2020
 
@@ -1236,13 +2042,16 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1236
2042
  Read the instruction files at <dir> and propose the rules they imply.
1237
2043
  Proposals only: an existing pack is never modified.
1238
2044
 
1239
- prae init [--pack <file>] [--profile <dir>] [--ledger <file>]
2045
+ prae init [--pack <file> | --pack-dir <dir>] [--profile <dir>] [--ledger <file>]
1240
2046
  (no --pack mounts the built-in baseline of 8 high-risk rules)
1241
2047
  [--approver <name>] [--link] [--shadow] [--apply]
1242
2048
  Compose the overlay row that mounts PRAE with a pack. Prints the
1243
2049
  diff; writes only with --apply. --link points the profile at the
1244
2050
  pack file instead of copying it in, so edits hot-reload; --shadow
1245
2051
  mounts in shadow mode: every verdict sealed, every call let through.
2052
+ --pack-dir adds the workspace's own packs (e.g. .prae/packs) beside
2053
+ that pack, loaded and watched as a set; a set that would not load is
2054
+ not mounted (see \`prae check <dir>\`).
1246
2055
  `;
1247
2056
  function fail(message) {
1248
2057
  console.error(`prae: ${message}`);
@@ -1263,7 +2072,25 @@ async function loadPack(path) {
1263
2072
  }
1264
2073
  return result.pack;
1265
2074
  }
1266
- async function check(path) {
2075
+ async function checkDir(dir, keyPath) {
2076
+ let key;
2077
+ if (keyPath !== void 0) try {
2078
+ key = await readFile(keyPath, "utf8");
2079
+ } catch {
2080
+ return fail(`cannot read ${keyPath}`);
2081
+ }
2082
+ const set = loadPackDir(dir, key);
2083
+ for (const p of set.packs) {
2084
+ console.log(`ok ${p.file.split("/").at(-1)} · ${p.pack.pack} ${p.pack.version} · ${p.pack.rules.length} rules · sha256 ${p.sha256.slice(0, 12)}${p.signedBy === void 0 ? "" : ` · signed ${p.signedBy}`}`);
2085
+ for (const rule of p.pack.rules) console.log(` ${rule.id.padEnd(14)} ${(rule.effect.kind === "ask" ? "HOLD" : rule.effect.kind.toUpperCase()).padEnd(8)} ${rule.title ?? rule.effect.because}`);
2086
+ }
2087
+ for (const r of set.rejected) console.error(`REJECTED ${r.file.split("/").at(-1)}\n ${r.reason}`);
2088
+ if (set.packs.length === 0) console.error("\nNo valid pack: the gate would refuse every call until one loads.");
2089
+ if (set.rejected.length > 0) process.exit(1);
2090
+ console.log(`\n${set.packs.length} pack${set.packs.length === 1 ? "" : "s"} valid · in force as ${set.effective.pack} ${set.effective.version}`);
2091
+ }
2092
+ async function check(path, argv = []) {
2093
+ if (existsSync(path) && statSync(path).isDirectory()) return checkDir(path, flag(argv, "policy-key"));
1267
2094
  const pack = await loadPack(path);
1268
2095
  console.log(`${pack.pack} ${pack.version} — valid`);
1269
2096
  const baseline = pack.baseline?.capabilities;
@@ -1324,13 +2151,13 @@ async function scanFile(path, packPath, json) {
1324
2151
  * not come up inspecting nothing.
1325
2152
  */
1326
2153
  async function mcp(root, packPath, ledgerPath) {
1327
- const { serveMcp } = await import("./mcp-doNYU-1C.js");
2154
+ const { serveMcp } = await import("./mcp-BCXbpqu4.js");
1328
2155
  const { resolve: resolve$1 } = await import("node:path");
1329
2156
  const bundled = [join(import.meta.dirname, "secret-catcher.prae.json"), join(import.meta.dirname, "..", "..", "..", "examples", "secret-catcher.prae.json")].find((candidate) => existsSync(candidate));
1330
2157
  const chosen = packPath ?? bundled;
1331
2158
  if (chosen === void 0) return fail("mcp needs --pack <file>; no bundled secret-catcher found");
1332
2159
  const pack = await loadPack(chosen);
1333
- const { Ledger: Ledger$1 } = await import("./src-C9L6NBvu.js");
2160
+ const { Ledger: Ledger$1 } = await import("./src-7X6dO7gC.js");
1334
2161
  const ledger = ledgerPath === void 0 ? void 0 : Ledger$1.open(ledgerPath);
1335
2162
  console.error(`prae mcp: serving ${resolve$1(root)} under ${pack.pack} ${pack.version}` + (ledgerPath === void 0 ? " (no ledger)" : ` · ledger ${ledgerPath}`));
1336
2163
  await serveMcp({
@@ -1382,7 +2209,7 @@ async function explain(path, tool, pairs) {
1382
2209
  * a key that arrived beside it is checked against nothing.
1383
2210
  */
1384
2211
  async function keygen(dir) {
1385
- const { generateKeypair, fingerprint: fingerprint$1 } = await import("./src-C9L6NBvu.js");
2212
+ const { generateKeypair, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
1386
2213
  const { writeFileSync: writeFileSync$1 } = await import("node:fs");
1387
2214
  const keys = generateKeypair();
1388
2215
  const priv = join(dir, "prae-signing.pem");
@@ -1397,12 +2224,77 @@ async function keygen(dir) {
1397
2224
  console.log("receipts, through a channel they trust. Point the plugin at the");
1398
2225
  console.log("private key with `signingKey` to sign every row.");
1399
2226
  }
2227
+ /**
2228
+ * A sealed keypair for a human authority (PRA-106/107): written as
2229
+ * `<role>.pub.pem` and `<role>.key.sealed.pem`. The passphrase is never an
2230
+ * argument — arguments land in process listings and shell history.
2231
+ */
2232
+ async function keygenSealed(role, dir) {
2233
+ if (role !== "operator" && role !== "policy" && role !== "ledger") fail("--role is operator, policy or ledger");
2234
+ const stem = role === "ledger" ? "prae-signing" : role;
2235
+ const { sealedKeypair, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
2236
+ const { writeFileSync: writeFileSync$1 } = await import("node:fs");
2237
+ const keys = sealedKeypair(process.env.PRAE_KEY_PASSPHRASE ?? await askHidden("passphrase: "));
2238
+ const pub = join(dir, `${stem}.pub.pem`);
2239
+ const sealed = join(dir, `${stem}.key.sealed.pem`);
2240
+ writeFileSync$1(sealed, keys.sealedPrivateKey, { mode: 384 });
2241
+ writeFileSync$1(pub, keys.publicKey);
2242
+ console.log(`sealed ${sealed} (useless without the passphrase)`);
2243
+ console.log(`public ${pub}`);
2244
+ console.log(`fingerprint ${fingerprint$1(keys.publicKey)}`);
2245
+ }
2246
+ /** A detached signature over a pack's bytes (PRA-107). */
2247
+ async function signPack(packPath, keyPath) {
2248
+ const { signBytes, unsealPrivateKey, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
2249
+ const { createPublicKey: createPublicKey$1 } = await import("node:crypto");
2250
+ const bytes = readFileSync(packPath);
2251
+ const parsed = parsePack(bytes.toString("utf8"));
2252
+ if (!parsed.ok) fail(`${packPath} does not parse; nothing signed`);
2253
+ const passphrase = process.env.PRAE_KEY_PASSPHRASE ?? await askHidden("passphrase: ");
2254
+ let key;
2255
+ try {
2256
+ key = unsealPrivateKey(readFileSync(keyPath, "utf8"), passphrase);
2257
+ } catch {
2258
+ fail("wrong passphrase, or not a sealed key");
2259
+ }
2260
+ await writeFile(`${packPath}.sig`, `${signBytes(bytes, key)}\n`);
2261
+ const pub = createPublicKey$1(key).export({
2262
+ type: "spki",
2263
+ format: "pem"
2264
+ });
2265
+ console.log(`signed ${packPath}.sig (${parsed.pack.pack} ${parsed.pack.version})`);
2266
+ console.log(`policy key ${fingerprint$1(pub.toString())}`);
2267
+ }
2268
+ /** Read a line from the terminal without echoing it. */
2269
+ function askHidden(prompt) {
2270
+ const input = process.stdin;
2271
+ if (!input.isTTY) fail("no terminal to ask on: set PRAE_KEY_PASSPHRASE");
2272
+ process.stdout.write(prompt);
2273
+ input.setRawMode(true);
2274
+ input.resume();
2275
+ input.setEncoding("utf8");
2276
+ return new Promise((done) => {
2277
+ let typed = "";
2278
+ const onData = (ch) => {
2279
+ if (ch === "\r" || ch === "\n") {
2280
+ input.setRawMode(false);
2281
+ input.pause();
2282
+ input.off("data", onData);
2283
+ process.stdout.write("\n");
2284
+ done(typed);
2285
+ } else if (ch === "") process.exit(130);
2286
+ else if (ch === "") typed = typed.slice(0, -1);
2287
+ else typed += ch;
2288
+ };
2289
+ input.on("data", onData);
2290
+ });
2291
+ }
1400
2292
  async function receipt(path, seq, keyPath, out) {
1401
- const { Ledger: Ledger$1, makeReceipt, fingerprint: fingerprint$1 } = await import("./src-C9L6NBvu.js");
2293
+ const { Ledger: Ledger$1, makeReceipt, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
1402
2294
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1403
- const { createPublicKey } = await import("node:crypto");
2295
+ const { createPublicKey: createPublicKey$1 } = await import("node:crypto");
1404
2296
  const privateKey = readFileSync$1(keyPath, "utf8");
1405
- const publicKey = createPublicKey(privateKey).export({
2297
+ const publicKey = createPublicKey$1(privateKey).export({
1406
2298
  type: "spki",
1407
2299
  format: "pem"
1408
2300
  }).toString();
@@ -1428,7 +2320,7 @@ async function receipt(path, seq, keyPath, out) {
1428
2320
  * file and sets the exit code, so scripts can branch on it.
1429
2321
  */
1430
2322
  async function verifyReceiptCmd(path) {
1431
- const { checkReceipt } = await import("./receipt-check-s3Aryyz-.js");
2323
+ const { checkReceipt } = await import("./receipt-check-DQK_gVPc.js");
1432
2324
  let parsed;
1433
2325
  try {
1434
2326
  parsed = JSON.parse(await readFile(path, "utf8"));
@@ -1459,7 +2351,7 @@ async function test(suitePath, packOverride, asJson) {
1459
2351
  } catch (error) {
1460
2352
  return fail(`${suitePath} is not valid JSON: ${error instanceof Error ? error.message : error}`);
1461
2353
  }
1462
- const { parseSuite, renderSuite, runSuite } = await import("./test-run-XNIRHKOu.js");
2354
+ const { parseSuite, renderSuite, runSuite } = await import("./test-run-CCDgJ0Dp.js");
1463
2355
  const suite = parseSuite(parsed);
1464
2356
  if (!suite.ok) {
1465
2357
  console.error(`prae: ${suitePath} is not a valid test suite`);
@@ -1486,7 +2378,7 @@ async function test(suitePath, packOverride, asJson) {
1486
2378
  else for (const line of renderSuite(result)) console.log(line);
1487
2379
  if (!result.ok) process.exit(1);
1488
2380
  }
1489
- async function verify(path, keyPath, checkpointPath) {
2381
+ async function verify$1(path, keyPath, checkpointPath) {
1490
2382
  let ledger;
1491
2383
  try {
1492
2384
  ledger = Ledger.open(path);
@@ -1499,14 +2391,14 @@ async function verify(path, keyPath, checkpointPath) {
1499
2391
  const last = entries.at(-1);
1500
2392
  console.log(`${result.entries} entries · chain intact${last === void 0 ? "" : ` · seal ${seal(last)}`}`);
1501
2393
  if (checkpointPath !== void 0) {
1502
- const { checkCheckpoint } = await import("./checkpoint-DiznOVpS.js");
2394
+ const { checkCheckpoint } = await import("./checkpoint-C_q_Rpa5.js");
1503
2395
  const publicKey = keyPath === void 0 ? void 0 : readFileSync(keyPath, "utf8");
1504
2396
  const verdict = checkCheckpoint(entries, JSON.parse(readFileSync(checkpointPath, "utf8")), publicKey);
1505
2397
  for (const line of verdict.lines) (verdict.ok ? console.log : console.error)(`checkpoint: ${line}`);
1506
2398
  if (!verdict.ok) process.exitCode = 1;
1507
2399
  }
1508
2400
  if (keyPath !== void 0) {
1509
- const { verifySignature: verifySignature$1, fingerprint: fingerprint$1 } = await import("./src-C9L6NBvu.js");
2401
+ const { verifySignature: verifySignature$1, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
1510
2402
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1511
2403
  const publicKey = readFileSync$1(keyPath, "utf8");
1512
2404
  let signed = 0;
@@ -1682,7 +2574,7 @@ async function consoleOut(path, argv) {
1682
2574
  const [{ ingest }, { extract }, { renderInstructions }] = await Promise.all([
1683
2575
  import("./src-d4c_n6wd.js"),
1684
2576
  import("./src-CBGZVvt7.js"),
1685
- import("./instructions-DulxSqFd.js")
2577
+ import("./instructions-ya0LVnZy.js")
1686
2578
  ]);
1687
2579
  const read = ingest(instructionsDir);
1688
2580
  instructionsView = renderInstructions(data, read, extract(read.statements));
@@ -1733,14 +2625,29 @@ async function init(argv) {
1733
2625
  }
1734
2626
  const pack = await loadPack(packPath);
1735
2627
  const source = await readFile(packPath, "utf8");
2628
+ const link = argv.includes("--link");
2629
+ const builtin = link ? { packFile: resolve(packPath) } : { pack: source };
2630
+ const builtinLine = `${pack.pack} ${pack.version} · ${pack.rules.length} rules${link ? ` · linked, edits hot-reload (${resolve(packPath)})` : ""}`;
2631
+ const packDirFlag = flag(argv, "pack-dir");
2632
+ if (packDirFlag === void 0) return writeRow(argv, builtin, builtinLine);
2633
+ const packDir = resolve(packDirFlag);
2634
+ const set = loadPackDir(packDir, void 0, void 0, readPackFile(packPath, void 0, "builtin"));
2635
+ for (const r of set.rejected) console.error(`REJECTED ${r.file.split("/").at(-1)}: ${r.reason}`);
2636
+ if (set.rejected.length > 0) fail(`not mounting ${packDir}: fix the packs above (prae check ${packDir})`);
2637
+ const custom = set.packs.filter((p) => p.origin === "workspace");
2638
+ return writeRow(argv, {
2639
+ ...builtin,
2640
+ packDir
2641
+ }, `${builtinLine}\n + ${custom.map((p) => `${p.pack.pack} ${p.pack.version}`).join(", ")} · workspace, watched (${packDir})`);
2642
+ }
2643
+ async function writeRow(argv, source, packLine) {
1736
2644
  const profileDir = flag(argv, "profile");
1737
2645
  const profile = profileDir === void 0 ? defaultProfile() : profileAt(profileDir);
1738
2646
  const ledger = flag(argv, "ledger");
1739
2647
  const approver = flag(argv, "approver");
1740
- const link = argv.includes("--link");
1741
2648
  const shadow = argv.includes("--shadow");
1742
2649
  const row$1 = praeRow({
1743
- ...link ? { packFile: resolve(packPath) } : { pack: source },
2650
+ ...source,
1744
2651
  ...shadow ? { mode: "shadow" } : {},
1745
2652
  ...ledger === void 0 ? {} : { ledger },
1746
2653
  ...approver === void 0 ? {} : { approver }
@@ -1753,7 +2660,7 @@ async function init(argv) {
1753
2660
  }
1754
2661
  console.log(`profile ${profile.dir}`);
1755
2662
  console.log(`overlay ${plan.patchPath}`);
1756
- console.log(`pack ${pack.pack} ${pack.version} · ${pack.rules.length} rules${link ? ` · linked, edits hot-reload (${resolve(packPath)})` : ""}`);
2663
+ console.log(`pack ${packLine}`);
1757
2664
  if (shadow) console.log("mode shadow · every verdict sealed, every call proceeds");
1758
2665
  if (ledger !== void 0) console.log(`ledger ${ledger}`);
1759
2666
  if (plan.findings.length > 0) {
@@ -1780,8 +2687,55 @@ async function init(argv) {
1780
2687
  return fail(error instanceof Error ? error.message : String(error));
1781
2688
  }
1782
2689
  }
2690
+ /** `prae chain`: the Attack Chain Integrity Matrix, from the record alone (PRA-128). */
2691
+ async function chainMatrix(path, argv) {
2692
+ const packPath = flag(argv, "pack");
2693
+ if (packPath === void 0) fail("chain needs --pack <pack.json>");
2694
+ const pack = await loadPack(packPath);
2695
+ let ledger;
2696
+ try {
2697
+ ledger = Ledger.open(path);
2698
+ } catch (error) {
2699
+ return fail(error instanceof Error ? error.message : String(error));
2700
+ }
2701
+ const entries = ledger.read();
2702
+ const chainOk = ledger.verify().ok;
2703
+ const guardiansPath = flag(argv, "guardians");
2704
+ const catalogue = guardiansPath === void 0 ? [] : JSON.parse(readFileSync(guardiansPath, "utf8")).guardians;
2705
+ const guardianOf = (rule) => rule === void 0 ? void 0 : catalogue.find((g) => g.rules.includes(rule) || (g.armed ?? []).includes(rule))?.name;
2706
+ const keyPath = flag(argv, "key");
2707
+ const { verifySignature: verifySignature$1 } = await import("./src-7X6dO7gC.js");
2708
+ const publicKey = keyPath === void 0 ? void 0 : readFileSync(keyPath, "utf8");
2709
+ const matrix = integrityMatrix(entries, pack, guardianOf).map((m) => {
2710
+ const entry = entries.find((e) => e.seq === m.seq);
2711
+ return {
2712
+ ...m,
2713
+ verified: publicKey === void 0 || entry?.sig === void 0 ? void 0 : verifySignature$1(entry, entry.sig, publicKey)
2714
+ };
2715
+ });
2716
+ const sigFailed = matrix.some((m) => m.verified === false);
2717
+ if (argv.includes("--json")) console.log(JSON.stringify({
2718
+ chain: chainOk,
2719
+ rows: matrix
2720
+ }, null, 2));
2721
+ else {
2722
+ for (const m of matrix) {
2723
+ console.log(`#${m.seq} ${m.stage}`);
2724
+ console.log(` attempted ${m.action}${m.target ? ` ${m.target}` : ""}`);
2725
+ console.log(` capability ${m.capability ?? "—"}`);
2726
+ console.log(` guardian ${m.guardian ?? "—"}`);
2727
+ console.log(` rule ${m.rule === void 0 ? "— (no rule matched)" : m.ruleRole === "recorded" ? `${m.rule} (matched and recorded; did not decide)` : m.rule}`);
2728
+ console.log(` decision ${m.decision} · ${m.execution}`);
2729
+ console.log(` basis ${m.basis}`);
2730
+ if (m.mismatch !== void 0) console.log(` mismatch row #${m.mismatch} (sealed link)`);
2731
+ console.log(` evidence hash ${m.hash.slice(0, 16)} · prev ${m.prev.slice(0, 16)} · ${m.verified === void 0 ? m.signed ? "signed" : "unsigned" : m.verified ? "signature verifies" : "SIGNATURE FAILS"}`);
2732
+ }
2733
+ console.log(`\n${matrix.length} attack-chain rows · chain ${chainOk ? "intact" : "BROKEN"}${publicKey === void 0 ? "" : sigFailed ? " · signatures FAIL" : " · signatures verify"}`);
2734
+ }
2735
+ if (!chainOk || sigFailed) process.exitCode = 1;
2736
+ }
1783
2737
  async function checkpoint(path, keyPath, out) {
1784
- const { makeCheckpoint } = await import("./checkpoint-DiznOVpS.js");
2738
+ const { makeCheckpoint } = await import("./checkpoint-C_q_Rpa5.js");
1785
2739
  let ledger;
1786
2740
  try {
1787
2741
  ledger = Ledger.open(path);
@@ -1839,8 +2793,8 @@ async function main(argv) {
1839
2793
  switch (command) {
1840
2794
  case "check": {
1841
2795
  const path = rest[0];
1842
- if (path === void 0) fail("check needs a pack file");
1843
- return check(path);
2796
+ if (path === void 0) fail("check needs a pack file or directory");
2797
+ return check(path, rest);
1844
2798
  }
1845
2799
  case "explain": {
1846
2800
  const [path, tool, ...pairs] = rest;
@@ -1857,7 +2811,17 @@ async function main(argv) {
1857
2811
  if (root === void 0) fail("mcp needs --root <dir>; serving without a boundary is not a gate");
1858
2812
  return mcp(root, flag(rest, "pack"), flag(rest, "ledger"));
1859
2813
  }
1860
- case "keygen": return keygen(flag(rest, "out") ?? ".");
2814
+ case "keygen": {
2815
+ const role = flag(rest, "role");
2816
+ if (role !== void 0) return keygenSealed(role, flag(rest, "out") ?? ".");
2817
+ return keygen(flag(rest, "out") ?? ".");
2818
+ }
2819
+ case "sign-pack": {
2820
+ const [path] = rest;
2821
+ const key = flag(rest, "key");
2822
+ if (path === void 0 || key === void 0) fail("sign-pack needs <pack.json> --key <policy.key.sealed.pem>");
2823
+ return signPack(path, key);
2824
+ }
1861
2825
  case "receipt": {
1862
2826
  const [path, seq] = rest;
1863
2827
  const key = flag(rest, "key");
@@ -1873,7 +2837,12 @@ async function main(argv) {
1873
2837
  case "verify": {
1874
2838
  const path = rest[0];
1875
2839
  if (path === void 0) fail("verify needs a ledger file");
1876
- return verify(path, flag(rest, "key"), flag(rest, "checkpoint"));
2840
+ return verify$1(path, flag(rest, "key"), flag(rest, "checkpoint"));
2841
+ }
2842
+ case "chain": {
2843
+ const path = rest[0];
2844
+ if (path === void 0) fail("chain needs a ledger file");
2845
+ return chainMatrix(path, rest);
1877
2846
  }
1878
2847
  case "checkpoint": {
1879
2848
  const path = rest[0];