prae-cli 0.9.1 → 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-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
- 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,33 +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
- const standard = control.standard;
1008
- const name = named?.name ?? control.name;
1009
- return {
1010
- id: code ?? (standard === void 0 ? control.id : control.id.split(" · ")[1] ?? control.id),
1011
- ...name === void 0 ? {} : { name },
1012
- asks: control.asks,
1013
- framework: found?.[0] ?? standard?.id ?? "obligation",
1014
- ...found === void 0 ? standard === void 0 ? {} : { frameworkName: standard.name } : { frameworkName: found[1].name },
1015
- ...control.evidence(data)
1016
- };
1017
- }) };
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
+ };
1018
1790
  }
1019
1791
  /**
1020
1792
  * The record over HTTP, for the Command Center (apps/console): the same
@@ -1071,6 +1843,10 @@ function handleApi(req, res, record) {
1071
1843
  json(complianceOf(record));
1072
1844
  return true;
1073
1845
  }
1846
+ if (route === "/api/packs") {
1847
+ json(packsOf(record));
1848
+ return true;
1849
+ }
1074
1850
  if (route === "/api/redteam") {
1075
1851
  json(readRedteam(dir, files));
1076
1852
  return true;
@@ -1169,6 +1945,12 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1169
1945
  prae check <pack.json>
1170
1946
  Validate a policy pack and summarise what it enforces.
1171
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
+
1172
1954
  prae explain <pack.json> <tool> [key=value ...]
1173
1955
  Judge one call against the pack and print the trace. Nothing is executed.
1174
1956
 
@@ -1195,6 +1977,14 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1195
1977
  --checkpoint, also check that nothing before that checkpoint's head
1196
1978
  has been removed or rewritten since it was taken.
1197
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
+
1198
1988
  prae checkpoint <ledger.jsonl> [--key <private.pem>] [--out <file>]
1199
1989
  Record the ledger's length and head, signed, to keep somewhere the
1200
1990
  ledger's machine cannot write. A chain proves no row inside the file
@@ -1252,13 +2042,16 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1252
2042
  Read the instruction files at <dir> and propose the rules they imply.
1253
2043
  Proposals only: an existing pack is never modified.
1254
2044
 
1255
- prae init [--pack <file>] [--profile <dir>] [--ledger <file>]
2045
+ prae init [--pack <file> | --pack-dir <dir>] [--profile <dir>] [--ledger <file>]
1256
2046
  (no --pack mounts the built-in baseline of 8 high-risk rules)
1257
2047
  [--approver <name>] [--link] [--shadow] [--apply]
1258
2048
  Compose the overlay row that mounts PRAE with a pack. Prints the
1259
2049
  diff; writes only with --apply. --link points the profile at the
1260
2050
  pack file instead of copying it in, so edits hot-reload; --shadow
1261
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>\`).
1262
2055
  `;
1263
2056
  function fail(message) {
1264
2057
  console.error(`prae: ${message}`);
@@ -1279,7 +2072,25 @@ async function loadPack(path) {
1279
2072
  }
1280
2073
  return result.pack;
1281
2074
  }
1282
- 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"));
1283
2094
  const pack = await loadPack(path);
1284
2095
  console.log(`${pack.pack} ${pack.version} — valid`);
1285
2096
  const baseline = pack.baseline?.capabilities;
@@ -1340,13 +2151,13 @@ async function scanFile(path, packPath, json) {
1340
2151
  * not come up inspecting nothing.
1341
2152
  */
1342
2153
  async function mcp(root, packPath, ledgerPath) {
1343
- const { serveMcp } = await import("./mcp-doNYU-1C.js");
2154
+ const { serveMcp } = await import("./mcp-BCXbpqu4.js");
1344
2155
  const { resolve: resolve$1 } = await import("node:path");
1345
2156
  const bundled = [join(import.meta.dirname, "secret-catcher.prae.json"), join(import.meta.dirname, "..", "..", "..", "examples", "secret-catcher.prae.json")].find((candidate) => existsSync(candidate));
1346
2157
  const chosen = packPath ?? bundled;
1347
2158
  if (chosen === void 0) return fail("mcp needs --pack <file>; no bundled secret-catcher found");
1348
2159
  const pack = await loadPack(chosen);
1349
- const { Ledger: Ledger$1 } = await import("./src-d82lSKVQ.js");
2160
+ const { Ledger: Ledger$1 } = await import("./src-7X6dO7gC.js");
1350
2161
  const ledger = ledgerPath === void 0 ? void 0 : Ledger$1.open(ledgerPath);
1351
2162
  console.error(`prae mcp: serving ${resolve$1(root)} under ${pack.pack} ${pack.version}` + (ledgerPath === void 0 ? " (no ledger)" : ` · ledger ${ledgerPath}`));
1352
2163
  await serveMcp({
@@ -1398,7 +2209,7 @@ async function explain(path, tool, pairs) {
1398
2209
  * a key that arrived beside it is checked against nothing.
1399
2210
  */
1400
2211
  async function keygen(dir) {
1401
- const { generateKeypair, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
2212
+ const { generateKeypair, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
1402
2213
  const { writeFileSync: writeFileSync$1 } = await import("node:fs");
1403
2214
  const keys = generateKeypair();
1404
2215
  const priv = join(dir, "prae-signing.pem");
@@ -1421,7 +2232,7 @@ async function keygen(dir) {
1421
2232
  async function keygenSealed(role, dir) {
1422
2233
  if (role !== "operator" && role !== "policy" && role !== "ledger") fail("--role is operator, policy or ledger");
1423
2234
  const stem = role === "ledger" ? "prae-signing" : role;
1424
- const { sealedKeypair, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
2235
+ const { sealedKeypair, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
1425
2236
  const { writeFileSync: writeFileSync$1 } = await import("node:fs");
1426
2237
  const keys = sealedKeypair(process.env.PRAE_KEY_PASSPHRASE ?? await askHidden("passphrase: "));
1427
2238
  const pub = join(dir, `${stem}.pub.pem`);
@@ -1434,8 +2245,8 @@ async function keygenSealed(role, dir) {
1434
2245
  }
1435
2246
  /** A detached signature over a pack's bytes (PRA-107). */
1436
2247
  async function signPack(packPath, keyPath) {
1437
- const { signBytes, unsealPrivateKey, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
1438
- const { createPublicKey } = await import("node:crypto");
2248
+ const { signBytes, unsealPrivateKey, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
2249
+ const { createPublicKey: createPublicKey$1 } = await import("node:crypto");
1439
2250
  const bytes = readFileSync(packPath);
1440
2251
  const parsed = parsePack(bytes.toString("utf8"));
1441
2252
  if (!parsed.ok) fail(`${packPath} does not parse; nothing signed`);
@@ -1447,7 +2258,7 @@ async function signPack(packPath, keyPath) {
1447
2258
  fail("wrong passphrase, or not a sealed key");
1448
2259
  }
1449
2260
  await writeFile(`${packPath}.sig`, `${signBytes(bytes, key)}\n`);
1450
- const pub = createPublicKey(key).export({
2261
+ const pub = createPublicKey$1(key).export({
1451
2262
  type: "spki",
1452
2263
  format: "pem"
1453
2264
  });
@@ -1479,11 +2290,11 @@ function askHidden(prompt) {
1479
2290
  });
1480
2291
  }
1481
2292
  async function receipt(path, seq, keyPath, out) {
1482
- const { Ledger: Ledger$1, makeReceipt, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
2293
+ const { Ledger: Ledger$1, makeReceipt, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
1483
2294
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1484
- const { createPublicKey } = await import("node:crypto");
2295
+ const { createPublicKey: createPublicKey$1 } = await import("node:crypto");
1485
2296
  const privateKey = readFileSync$1(keyPath, "utf8");
1486
- const publicKey = createPublicKey(privateKey).export({
2297
+ const publicKey = createPublicKey$1(privateKey).export({
1487
2298
  type: "spki",
1488
2299
  format: "pem"
1489
2300
  }).toString();
@@ -1509,7 +2320,7 @@ async function receipt(path, seq, keyPath, out) {
1509
2320
  * file and sets the exit code, so scripts can branch on it.
1510
2321
  */
1511
2322
  async function verifyReceiptCmd(path) {
1512
- const { checkReceipt } = await import("./receipt-check-CD-W1wXs.js");
2323
+ const { checkReceipt } = await import("./receipt-check-DQK_gVPc.js");
1513
2324
  let parsed;
1514
2325
  try {
1515
2326
  parsed = JSON.parse(await readFile(path, "utf8"));
@@ -1540,7 +2351,7 @@ async function test(suitePath, packOverride, asJson) {
1540
2351
  } catch (error) {
1541
2352
  return fail(`${suitePath} is not valid JSON: ${error instanceof Error ? error.message : error}`);
1542
2353
  }
1543
- const { parseSuite, renderSuite, runSuite } = await import("./test-run-XNIRHKOu.js");
2354
+ const { parseSuite, renderSuite, runSuite } = await import("./test-run-CCDgJ0Dp.js");
1544
2355
  const suite = parseSuite(parsed);
1545
2356
  if (!suite.ok) {
1546
2357
  console.error(`prae: ${suitePath} is not a valid test suite`);
@@ -1567,7 +2378,7 @@ async function test(suitePath, packOverride, asJson) {
1567
2378
  else for (const line of renderSuite(result)) console.log(line);
1568
2379
  if (!result.ok) process.exit(1);
1569
2380
  }
1570
- async function verify(path, keyPath, checkpointPath) {
2381
+ async function verify$1(path, keyPath, checkpointPath) {
1571
2382
  let ledger;
1572
2383
  try {
1573
2384
  ledger = Ledger.open(path);
@@ -1580,14 +2391,14 @@ async function verify(path, keyPath, checkpointPath) {
1580
2391
  const last = entries.at(-1);
1581
2392
  console.log(`${result.entries} entries · chain intact${last === void 0 ? "" : ` · seal ${seal(last)}`}`);
1582
2393
  if (checkpointPath !== void 0) {
1583
- const { checkCheckpoint } = await import("./checkpoint-BWSuhZ5h.js");
2394
+ const { checkCheckpoint } = await import("./checkpoint-C_q_Rpa5.js");
1584
2395
  const publicKey = keyPath === void 0 ? void 0 : readFileSync(keyPath, "utf8");
1585
2396
  const verdict = checkCheckpoint(entries, JSON.parse(readFileSync(checkpointPath, "utf8")), publicKey);
1586
2397
  for (const line of verdict.lines) (verdict.ok ? console.log : console.error)(`checkpoint: ${line}`);
1587
2398
  if (!verdict.ok) process.exitCode = 1;
1588
2399
  }
1589
2400
  if (keyPath !== void 0) {
1590
- const { verifySignature: verifySignature$1, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
2401
+ const { verifySignature: verifySignature$1, fingerprint: fingerprint$1 } = await import("./src-7X6dO7gC.js");
1591
2402
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1592
2403
  const publicKey = readFileSync$1(keyPath, "utf8");
1593
2404
  let signed = 0;
@@ -1763,7 +2574,7 @@ async function consoleOut(path, argv) {
1763
2574
  const [{ ingest }, { extract }, { renderInstructions }] = await Promise.all([
1764
2575
  import("./src-d4c_n6wd.js"),
1765
2576
  import("./src-CBGZVvt7.js"),
1766
- import("./instructions-Bg5puOen.js")
2577
+ import("./instructions-ya0LVnZy.js")
1767
2578
  ]);
1768
2579
  const read = ingest(instructionsDir);
1769
2580
  instructionsView = renderInstructions(data, read, extract(read.statements));
@@ -1814,14 +2625,29 @@ async function init(argv) {
1814
2625
  }
1815
2626
  const pack = await loadPack(packPath);
1816
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) {
1817
2644
  const profileDir = flag(argv, "profile");
1818
2645
  const profile = profileDir === void 0 ? defaultProfile() : profileAt(profileDir);
1819
2646
  const ledger = flag(argv, "ledger");
1820
2647
  const approver = flag(argv, "approver");
1821
- const link = argv.includes("--link");
1822
2648
  const shadow = argv.includes("--shadow");
1823
2649
  const row$1 = praeRow({
1824
- ...link ? { packFile: resolve(packPath) } : { pack: source },
2650
+ ...source,
1825
2651
  ...shadow ? { mode: "shadow" } : {},
1826
2652
  ...ledger === void 0 ? {} : { ledger },
1827
2653
  ...approver === void 0 ? {} : { approver }
@@ -1834,7 +2660,7 @@ async function init(argv) {
1834
2660
  }
1835
2661
  console.log(`profile ${profile.dir}`);
1836
2662
  console.log(`overlay ${plan.patchPath}`);
1837
- console.log(`pack ${pack.pack} ${pack.version} · ${pack.rules.length} rules${link ? ` · linked, edits hot-reload (${resolve(packPath)})` : ""}`);
2663
+ console.log(`pack ${packLine}`);
1838
2664
  if (shadow) console.log("mode shadow · every verdict sealed, every call proceeds");
1839
2665
  if (ledger !== void 0) console.log(`ledger ${ledger}`);
1840
2666
  if (plan.findings.length > 0) {
@@ -1861,8 +2687,55 @@ async function init(argv) {
1861
2687
  return fail(error instanceof Error ? error.message : String(error));
1862
2688
  }
1863
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
+ }
1864
2737
  async function checkpoint(path, keyPath, out) {
1865
- const { makeCheckpoint } = await import("./checkpoint-BWSuhZ5h.js");
2738
+ const { makeCheckpoint } = await import("./checkpoint-C_q_Rpa5.js");
1866
2739
  let ledger;
1867
2740
  try {
1868
2741
  ledger = Ledger.open(path);
@@ -1920,8 +2793,8 @@ async function main(argv) {
1920
2793
  switch (command) {
1921
2794
  case "check": {
1922
2795
  const path = rest[0];
1923
- if (path === void 0) fail("check needs a pack file");
1924
- return check(path);
2796
+ if (path === void 0) fail("check needs a pack file or directory");
2797
+ return check(path, rest);
1925
2798
  }
1926
2799
  case "explain": {
1927
2800
  const [path, tool, ...pairs] = rest;
@@ -1964,7 +2837,12 @@ async function main(argv) {
1964
2837
  case "verify": {
1965
2838
  const path = rest[0];
1966
2839
  if (path === void 0) fail("verify needs a ledger file");
1967
- 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);
1968
2846
  }
1969
2847
  case "checkpoint": {
1970
2848
  const path = rest[0];