prae-cli 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,438 +1,388 @@
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 { a as withinAny, r as hostOf, t as evaluate } from "./evaluate-DhoGQw0Z.js";
2
+ import { n as cite, t as FRAMEWORKS } from "./frameworks-BgZHGKiH.js";
3
+ import { t as parsePack } from "./pack-h5n43S0F.js";
4
+ import { a as verifyEntries, b as seal, g as GENESIS, h as verifySignature, l as verifyBytes, n as Ledger, r as readEntries, t as verifyGrowing, u as fingerprint, v as chain } from "./src-Cy-Acrgj.js";
5
+ import { a as renderPacks, c as renderNav, 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-CUcixte0.js";
6
+ import { createRequire } from "node:module";
7
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
5
8
  import { mkdir, readFile, writeFile } from "node:fs/promises";
6
- import { dirname, extname, join, normalize, resolve, sep } from "node:path";
9
+ import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
10
+ import { createHash } from "node:crypto";
7
11
  import { homedir } from "node:os";
12
+ import { parse } from "yaml";
8
13
  import { composePatchFile, preflightOps, revisionOf, takeSnapshot } from "dsh-overlay-check";
9
14
  import { createServer } from "node:http";
10
15
 
11
- //#region ../kernel/src/pack.ts
12
- function isObject(value) {
13
- return typeof value === "object" && value !== null && !Array.isArray(value);
14
- }
15
- var Check = class {
16
- errors = [];
17
- fail(path, message) {
18
- this.errors.push({
19
- path,
20
- message
21
- });
22
- }
23
- /** Reject any key the shape does not define. */
24
- keys(path, value, allowed) {
25
- for (const key of Object.keys(value)) if (!allowed.includes(key)) this.fail(`${path}.${key}`, `unknown field \`${key}\`; expected one of ${allowed.map((k) => `\`${k}\``).join(", ")}`);
26
- }
27
- text(path, value) {
28
- if (typeof value !== "string" || value.trim() === "") {
29
- this.fail(path, "expected a non-empty string");
30
- return;
31
- }
32
- return value;
33
- }
34
- /** A field that accepts one name or a list of them. */
35
- names(path, value) {
36
- if (typeof value === "string") return value;
37
- if (Array.isArray(value)) {
38
- const out = [];
39
- value.forEach((entry, index) => {
40
- const one = this.text(`${path}[${index}]`, entry);
41
- if (one !== void 0) out.push(one);
42
- });
43
- return out;
44
- }
45
- this.fail(path, "expected a string or an array of strings");
46
- }
47
- globs(path, value) {
48
- if (!Array.isArray(value)) {
49
- this.fail(path, "expected an array of path globs");
50
- return;
51
- }
52
- const out = [];
53
- value.forEach((entry, index) => {
54
- const one = this.text(`${path}[${index}]`, entry);
55
- if (one !== void 0) out.push(one);
56
- });
57
- return out;
58
- }
59
- number(path, value) {
60
- if (typeof value !== "number" || !Number.isFinite(value)) {
61
- this.fail(path, "expected a finite number");
62
- return;
63
- }
64
- return value;
65
- }
66
- };
67
- function readCommandMatch(check$1, path, value) {
68
- if (!isObject(value)) {
69
- check$1.fail(path, "expected an object");
70
- return;
71
- }
72
- check$1.keys(path, value, [
73
- "name",
74
- "token",
75
- "anyToken",
76
- "pipedTo",
77
- "pipeline"
78
- ]);
79
- if (value.name === void 0 && value.token === void 0 && value.anyToken === void 0 && value.pipedTo === void 0 && value.pipeline === void 0) check$1.fail(path, "expected `name`, `token`, `anyToken`, `pipedTo` or `pipeline`; a command matcher with none matches nothing");
80
- const out = {};
81
- if (value.token !== void 0) {
82
- const tokens = check$1.names(`${path}.token`, value.token);
83
- if (tokens !== void 0) out.token = tokens;
84
- }
85
- if (value.name !== void 0) {
86
- const names = check$1.names(`${path}.name`, value.name);
87
- if (names !== void 0) out.name = names;
88
- }
89
- if (value.anyToken !== void 0) {
90
- const tokens = check$1.names(`${path}.anyToken`, value.anyToken);
91
- if (tokens !== void 0) out.anyToken = tokens;
92
- }
93
- if (value.pipedTo !== void 0) {
94
- const pipes = check$1.names(`${path}.pipedTo`, value.pipedTo);
95
- if (pipes !== void 0) out.pipedTo = pipes;
96
- }
97
- if (value.pipeline !== void 0) {
98
- const shapes = check$1.names(`${path}.pipeline`, value.pipeline);
99
- if (shapes !== void 0) out.pipeline = shapes;
100
- }
101
- return out;
102
- }
103
- function readPathMatch(check$1, path, value) {
104
- if (!isObject(value)) {
105
- check$1.fail(path, "expected an object");
106
- return;
107
- }
108
- check$1.keys(path, value, [
109
- "arg",
110
- "outside",
111
- "inside"
112
- ]);
113
- if (value.outside === void 0 && value.inside === void 0) check$1.fail(path, "expected `inside` or `outside`; a path matcher with neither matches nothing");
114
- const out = {};
115
- if (value.arg !== void 0) {
116
- const arg = check$1.text(`${path}.arg`, value.arg);
117
- if (arg !== void 0) out.arg = arg;
118
- }
119
- if (value.outside !== void 0) {
120
- const globs = check$1.globs(`${path}.outside`, value.outside);
121
- if (globs !== void 0) out.outside = globs;
122
- }
123
- if (value.inside !== void 0) {
124
- const globs = check$1.globs(`${path}.inside`, value.inside);
125
- if (globs !== void 0) out.inside = globs;
126
- }
127
- return out;
128
- }
129
- function readHostMatch(check$1, path, value) {
130
- if (!isObject(value)) {
131
- check$1.fail(path, "expected an object");
132
- return;
133
- }
134
- check$1.keys(path, value, [
135
- "arg",
136
- "in",
137
- "notIn"
138
- ]);
139
- if (value.in === void 0 && value.notIn === void 0) check$1.fail(path, "expected `in` or `notIn`; a host matcher with neither matches nothing");
140
- const out = {};
141
- if (value.arg !== void 0) {
142
- const arg = check$1.text(`${path}.arg`, value.arg);
143
- if (arg !== void 0) out.arg = arg;
144
- }
145
- if (value.in !== void 0) {
146
- const list = check$1.globs(`${path}.in`, value.in);
147
- if (list !== void 0) out.in = list;
16
+ //#region ../dsh/src/chain.ts
17
+ const STAGES = [
18
+ {
19
+ id: "untrusted-input",
20
+ step: 1,
21
+ name: "UNTRUSTED INPUT",
22
+ severity: "INFO",
23
+ 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.",
24
+ rule: "fs:read of a path under the untrusted globs (default **/tickets/**), or a row whose screening outcome is steer or hold.",
25
+ owasp: ["ASI01"],
26
+ atlas: ["AML.T0051.001"],
27
+ 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."
28
+ },
29
+ {
30
+ id: "credential-access",
31
+ step: 2,
32
+ name: "CREDENTIAL ACCESS",
33
+ severity: "HIGH",
34
+ definition: "The agent tried to reach a credential: a file that is one by its path, or content the inspector found holding one.",
35
+ 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, …).",
36
+ owasp: ["ASI03"],
37
+ atlas: [
38
+ "AML.TA0013",
39
+ "AML.T0055",
40
+ "AML.T0098"
41
+ ],
42
+ 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)."
43
+ },
44
+ {
45
+ id: "exfiltration",
46
+ step: 3,
47
+ name: "EXFILTRATION ATTEMPT",
48
+ severity: "CRITICAL",
49
+ 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.",
50
+ 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.",
51
+ owasp: ["ASI02"],
52
+ atlas: ["AML.TA0010", "AML.T0086"],
53
+ 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."
54
+ },
55
+ {
56
+ id: "token-abuse",
57
+ step: 4,
58
+ name: "TOKEN ABUSE",
59
+ severity: "CRITICAL",
60
+ 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.",
61
+ rule: "Stage 3, and the URL path has a segment token, oauth, oauth2 or sts.",
62
+ owasp: ["ASI03"],
63
+ atlas: ["AML.TA0013", "AML.TA0010"],
64
+ 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."
65
+ },
66
+ {
67
+ id: "runaway",
68
+ step: 5,
69
+ name: "RUNAWAY BEHAVIOUR",
70
+ severity: "HIGH",
71
+ definition: "The agent kept calling past a budget the pack sets for the session.",
72
+ rule: "The deciding or a matched rule has a count matcher.",
73
+ owasp: ["ASI08"],
74
+ atlas: ["AML.T0034.002"],
75
+ alignment: "ATLAS Agentic Resource Consumption. OWASP ASI08 (Cascading Failures) is the nearest Agentic entry, not an equivalent."
148
76
  }
149
- if (value.notIn !== void 0) {
150
- const list = check$1.globs(`${path}.notIn`, value.notIn);
151
- if (list !== void 0) out.notIn = list;
77
+ ];
78
+ /** Paths whose content the classifier treats as from outside the boundary. */
79
+ const UNTRUSTED_GLOBS = ["**/tickets/**", "./tickets/**"];
80
+ /** Paths that are credentials by location, whatever they contain. */
81
+ const CREDENTIAL_GLOBS = [
82
+ "/etc/shadow",
83
+ "/etc/gshadow",
84
+ "/etc/sudoers",
85
+ "**/.ssh/**",
86
+ "**/.aws/**",
87
+ "**/.gnupg/**",
88
+ "**/*.pem",
89
+ "**/*.key",
90
+ "**/.env",
91
+ "**/.env.*",
92
+ "**/credentials/**",
93
+ "**/.netrc"
94
+ ];
95
+ const NETWORK_CLIENTS = new Set([
96
+ "curl",
97
+ "wget",
98
+ "nc",
99
+ "ncat",
100
+ "netcat",
101
+ "ssh",
102
+ "scp",
103
+ "sftp",
104
+ "ftp",
105
+ "telnet",
106
+ "openssl"
107
+ ]);
108
+ const TOKEN_SEGMENT = new Set([
109
+ "token",
110
+ "oauth",
111
+ "oauth2",
112
+ "sts"
113
+ ]);
114
+ function rulesOf(row$1, pack) {
115
+ if (pack === void 0) return [];
116
+ const ids = [row$1.rule, ...(row$1.trace ?? []).map((t) => t.rule)].filter((id) => id !== void 0);
117
+ return [...new Set(ids)].map((id) => pack.rules.find((r) => r.id === id)).filter((r) => r !== void 0);
118
+ }
119
+ function pathOf(url) {
120
+ try {
121
+ return new URL(url).pathname.toLowerCase().split("/").filter(Boolean);
122
+ } catch {
123
+ return [];
152
124
  }
153
- return out;
154
125
  }
155
- function readArgMatch(check$1, path, value) {
156
- if (!isObject(value)) {
157
- check$1.fail(path, "expected an object");
158
- return;
126
+ const stage = (id) => STAGES.find((s) => s.id === id);
127
+ /**
128
+ * The stage of one row, or undefined when the row is not an attack-chain
129
+ * step. Reads only the sealed row and the pack; the same inputs give the
130
+ * same answer every time. Checked in order: budget, credential, egress,
131
+ * untrusted input. The first that holds decides.
132
+ */
133
+ function classify(row$1, pack) {
134
+ if (row$1.tool.startsWith("prae:")) return void 0;
135
+ const rules = rulesOf(row$1, pack);
136
+ const target = row$1.subject ?? "";
137
+ const budget = rules.find((r) => r.when.count !== void 0);
138
+ if (budget !== void 0) return {
139
+ stage: stage("runaway"),
140
+ basis: `${budget.id} is a session budget (count above ${budget.when.count?.above})`
141
+ };
142
+ const secrets = rules.find((r) => r.when.secrets !== void 0);
143
+ if (secrets !== void 0 || (row$1.secrets?.length ?? 0) > 0) return {
144
+ stage: stage("credential-access"),
145
+ basis: secrets !== void 0 ? `${secrets.id} inspects content for credentials, and matched` : `content inspection found ${row$1.secrets?.length} credential finding(s)`
146
+ };
147
+ if (target !== "" && (row$1.capability === "fs:read" || row$1.capability === "fs:write" || row$1.capability === "proc:spawn") && withinAny(target, CREDENTIAL_GLOBS)) return {
148
+ stage: stage("credential-access"),
149
+ basis: `the target ${target} is a credential by its path`
150
+ };
151
+ const host = rules.find((r) => r.when.host !== void 0);
152
+ 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;
153
+ const egress = row$1.capability === "net:fetch" ? host : shell;
154
+ if (egress !== void 0) {
155
+ const token = row$1.capability === "net:fetch" ? pathOf(target).find((s) => TOKEN_SEGMENT.has(s)) : void 0;
156
+ 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}`;
157
+ return token !== void 0 ? {
158
+ stage: stage("token-abuse"),
159
+ basis: `${where} (${egress.id}), and the path names a token endpoint (/${token})`
160
+ } : {
161
+ stage: stage("exfiltration"),
162
+ basis: `${where} (${egress.id})`
163
+ };
159
164
  }
160
- check$1.keys(path, value, [
161
- "name",
162
- "above",
163
- "below",
164
- "equals",
165
- "oneOf",
166
- "present"
167
- ]);
168
- const name = check$1.text(`${path}.name`, value.name);
169
- if (name === void 0) return void 0;
170
- const out = { name };
171
- if (value.above !== void 0) {
172
- const n = check$1.number(`${path}.above`, value.above);
173
- if (n !== void 0) out.above = n;
174
- }
175
- if (value.below !== void 0) {
176
- const n = check$1.number(`${path}.below`, value.below);
177
- if (n !== void 0) out.below = n;
178
- }
179
- if (value.equals !== void 0) out.equals = value.equals;
180
- if (value.oneOf !== void 0) if (!Array.isArray(value.oneOf)) check$1.fail(`${path}.oneOf`, "expected an array");
181
- else out.oneOf = value.oneOf;
182
- if (value.present !== void 0) if (typeof value.present !== "boolean") check$1.fail(`${path}.present`, "expected a boolean");
183
- else out.present = value.present;
184
- return out;
165
+ if (row$1.screening !== void 0 && (row$1.screening.outcome === "steer" || row$1.screening.outcome === "hold")) return {
166
+ stage: stage("untrusted-input"),
167
+ basis: `screening found instructions in the content (${row$1.screening.outcome})`
168
+ };
169
+ if (row$1.capability === "fs:read" && target !== "" && withinAny(target, UNTRUSTED_GLOBS)) return {
170
+ stage: stage("untrusted-input"),
171
+ basis: `${target} is customer-supplied content`
172
+ };
185
173
  }
186
- function readSecretsMatch(check$1, path, value) {
187
- if (!isObject(value)) {
188
- check$1.fail(path, "expected an object");
189
- return;
190
- }
191
- check$1.keys(path, value, [
192
- "types",
193
- "minConfidence",
194
- "atLeast"
195
- ]);
196
- const out = {};
197
- if (value.types !== void 0) {
198
- const types = check$1.globs(`${path}.types`, value.types);
199
- if (types !== void 0) out.types = types;
200
- }
201
- if (value.minConfidence !== void 0) {
202
- const n = check$1.number(`${path}.minConfidence`, value.minConfidence);
203
- if (n !== void 0) if (n < 0 || n > 1) check$1.fail(`${path}.minConfidence`, "expected a number between 0 and 1");
204
- else out.minConfidence = n;
205
- }
206
- if (value.atLeast !== void 0) {
207
- const n = check$1.number(`${path}.atLeast`, value.atLeast);
208
- if (n !== void 0) if (!Number.isInteger(n) || n < 1) check$1.fail(`${path}.atLeast`, "expected an integer of at least 1");
209
- else out.atLeast = n;
210
- }
211
- return out;
174
+ const EXECUTION_LABEL = {
175
+ "blocked-before-execution": "BLOCKED BEFORE EXECUTION",
176
+ "held-before-execution": "HELD BEFORE EXECUTION",
177
+ "executed-output-withheld": "EXECUTED · DETECTED AFTER EXECUTION",
178
+ "executed-shadow": "EXECUTED · SHADOW (WOULD HAVE BEEN REFUSED)",
179
+ executed: "EXECUTED",
180
+ permitted: "PERMITTED"
181
+ };
182
+ /**
183
+ * Whether the call ran, from the row alone. A refusal before execution and
184
+ * a result withheld after it are different claims, and the row says which:
185
+ * `phase: output` is the second.
186
+ */
187
+ function executionOf(row$1) {
188
+ if (row$1.tool.startsWith("prae:")) return void 0;
189
+ const refused = row$1.verdict === "deny" || row$1.verdict === "correct";
190
+ if (row$1.shadow === true && (refused || row$1.verdict === "ask")) return "executed-shadow";
191
+ if (row$1.phase === "output") return refused ? "executed-output-withheld" : "executed";
192
+ if (refused) return "blocked-before-execution";
193
+ if (row$1.verdict === "ask") return "held-before-execution";
194
+ return row$1.executed === true ? "executed" : "permitted";
212
195
  }
213
- function readMatch(check$1, path, value) {
214
- if (!isObject(value)) {
215
- check$1.fail(path, "expected an object");
216
- return {};
217
- }
218
- check$1.keys(path, value, [
219
- "tool",
220
- "capability",
221
- "plugin",
222
- "agent",
223
- "command",
224
- "path",
225
- "host",
226
- "arg",
227
- "count",
228
- "secrets"
229
- ]);
230
- const out = {};
231
- for (const key of [
232
- "tool",
233
- "capability",
234
- "plugin",
235
- "agent"
236
- ]) if (value[key] !== void 0) {
237
- const names = check$1.names(`${path}.${key}`, value[key]);
238
- if (names !== void 0) out[key] = names;
239
- }
240
- if (value.command !== void 0) {
241
- const match = readCommandMatch(check$1, `${path}.command`, value.command);
242
- if (match !== void 0) out.command = match;
243
- }
244
- if (value.path !== void 0) {
245
- const match = readPathMatch(check$1, `${path}.path`, value.path);
246
- if (match !== void 0) out.path = match;
247
- }
248
- if (value.host !== void 0) {
249
- const match = readHostMatch(check$1, `${path}.host`, value.host);
250
- if (match !== void 0) out.host = match;
251
- }
252
- if (value.arg !== void 0) if (!Array.isArray(value.arg)) check$1.fail(`${path}.arg`, "expected an array of argument matchers");
253
- else {
254
- const list = [];
255
- value.arg.forEach((entry, index) => {
256
- const one = readArgMatch(check$1, `${path}.arg[${index}]`, entry);
257
- if (one !== void 0) list.push(one);
196
+ /**
197
+ * The Attack Chain Integrity Matrix: every classified row, in ledger order,
198
+ * from attempted action to the seal that proves it. The Guardian comes from
199
+ * the catalogue the caller passes; the rest is the row's own.
200
+ */
201
+ function integrityMatrix(entries, pack, guardianOf = () => void 0) {
202
+ const out = [];
203
+ for (const row$1 of entries) {
204
+ const c = classify(row$1, pack);
205
+ const execution = executionOf(row$1);
206
+ if (c === void 0 || execution === void 0) continue;
207
+ const rule = row$1.rule ?? row$1.trace?.[0]?.rule;
208
+ const mismatch = entries.find((e) => e.rule === "skill.capability_mismatch" && e.about === row$1.seq)?.seq;
209
+ out.push({
210
+ seq: row$1.seq,
211
+ at: row$1.at,
212
+ action: row$1.tool,
213
+ ...row$1.subject === void 0 ? {} : { target: row$1.subject },
214
+ ...row$1.capability === void 0 ? {} : { capability: row$1.capability },
215
+ ...guardianOf(rule) === void 0 ? {} : { guardian: guardianOf(rule) },
216
+ ...rule === void 0 ? {} : {
217
+ rule,
218
+ ruleRole: row$1.rule === void 0 ? "recorded" : "decided"
219
+ },
220
+ decision: row$1.verdict.toUpperCase(),
221
+ ...row$1.reason === void 0 ? {} : { reason: row$1.reason },
222
+ execution: EXECUTION_LABEL[execution],
223
+ stage: `${c.stage.step}/5 ${c.stage.name}`,
224
+ basis: c.basis,
225
+ ...mismatch === void 0 ? {} : { mismatch },
226
+ hash: row$1.hash,
227
+ prev: row$1.prev,
228
+ signed: row$1.sig !== void 0
258
229
  });
259
- out.arg = list;
260
- }
261
- if (value.count !== void 0) if (!isObject(value.count)) check$1.fail(`${path}.count`, "expected an object");
262
- else {
263
- check$1.keys(`${path}.count`, value.count, ["above"]);
264
- const above = check$1.number(`${path}.count.above`, value.count.above);
265
- if (above !== void 0) out.count = { above };
266
- }
267
- if (value.secrets !== void 0) {
268
- const match = readSecretsMatch(check$1, `${path}.secrets`, value.secrets);
269
- if (match !== void 0) out.secrets = match;
270
230
  }
271
231
  return out;
272
232
  }
273
- function readEffect(check$1, path, value) {
274
- if (!isObject(value)) {
275
- check$1.fail(path, "expected an object");
276
- return;
233
+
234
+ //#endregion
235
+ //#region ../dsh/src/packset.ts
236
+ const PACK_FILE = /\.prae\.(json|ya?ml)$/;
237
+ /** Read one pack file: its parsed pack, or why it cannot be one. */
238
+ function readPackFile(file, policyKey, origin = "workspace") {
239
+ let bytes;
240
+ try {
241
+ bytes = readFileSync(file);
242
+ } catch (e) {
243
+ return {
244
+ file,
245
+ reason: `unreadable: ${e instanceof Error ? e.message : String(e)}`
246
+ };
277
247
  }
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`");
281
- return;
248
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
249
+ if (policyKey !== void 0) {
250
+ let signature = "";
251
+ try {
252
+ signature = readFileSync(`${file}.sig`, "utf8");
253
+ } catch {}
254
+ if (signature === "") return {
255
+ file,
256
+ reason: `unsigned: no ${file.split("/").at(-1)}.sig beside it, and a policy key is pinned`
257
+ };
258
+ if (!verifyBytes(bytes, signature, policyKey)) return {
259
+ file,
260
+ reason: `not signed by the policy key ${fingerprint(policyKey)} (edited after signing, or signed by another key)`
261
+ };
282
262
  }
283
- check$1.keys(path, value, kind === "correct" ? [
284
- "kind",
285
- "set",
286
- "because"
287
- ] : ["kind", "because"]);
288
- const because = check$1.text(`${path}.because`, value.because);
289
- if (because === void 0) return void 0;
290
- if (kind === "correct") {
291
- if (!isObject(value.set) || Object.keys(value.set).length === 0) {
292
- check$1.fail(`${path}.set`, "expected a non-empty object of arguments to set");
293
- return;
294
- }
263
+ let json = bytes.toString("utf8");
264
+ if (/\.ya?ml$/.test(file)) try {
265
+ json = JSON.stringify(parse(json));
266
+ } catch (e) {
295
267
  return {
296
- kind,
297
- set: value.set,
298
- because
268
+ file,
269
+ reason: `invalid YAML: ${e instanceof Error ? e.message : String(e)}`
299
270
  };
300
271
  }
301
- return {
302
- kind,
303
- because
272
+ const parsed$1 = parsePack(json);
273
+ if (!parsed$1.ok) return {
274
+ file,
275
+ reason: parsed$1.errors.map((e) => `${e.path || "<root>"}: ${e.message}`).join("; ")
304
276
  };
305
- }
306
- function readRule(check$1, path, value) {
307
- if (!isObject(value)) {
308
- check$1.fail(path, "expected an object");
309
- return;
310
- }
311
- check$1.keys(path, value, [
312
- "id",
313
- "title",
314
- "when",
315
- "effect",
316
- "frameworks"
317
- ]);
318
- const id = check$1.text(`${path}.id`, value.id);
319
- const effect = readEffect(check$1, `${path}.effect`, value.effect);
320
- if (value.when === void 0) check$1.fail(`${path}.when`, "expected an object");
321
- const when = readMatch(check$1, `${path}.when`, value.when ?? {});
322
- if (id === void 0 || effect === void 0) return void 0;
323
- const rule = {
324
- id,
325
- when,
326
- effect
277
+ return {
278
+ file,
279
+ pack: parsed$1.pack,
280
+ origin,
281
+ sha256,
282
+ ...policyKey === void 0 ? {} : { signedBy: fingerprint(policyKey) }
327
283
  };
328
- if (value.title !== void 0) {
329
- const title = check$1.text(`${path}.title`, value.title);
330
- if (title !== void 0) rule.title = title;
331
- }
332
- if (value.frameworks !== void 0) {
333
- const frameworks = readFrameworks(check$1, `${path}.frameworks`, value.frameworks);
334
- if (frameworks !== void 0) rule.frameworks = frameworks;
335
- }
336
- return rule;
337
284
  }
338
- /**
339
- * A citation names a framework edition and controls the registry knows. A
340
- * typo here would be a rule that claims a mapping no view can render, so it
341
- * is a pack error rather than an ignored key — the same line the rest of the
342
- * parser takes.
343
- */
344
- function readFrameworks(check$1, path, value) {
345
- if (!isObject(value)) {
346
- check$1.fail(path, "expected an object keyed by framework id");
347
- return;
285
+ /** Load, validate and merge every pack in a directory. Never throws. */
286
+ function loadPackDir(dir, policyKey, accept, builtin) {
287
+ const packs = [];
288
+ const rejected = [];
289
+ const ids = /* @__PURE__ */ new Map();
290
+ let baselineFrom;
291
+ let names = [];
292
+ try {
293
+ names = readdirSync(resolve(dir)).filter((n) => PACK_FILE.test(n)).sort();
294
+ } catch (e) {
295
+ rejected.push({
296
+ file: dir,
297
+ reason: `no such directory: ${e instanceof Error ? e.message : String(e)}`
298
+ });
348
299
  }
349
- const out = {};
350
- for (const [framework, controls] of Object.entries(value)) {
351
- const known = FRAMEWORKS[framework];
352
- if (known === void 0) {
353
- check$1.fail(`${path}.${framework}`, `unknown framework; expected one of ${Object.keys(FRAMEWORKS).map((k) => `\`${k}\``).join(", ")}`);
300
+ const candidates = [...builtin === void 0 ? [] : [builtin], ...names.map((name) => readPackFile(join(resolve(dir), name), policyKey))];
301
+ for (const read of candidates) {
302
+ const file = read.file;
303
+ const name = file.split("/").at(-1) ?? file;
304
+ if (!("pack" in read)) {
305
+ rejected.push(read);
354
306
  continue;
355
307
  }
356
- if (!Array.isArray(controls) || !controls.every((c) => typeof c === "string")) {
357
- check$1.fail(`${path}.${framework}`, "expected an array of control ids");
308
+ const refused = accept?.(read);
309
+ if (refused !== void 0) {
310
+ rejected.push({
311
+ file,
312
+ reason: refused
313
+ });
358
314
  continue;
359
315
  }
360
- const ids = controls;
361
- for (const control of ids) if (known.controls[control] === void 0) check$1.fail(`${path}.${framework}`, `unknown control \`${control}\` in ${known.name}`);
362
- if (ids.length > 0) out[framework] = [...new Set(ids)];
363
- }
364
- return out;
365
- }
366
- /** Parse and validate a pack. Never throws; every problem lands in `errors`. */
367
- function parsePack(source) {
368
- let raw;
369
- try {
370
- raw = JSON.parse(source);
371
- } catch (error) {
372
- return {
373
- ok: false,
374
- errors: [{
375
- path: "",
376
- message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}`
316
+ const foreign = read.origin === "workspace" ? read.pack.rules.find((r) => !r.id.startsWith("ORG-")) : void 0;
317
+ if (foreign !== void 0) {
318
+ rejected.push({
319
+ file,
320
+ reason: `rule id ${foreign.id} must start with ORG-; workspace rules are the organisation's own, and other ids belong to built-in packs`
321
+ });
322
+ continue;
323
+ }
324
+ const clash = read.pack.rules.find((r) => ids.has(r.id));
325
+ if (clash !== void 0) {
326
+ rejected.push({
327
+ file,
328
+ reason: `rule id ${clash.id} is already defined by ${ids.get(clash.id)}; rule ids must be unique across the workspace`
329
+ });
330
+ continue;
331
+ }
332
+ if (read.pack.baseline !== void 0 && baselineFrom !== void 0) {
333
+ rejected.push({
334
+ file,
335
+ reason: `declares a baseline, and ${baselineFrom} already does; only one pack in a workspace may, so a grant is never widened`
336
+ });
337
+ continue;
338
+ }
339
+ if (read.pack.baseline !== void 0) baselineFrom = name;
340
+ for (const r of read.pack.rules) ids.set(r.id, name);
341
+ packs.push(read);
342
+ }
343
+ return merge(packs, rejected);
344
+ }
345
+ /** The packs as one policy, each rule remembering where it came from. */
346
+ function merge(packs, rejected) {
347
+ const origins = /* @__PURE__ */ new Map();
348
+ for (const p of packs) for (const r of p.pack.rules) origins.set(r.id, {
349
+ name: p.pack.pack,
350
+ version: p.pack.version,
351
+ sha256: p.sha256,
352
+ origin: p.origin
353
+ });
354
+ if (packs.length === 0) return {
355
+ packs,
356
+ rejected,
357
+ origins,
358
+ effective: {
359
+ prae: "0.1",
360
+ pack: "no-valid-pack",
361
+ version: "0",
362
+ rules: [{
363
+ id: "PRAE-NO-PACK",
364
+ when: {},
365
+ effect: {
366
+ kind: "deny",
367
+ 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(" · ")})`
368
+ }
377
369
  }]
378
- };
379
- }
380
- const check$1 = new Check();
381
- if (!isObject(raw)) return {
382
- ok: false,
383
- errors: [{
384
- path: "",
385
- message: "expected an object"
386
- }]
387
- };
388
- check$1.keys("", raw, [
389
- "prae",
390
- "pack",
391
- "version",
392
- "baseline",
393
- "rules"
394
- ]);
395
- if (raw.prae !== "0.1") check$1.fail(".prae", "expected `\"0.1\"`; this is the only pack version this build reads");
396
- const name = check$1.text(".pack", raw.pack);
397
- const version = check$1.text(".version", raw.version);
398
- let baseline;
399
- if (raw.baseline !== void 0) if (!isObject(raw.baseline)) check$1.fail(".baseline", "expected an object");
400
- else {
401
- check$1.keys(".baseline", raw.baseline, ["capabilities"]);
402
- const caps = check$1.globs(".baseline.capabilities", raw.baseline.capabilities);
403
- if (caps !== void 0) baseline = { capabilities: caps };
404
- }
405
- const rules = [];
406
- if (!Array.isArray(raw.rules)) check$1.fail(".rules", "expected an array");
407
- else {
408
- const seen = /* @__PURE__ */ new Set();
409
- raw.rules.forEach((entry, index) => {
410
- const rule = readRule(check$1, `rules[${index}]`, entry);
411
- if (rule === void 0) return;
412
- if (seen.has(rule.id)) {
413
- check$1.fail(`rules[${index}].id`, `duplicate rule id \`${rule.id}\``);
414
- return;
415
- }
416
- seen.add(rule.id);
417
- rules.push(rule);
418
- });
419
- }
420
- if (check$1.errors.length > 0) return {
421
- ok: false,
422
- errors: check$1.errors
423
- };
424
- if (name === void 0 || version === void 0) return {
425
- ok: false,
426
- errors: check$1.errors
370
+ }
427
371
  };
372
+ const baseline = packs.find((p) => p.pack.baseline !== void 0)?.pack.baseline;
373
+ const only = packs.length === 1 ? packs[0] : void 0;
374
+ const expires = packs.map((p) => p.pack.expires_at).filter((e) => e !== void 0).sort((a, b) => Date.parse(a) - Date.parse(b))[0];
428
375
  return {
429
- ok: true,
430
- pack: {
376
+ packs,
377
+ rejected,
378
+ origins,
379
+ effective: {
431
380
  prae: "0.1",
432
- pack: name,
433
- version,
381
+ pack: only?.pack.pack ?? packs.map((p) => p.pack.pack).join("+"),
382
+ version: only?.pack.version ?? createHash("sha256").update(packs.map((p) => p.sha256).join("")).digest("hex").slice(0, 12),
434
383
  ...baseline === void 0 ? {} : { baseline },
435
- rules
384
+ ...expires === void 0 ? {} : { expires_at: expires },
385
+ rules: packs.flatMap((p) => p.pack.rules)
436
386
  }
437
387
  };
438
388
  }
@@ -457,7 +407,7 @@ function row(entry, pack) {
457
407
  <div class="call">${esc(call)}</div>
458
408
  <div class="actor">${esc(actor)}</div>
459
409
  <div class="clause">${esc(entry.rule ?? "—")}${cited}</div>
460
- <div class="verdict v-${esc(entry.verdict)}">${esc(VERDICT_LABEL$1[entry.verdict] ?? entry.verdict)}</div>
410
+ <div class="verdict v-${esc(entry.verdict)}">${esc(VERDICT_LABEL$1[entry.verdict] ?? entry.verdict)}${entry.shadow === true ? " · SHADOW" : ""}</div>
461
411
  <div class="seal">${esc(seal(entry))}</div>
462
412
  </div>`;
463
413
  }
@@ -465,12 +415,14 @@ function renderLedgerView(entries, verification, options) {
465
415
  const counts = {};
466
416
  const packs = /* @__PURE__ */ new Set();
467
417
  const sessions = /* @__PURE__ */ new Set();
418
+ let shadowed = 0;
468
419
  for (const entry of entries) {
469
- counts[entry.verdict] = (counts[entry.verdict] ?? 0) + 1;
420
+ if (entry.shadow === true) shadowed += 1;
421
+ else counts[entry.verdict] = (counts[entry.verdict] ?? 0) + 1;
470
422
  packs.add(`${entry.pack.name} ${entry.pack.version}`);
471
423
  if (entry.session !== void 0) sessions.add(entry.session);
472
424
  }
473
- const held = entries.filter((e) => e.verdict === "ask");
425
+ const held = entries.filter((e) => e.verdict === "ask" && e.shadow !== true);
474
426
  const pack = [...packs][0] ?? "—";
475
427
  const now = Date.parse(options.generatedAt);
476
428
  const chainOk = verification.ok;
@@ -480,6 +432,7 @@ function renderLedgerView(entries, verification, options) {
480
432
  <div class="meta">${esc([
481
433
  `${sessions.size || 1} session${sessions.size === 1 ? "" : "s"}`,
482
434
  `${entries.length.toLocaleString("en-US")} decisions`,
435
+ ...shadowed === 0 ? [] : [`${shadowed} in shadow mode (recorded, not enforced)`],
483
436
  options.retention === void 0 ? "retention local" : `retention ${options.retention}`
484
437
  ].join(" · "))}</div>
485
438
  </div>
@@ -763,6 +716,7 @@ function praeRow(options) {
763
716
  if (options.ledger !== void 0) lines.push(` ledger: ${JSON.stringify(options.ledger)}`);
764
717
  if (options.approver !== void 0) lines.push(` approver: ${JSON.stringify(options.approver)}`);
765
718
  if (options.mode !== void 0) lines.push(` mode: ${options.mode}`);
719
+ if (options.packDir !== void 0) lines.push(` packDir: ${JSON.stringify(options.packDir)}`);
766
720
  if (options.packFile !== void 0) lines.push(` packFile: ${JSON.stringify(options.packFile)}`);
767
721
  else lines.push(" pack: |", indent(options.pack ?? "", 10));
768
722
  return lines.join("\n");
@@ -814,16 +768,60 @@ function jsonOrNull(path) {
814
768
  return null;
815
769
  }
816
770
  }
817
- function readLedger(path) {
818
- if (!existsSync(path)) return [];
819
- const out = [];
820
- for (const line of readFileSync(path, "utf8").split("\n")) {
771
+ const parsed = /* @__PURE__ */ new Map();
772
+ function parseLines(text, into) {
773
+ for (const line of text.split("\n")) {
821
774
  if (line.trim() === "") continue;
822
775
  try {
823
- out.push(JSON.parse(line));
776
+ into.push(JSON.parse(line));
824
777
  } catch {}
825
778
  }
826
- return out;
779
+ }
780
+ /**
781
+ * The ledger's rows. A ledger is append-only, so the parse is kept and only
782
+ * the bytes appended since are read: a long demo grows it to tens of
783
+ * thousands of rows, and every endpoint the console polls reads it — parsed
784
+ * whole each time, that kept the server too busy to answer anyone. A file
785
+ * that shrank or was replaced is read again from the start. A half-written
786
+ * last line is left unread until its newline arrives.
787
+ */
788
+ function readLedger(path) {
789
+ if (!existsSync(path)) {
790
+ parsed.delete(path);
791
+ return [];
792
+ }
793
+ const stat$1 = statSync(path);
794
+ let entry = parsed.get(path);
795
+ if (entry === void 0 || entry.ino !== stat$1.ino || stat$1.size < entry.size) {
796
+ entry = {
797
+ ino: stat$1.ino,
798
+ size: 0,
799
+ mtimeMs: 0,
800
+ offset: 0,
801
+ rows: []
802
+ };
803
+ parsed.set(path, entry);
804
+ }
805
+ if (stat$1.size !== entry.size || stat$1.mtimeMs !== entry.mtimeMs) {
806
+ const length = stat$1.size - entry.offset;
807
+ if (length > 0) {
808
+ const buf = Buffer.alloc(length);
809
+ const fd = openSync(path, "r");
810
+ try {
811
+ readSync(fd, buf, 0, length, entry.offset);
812
+ } finally {
813
+ closeSync(fd);
814
+ }
815
+ const end = buf.lastIndexOf(10);
816
+ if (end >= 0) {
817
+ parseLines(buf.subarray(0, end).toString("utf8"), entry.rows);
818
+ entry.offset += end + 1;
819
+ }
820
+ }
821
+ entry.size = stat$1.size;
822
+ entry.mtimeMs = stat$1.mtimeMs;
823
+ }
824
+ return entry.rows.slice();
827
825
  }
828
826
  /** The defaults the demo writes; a caller with other names overrides them. */
829
827
  const DEFAULT_FILES = {
@@ -832,6 +830,7 @@ const DEFAULT_FILES = {
832
830
  report: "security-report.json",
833
831
  guardians: "guardians.json",
834
832
  pack: "papaya.prae.json",
833
+ packs: ".prae/packs",
835
834
  publicKey: "prae-signing.pub.pem",
836
835
  redteam: "../redteam/results"
837
836
  };
@@ -841,7 +840,8 @@ function readRecord(dir, files) {
841
840
  discovery: jsonOrNull(resolve(dir, files.discovery)),
842
841
  report: jsonOrNull(resolve(dir, files.report)),
843
842
  guardians: jsonOrNull(resolve(dir, files.guardians)),
844
- pack: jsonOrNull(resolve(dir, files.pack))
843
+ pack: jsonOrNull(resolve(dir, files.pack)),
844
+ crownJewel: jsonOrNull(resolve(dir, "crown-jewel.json"))
845
845
  };
846
846
  }
847
847
  /** The red-team campaign and its runs, as the runner wrote them; empty when none. */
@@ -849,12 +849,362 @@ function readRedteam(dir, files) {
849
849
  const root = resolve(dir, files.redteam);
850
850
  if (!existsSync(root)) return {
851
851
  campaign: null,
852
- runs: []
852
+ runs: [],
853
+ steering: [],
854
+ history: []
853
855
  };
854
856
  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);
857
+ const steeringDir = resolve(root, "steering");
858
+ 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) : [];
859
+ const historyDir = resolve(steeringDir, "history");
860
+ const history = existsSync(historyDir) ? readdirSync(historyDir).filter((n) => n.endsWith(".json")).sort().reverse().map((n) => jsonOrNull(resolve(historyDir, n))).filter((r) => r !== null) : [];
855
861
  return {
856
862
  campaign: jsonOrNull(resolve(root, "campaign.json")),
857
- runs
863
+ runs,
864
+ steering,
865
+ history
866
+ };
867
+ }
868
+
869
+ //#endregion
870
+ //#region ../controls/src/scope.ts
871
+ const ROW_LIMIT = 50;
872
+ /** A framework's edition, read from its versioned id (`…-2026`). */
873
+ const versionOf = (id) => /(\d{4})$/.exec(id)?.[1];
874
+ /** Where a built-in control belongs, and its code within it. */
875
+ function placeOf(control) {
876
+ const found = Object.entries(FRAMEWORKS).find(([, f]) => control.id.startsWith(`${f.short} · `));
877
+ if (found !== void 0) return {
878
+ framework: {
879
+ id: found[0],
880
+ name: found[1].name
881
+ },
882
+ id: control.id.slice(found[1].short.length + 3)
883
+ };
884
+ return {
885
+ framework: control.standard ?? {
886
+ id: "obligations",
887
+ name: "Obligations"
888
+ },
889
+ id: control.code ?? control.id.split(" · ")[1] ?? control.id
890
+ };
891
+ }
892
+ /** A control defined as data: evidence is the rows under its mapped rules. */
893
+ function fromData(def) {
894
+ const types = new Set(def.evidence_types ?? [
895
+ "allow",
896
+ "deny",
897
+ "ask"
898
+ ]);
899
+ const trace = (data) => {
900
+ const ids = new Set(def.prae_controls);
901
+ return {
902
+ rules: [...def.prae_controls],
903
+ rows: data.entries.filter((e) => e.rule !== void 0 && ids.has(e.rule) && types.has(e.verdict))
904
+ };
905
+ };
906
+ return {
907
+ id: def.id,
908
+ ...def.name === void 0 ? {} : { name: def.name },
909
+ asks: def.asks,
910
+ trace,
911
+ evidence: (data) => {
912
+ const inPack = new Set((data.pack?.rules ?? []).map((r) => r.id));
913
+ const present = def.prae_controls.filter((id) => inPack.has(id));
914
+ const rows = trace(data).rows.length;
915
+ if (rows > 0) return {
916
+ state: "covered",
917
+ 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.`
918
+ };
919
+ if (present.length > 0) return {
920
+ state: "partial",
921
+ 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.`
922
+ };
923
+ return {
924
+ state: "absent",
925
+ 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.`
926
+ };
927
+ }
928
+ };
929
+ }
930
+ /** The built-in catalog, plus a record's own frameworks when it has any. */
931
+ function catalogOf(file) {
932
+ const frameworks = /* @__PURE__ */ new Map();
933
+ const controls = /* @__PURE__ */ new Map();
934
+ const add = (framework, id, control) => {
935
+ const key = `${framework.id}:${id}`;
936
+ if (controls.has(key)) return;
937
+ controls.set(key, control);
938
+ let f = frameworks.get(framework.id);
939
+ if (f === void 0) {
940
+ f = {
941
+ ...framework,
942
+ controls: []
943
+ };
944
+ frameworks.set(framework.id, f);
945
+ }
946
+ f.controls.push({
947
+ key,
948
+ id,
949
+ ...control.name === void 0 ? {} : { name: control.name },
950
+ asks: control.asks
951
+ });
952
+ };
953
+ for (const control of CONTROLS) {
954
+ const place = placeOf(control);
955
+ const version = versionOf(place.framework.id);
956
+ add({
957
+ id: place.framework.id,
958
+ name: place.framework.name,
959
+ ...version === void 0 ? {} : { version }
960
+ }, place.id, control);
961
+ }
962
+ for (const f of file?.frameworks ?? []) for (const c of f.controls) add({
963
+ id: f.id,
964
+ name: f.name,
965
+ ...f.version === void 0 ? {} : { version: f.version },
966
+ ...f.description === void 0 ? {} : { description: f.description },
967
+ custom: true
968
+ }, c.id, fromData(c));
969
+ return {
970
+ frameworks: [...frameworks.values()],
971
+ controls
972
+ };
973
+ }
974
+ /** A catalog file, checked: what is wrong with it, or the file. */
975
+ function parseCatalog(raw) {
976
+ const text = (v) => typeof v === "string" && v.trim() !== "";
977
+ if (typeof raw !== "object" || raw === null) return "not an object";
978
+ const frameworks = raw.frameworks;
979
+ if (!Array.isArray(frameworks)) return "frameworks: expected an array";
980
+ for (const [i, f] of frameworks.entries()) {
981
+ if (!text(f?.id) || !/^[a-z0-9][a-z0-9-]*$/.test(f.id)) return `frameworks[${i}].id: lowercase letters, digits and dashes`;
982
+ if (!text(f.name)) return `frameworks[${i}].name: required`;
983
+ if (!Array.isArray(f.controls)) return `frameworks[${i}].controls: expected an array`;
984
+ for (const [j, c] of f.controls.entries()) {
985
+ const at = `frameworks[${i}].controls[${j}]`;
986
+ if (!text(c?.id)) return `${at}.id: required`;
987
+ if (!text(c.asks)) return `${at}.asks: required`;
988
+ if (!Array.isArray(c.prae_controls) || !c.prae_controls.every((r) => text(r))) return `${at}.prae_controls: expected an array of PRAE rule ids`;
989
+ if (c.evidence_types !== void 0 && (!Array.isArray(c.evidence_types) || !c.evidence_types.every((t) => [
990
+ "allow",
991
+ "deny",
992
+ "ask"
993
+ ].includes(t)))) return `${at}.evidence_types: allow, deny or ask`;
994
+ }
995
+ }
996
+ return raw;
997
+ }
998
+ /** Everything built in, every agent and session, the whole record. */
999
+ function defaultScope(catalog) {
1000
+ const builtIn = catalog.frameworks.filter((f) => f.custom !== true);
1001
+ return {
1002
+ frameworks: builtIn.map((f) => f.id),
1003
+ controls: builtIn.flatMap((f) => f.controls.map((c) => c.key)),
1004
+ agents: "all",
1005
+ sessions: "all",
1006
+ period: { kind: "all" }
1007
+ };
1008
+ }
1009
+ /** A scope as saved or sent, checked: what is wrong with it, or the scope. */
1010
+ function parseScope(raw) {
1011
+ if (typeof raw !== "object" || raw === null) return "not an object";
1012
+ const r = raw;
1013
+ const strings = (v) => Array.isArray(v) && v.every((s) => typeof s === "string" && s.length <= 200);
1014
+ if (!strings(r.frameworks)) return "frameworks: expected an array of ids";
1015
+ if (!strings(r.controls)) return "controls: expected an array of keys";
1016
+ for (const d of ["agents", "sessions"]) if (r[d] !== "all" && !strings(r[d])) return `${d}: "all" or an array`;
1017
+ const p = r.period;
1018
+ const period = p?.kind === "all" ? { kind: "all" } : p?.kind === "days" && typeof p.days === "number" && Number.isInteger(p.days) && p.days > 0 && p.days <= 3650 ? {
1019
+ kind: "days",
1020
+ days: p.days
1021
+ } : 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) ? {
1022
+ kind: "custom",
1023
+ from: p.from,
1024
+ to: p.to
1025
+ } : "period: all, the last N days, or a custom from–to range";
1026
+ if (typeof period === "string") return period;
1027
+ return {
1028
+ frameworks: r.frameworks,
1029
+ controls: r.controls,
1030
+ agents: r.agents,
1031
+ sessions: r.sessions,
1032
+ period
1033
+ };
1034
+ }
1035
+ /** Whether a row falls in the scope's period. */
1036
+ function inPeriod(at, period, now) {
1037
+ const t = Date.parse(at);
1038
+ if (period.kind === "all") return true;
1039
+ if (Number.isNaN(t)) return false;
1040
+ if (period.kind === "days") return t >= now - period.days * 864e5;
1041
+ return t >= Date.parse(period.from) && t < Date.parse(period.to) + 864e5;
1042
+ }
1043
+ /**
1044
+ * Assess the scoped controls against the record. The rows are narrowed to
1045
+ * the scope's agents, sessions and period before any control reads them;
1046
+ * the chain's verification stays the whole record's, since integrity is a
1047
+ * property of the chain, not of a slice of it.
1048
+ */
1049
+ function assess(data, scope, catalog, options) {
1050
+ const now = options.now ?? Date.now();
1051
+ const agentOf$1 = (e) => e.agent ?? options.agent;
1052
+ 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));
1053
+ const scoped = {
1054
+ ...data,
1055
+ entries: admitted
1056
+ };
1057
+ const frameworksOn = new Set(scope.frameworks);
1058
+ const controlsOn = new Set(scope.controls);
1059
+ const row$1 = (e) => ({
1060
+ seq: e.seq,
1061
+ at: e.at,
1062
+ tool: e.tool,
1063
+ ...e.subject === void 0 ? {} : { subject: e.subject },
1064
+ verdict: e.verdict,
1065
+ ...e.rule === void 0 ? {} : { rule: e.rule },
1066
+ ...e.session === void 0 ? {} : { session: e.session },
1067
+ agent: agentOf$1(e),
1068
+ hash: e.hash
1069
+ });
1070
+ const frameworks = catalog.frameworks.map((f) => {
1071
+ const selected = frameworksOn.has(f.id);
1072
+ const controls = f.controls.map((c) => {
1073
+ const control = catalog.controls.get(c.key);
1074
+ if (!(selected && controlsOn.has(c.key)) || control === void 0) return {
1075
+ ...c,
1076
+ inScope: false,
1077
+ state: "not-in-scope",
1078
+ note: "Not in the selected scope, so not assessed.",
1079
+ rules: [],
1080
+ evidence: {
1081
+ count: 0,
1082
+ rows: []
1083
+ }
1084
+ };
1085
+ const found = control.evidence(scoped);
1086
+ const trace = control.trace?.(scoped) ?? {
1087
+ rules: [],
1088
+ rows: []
1089
+ };
1090
+ return {
1091
+ ...c,
1092
+ inScope: true,
1093
+ state: found.state,
1094
+ note: found.note,
1095
+ rules: trace.rules,
1096
+ evidence: {
1097
+ count: trace.rows.length,
1098
+ rows: trace.rows.slice(-ROW_LIMIT).reverse().map(row$1)
1099
+ }
1100
+ };
1101
+ });
1102
+ const on$1 = controls.filter((c) => c.inScope);
1103
+ const count = (s) => on$1.filter((c) => c.state === s).length;
1104
+ const { controls: _,...meta } = f;
1105
+ return {
1106
+ ...meta,
1107
+ selected,
1108
+ total: controls.length,
1109
+ inScope: on$1.length,
1110
+ covered: count("covered"),
1111
+ partial: count("partial"),
1112
+ absent: count("absent"),
1113
+ controls
1114
+ };
1115
+ });
1116
+ const on = frameworks.flatMap((f) => f.controls.filter((c) => c.inScope));
1117
+ const sessions = /* @__PURE__ */ new Map();
1118
+ for (const e of data.entries) {
1119
+ if (e.session === void 0) continue;
1120
+ const s = sessions.get(e.session);
1121
+ if (s === void 0) sessions.set(e.session, {
1122
+ rows: 1,
1123
+ first: e.at,
1124
+ last: e.at
1125
+ });
1126
+ else {
1127
+ s.rows += 1;
1128
+ s.last = e.at;
1129
+ }
1130
+ }
1131
+ return {
1132
+ scope,
1133
+ frameworks,
1134
+ totals: {
1135
+ frameworks: frameworks.filter((f) => f.inScope > 0).length,
1136
+ controls: on.length,
1137
+ withEvidence: on.filter((c) => c.state !== "absent").length,
1138
+ covered: on.filter((c) => c.state === "covered").length,
1139
+ partial: on.filter((c) => c.state === "partial").length,
1140
+ absent: on.filter((c) => c.state === "absent").length
1141
+ },
1142
+ dimensions: {
1143
+ agents: [...new Set([options.agent, ...data.entries.map(agentOf$1)])],
1144
+ sessions: [...sessions.entries()].map(([id, s]) => ({
1145
+ id,
1146
+ ...s
1147
+ })),
1148
+ environment: options.environment
1149
+ },
1150
+ rows: {
1151
+ admitted: admitted.length,
1152
+ total: data.entries.length
1153
+ }
1154
+ };
1155
+ }
1156
+
1157
+ //#endregion
1158
+ //#region src/compliance.ts
1159
+ const SCOPE_FILE = ".prae-compliance/scope.json";
1160
+ const CATALOG_FILE = "compliance-catalog.json";
1161
+ /** The record's catalog: built in, plus its own file when it parses. */
1162
+ function catalogFor(dir) {
1163
+ const path = resolve(dir, CATALOG_FILE);
1164
+ if (!existsSync(path)) return { catalog: catalogOf() };
1165
+ let raw;
1166
+ try {
1167
+ raw = JSON.parse(readFileSync(path, "utf8"));
1168
+ } catch {
1169
+ return {
1170
+ catalog: catalogOf(),
1171
+ catalogError: `${CATALOG_FILE}: not JSON`
1172
+ };
1173
+ }
1174
+ const file = parseCatalog(raw);
1175
+ return typeof file === "string" ? {
1176
+ catalog: catalogOf(),
1177
+ catalogError: `${CATALOG_FILE}: ${file}`
1178
+ } : { catalog: catalogOf(file) };
1179
+ }
1180
+ /** The saved scope, or everything built in when none is saved yet. */
1181
+ function scopeFor(dir, catalog) {
1182
+ const path = resolve(dir, SCOPE_FILE);
1183
+ if (!existsSync(path)) return defaultScope(catalog);
1184
+ try {
1185
+ const scope = parseScope(JSON.parse(readFileSync(path, "utf8")));
1186
+ return typeof scope === "string" ? defaultScope(catalog) : scope;
1187
+ } catch {
1188
+ return defaultScope(catalog);
1189
+ }
1190
+ }
1191
+ /** The agent the record's discovery names, for rows that name none. */
1192
+ function agentOf(dir) {
1193
+ try {
1194
+ const discovery = JSON.parse(readFileSync(resolve(dir, "discovery.json"), "utf8"));
1195
+ if (typeof discovery.agent?.name === "string") return discovery.agent.name;
1196
+ } catch {}
1197
+ return "this agent";
1198
+ }
1199
+ function assessCompliance(record, data) {
1200
+ const { catalog, catalogError } = catalogFor(record.dir);
1201
+ return {
1202
+ ...assess(data, scopeFor(record.dir, catalog), catalog, {
1203
+ agent: agentOf(record.dir),
1204
+ environment: basename(resolve(record.dir))
1205
+ }),
1206
+ saved: existsSync(resolve(record.dir, SCOPE_FILE)),
1207
+ ...catalogError === void 0 ? {} : { catalogError }
858
1208
  };
859
1209
  }
860
1210
 
@@ -913,6 +1263,23 @@ function verifyRecord(record, tamper, repair = false) {
913
1263
  repaired: false,
914
1264
  signatures: null
915
1265
  };
1266
+ if (tamper === void 0) {
1267
+ const keyPath$1 = resolve(dir, files.publicKey);
1268
+ const publicKey = existsSync(keyPath$1) ? readFileSync(keyPath$1, "utf8") : void 0;
1269
+ const v = verifyGrowing(path, publicKey);
1270
+ return {
1271
+ ok: v.ok,
1272
+ entries: v.entries,
1273
+ problems: [...v.problems],
1274
+ tampered: null,
1275
+ repaired: false,
1276
+ signatures: v.signatures === null || publicKey === void 0 ? null : {
1277
+ checked: v.signatures.checked,
1278
+ failed: [...v.signatures.failed],
1279
+ fingerprint: fingerprint(publicKey)
1280
+ }
1281
+ };
1282
+ }
916
1283
  let text = readFileSync(path, "utf8");
917
1284
  let tampered = null;
918
1285
  if (tamper !== void 0) {
@@ -972,6 +1339,45 @@ function verifyRecord(record, tamper, repair = false) {
972
1339
  signatures
973
1340
  };
974
1341
  }
1342
+ /** The record's built-in pack and workspace packs, as the gate loads them. */
1343
+ function packSetOf(record) {
1344
+ const { dir, files } = record;
1345
+ const packsDir = resolve(dir, files.packs);
1346
+ const builtinPath = resolve(dir, files.pack);
1347
+ return {
1348
+ packsDir,
1349
+ set: loadPackDir(packsDir, void 0, void 0, existsSync(builtinPath) ? readPackFile(builtinPath, void 0, "builtin") : void 0)
1350
+ };
1351
+ }
1352
+ /**
1353
+ * The packs in force for a record (PRA-127): the built-in pack and the
1354
+ * workspace's own, read by the gate's loader — each with its origin, version,
1355
+ * sha256 and rules — and every workspace pack the loader rejects, with why.
1356
+ */
1357
+ function packsOf(record) {
1358
+ const { packsDir, set } = packSetOf(record);
1359
+ return {
1360
+ packs: set.packs.map((p) => ({
1361
+ file: p.file.split("/").at(-1),
1362
+ name: p.pack.pack,
1363
+ version: p.pack.version,
1364
+ ...p.pack.description === void 0 ? {} : { description: p.pack.description },
1365
+ ...p.pack.owner === void 0 ? {} : { owner: p.pack.owner },
1366
+ sha256: p.sha256,
1367
+ origin: p.origin,
1368
+ rules: p.pack.rules.map((r) => ({
1369
+ id: r.id,
1370
+ ...r.title === void 0 ? {} : { title: r.title },
1371
+ kind: r.effect.kind,
1372
+ because: r.effect.because
1373
+ }))
1374
+ })),
1375
+ rejected: set.rejected.filter((r) => r.file !== packsDir).map((r) => ({
1376
+ file: r.file.split("/").at(-1),
1377
+ reason: r.reason
1378
+ }))
1379
+ };
1380
+ }
975
1381
  /**
976
1382
  * Evidence toward each control, from the pack and the ledger as they are
977
1383
  * now — the rows the controls package defines, so the console, the exporter and
@@ -981,40 +1387,56 @@ function verifyRecord(record, tamper, repair = false) {
981
1387
  function complianceOf(record) {
982
1388
  const { dir, files } = record;
983
1389
  const ledgerPath = resolve(dir, files.ledger);
984
- const text = existsSync(ledgerPath) ? readFileSync(ledgerPath, "utf8") : "";
985
- let entries = [];
986
- try {
987
- entries = readEntries(text);
988
- } catch {
989
- entries = [];
990
- }
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
- }
1390
+ const entries = readLedger(ledgerPath);
1391
+ const grown = existsSync(ledgerPath) ? verifyGrowing(ledgerPath) : void 0;
1392
+ const verification = grown === void 0 ? verifyEntries("") : {
1393
+ ok: grown.ok,
1394
+ entries: grown.entries,
1395
+ problems: grown.problems
1396
+ };
1397
+ const { set } = packSetOf(record);
1398
+ const pack = set.packs.length > 0 ? set.effective : void 0;
997
1399
  const data = {
998
1400
  ...pack === void 0 ? {} : { pack },
999
1401
  entries,
1000
- verification: verifyEntries(text),
1402
+ verification,
1001
1403
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
1002
1404
  };
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
- }) };
1405
+ return {
1406
+ assessment: assessCompliance(record, data),
1407
+ controls: CONTROLS.map((control) => {
1408
+ const found = Object.entries(FRAMEWORKS).find(([, f]) => control.id.startsWith(`${f.short} · `));
1409
+ const code = found === void 0 ? void 0 : control.id.split(" · ")[1];
1410
+ const named = found === void 0 || code === void 0 ? void 0 : found[1].controls[code];
1411
+ const standard = control.standard;
1412
+ const name = named?.name ?? control.name;
1413
+ return {
1414
+ id: code ?? (standard === void 0 ? control.id : control.id.split(" · ")[1] ?? control.id),
1415
+ ...name === void 0 ? {} : { name },
1416
+ asks: control.asks,
1417
+ framework: found?.[0] ?? standard?.id ?? "obligation",
1418
+ ...found === void 0 ? standard === void 0 ? {} : { frameworkName: standard.name } : { frameworkName: found[1].name },
1419
+ ...control.evidence(data)
1420
+ };
1421
+ })
1422
+ };
1423
+ }
1424
+ /**
1425
+ * The API's version is prae-cli's: every `/api/*` response names it in
1426
+ * `X-PRAE-API-Version`, so a client can tell which contract answered. The
1427
+ * contract follows the package's semver — a breaking change to a route bumps
1428
+ * the major — and a route on its way out carries `Deprecation` and `Sunset`
1429
+ * (RFC 9745 / RFC 8594) for at least one minor release before it goes.
1430
+ */
1431
+ /**
1432
+ * The most rows `/api/record` sends: the newest ones. The console keeps the
1433
+ * same window (apps/console/src/data/hooks.ts); `trimmed` counts the rest.
1434
+ */
1435
+ const RECORD_WINDOW = 25e3;
1436
+ const API_VERSION = createRequire(import.meta.url)("../package.json").version;
1437
+ /** Name the contract on an API response; call before the status is written. */
1438
+ function apiVersion(res) {
1439
+ res.setHeader("x-prae-api-version", API_VERSION);
1018
1440
  }
1019
1441
  /**
1020
1442
  * The record over HTTP, for the Command Center (apps/console): the same
@@ -1029,6 +1451,7 @@ function complianceOf(record) {
1029
1451
  function handleApi(req, res, record) {
1030
1452
  const route = (req.url ?? "").split("?")[0] ?? "";
1031
1453
  if (!route.startsWith("/api/")) return false;
1454
+ apiVersion(res);
1032
1455
  const json = (body, status = 200) => {
1033
1456
  res.statusCode = status;
1034
1457
  res.setHeader("content-type", "application/json; charset=utf-8");
@@ -1043,19 +1466,56 @@ function handleApi(req, res, record) {
1043
1466
  connection: "keep-alive"
1044
1467
  });
1045
1468
  res.write(": connected\n\n");
1046
- let sent = readRecord(dir, files).ledger.length;
1469
+ const ledgerPath = resolve(dir, files.ledger);
1470
+ const size = () => existsSync(ledgerPath) ? statSync(ledgerPath).size : 0;
1471
+ let offset = (() => {
1472
+ if (!existsSync(ledgerPath)) return 0;
1473
+ return readFileSync(ledgerPath).lastIndexOf(10) + 1;
1474
+ })();
1047
1475
  const push = () => {
1048
- const rows = readRecord(dir, files).ledger;
1049
- if (rows.length < sent) sent = 0;
1050
- const fresh = rows.slice(sent);
1051
- sent = rows.length;
1476
+ const end = size();
1477
+ if (end < offset) {
1478
+ offset = 0;
1479
+ res.write("event: reset\ndata: {}\n\n");
1480
+ }
1481
+ const fresh = [];
1482
+ if (end > offset) {
1483
+ const bytes = Buffer.alloc(end - offset);
1484
+ const fd = openSync(ledgerPath, "r");
1485
+ try {
1486
+ readSync(fd, bytes, 0, bytes.length, offset);
1487
+ } finally {
1488
+ closeSync(fd);
1489
+ }
1490
+ let at = 0;
1491
+ for (;;) {
1492
+ const nl = bytes.indexOf(10, at);
1493
+ if (nl === -1) break;
1494
+ const l = bytes.subarray(at, nl).toString("utf8");
1495
+ if (l.trim() !== "") try {
1496
+ fresh.push(JSON.parse(l));
1497
+ } catch {
1498
+ break;
1499
+ }
1500
+ at = nl + 1;
1501
+ }
1502
+ offset += at;
1503
+ }
1052
1504
  if (fresh.length > 0) res.write(`data: ${JSON.stringify(fresh)}\n\n`);
1053
1505
  else res.write(": keepalive\n\n");
1054
1506
  };
1055
- const watcher = existsSync(dir) ? watch(dir, { persistent: false }, () => push()) : void 0;
1507
+ let soon;
1508
+ const schedule = () => {
1509
+ soon ??= setTimeout(() => {
1510
+ soon = void 0;
1511
+ push();
1512
+ }, 250);
1513
+ };
1514
+ const watcher = existsSync(dir) ? watch(dir, { persistent: false }, schedule) : void 0;
1056
1515
  const keep = setInterval(push, 2e3);
1057
1516
  res.on("close", () => {
1058
1517
  clearInterval(keep);
1518
+ if (soon !== void 0) clearTimeout(soon);
1059
1519
  watcher?.close();
1060
1520
  });
1061
1521
  return true;
@@ -1071,15 +1531,40 @@ function handleApi(req, res, record) {
1071
1531
  json(complianceOf(record));
1072
1532
  return true;
1073
1533
  }
1534
+ if (route === "/api/packs") {
1535
+ json(packsOf(record));
1536
+ return true;
1537
+ }
1074
1538
  if (route === "/api/redteam") {
1075
1539
  json(readRedteam(dir, files));
1076
1540
  return true;
1077
1541
  }
1542
+ if (route === "/api/status") {
1543
+ json({
1544
+ dir,
1545
+ files: Object.fromEntries(Object.entries(files).map(([name, file]) => {
1546
+ const path = resolve(dir, file);
1547
+ if (!existsSync(path)) return [name, null];
1548
+ const stat$1 = statSync(path);
1549
+ return [name, {
1550
+ size: stat$1.size,
1551
+ mtime: stat$1.mtimeMs
1552
+ }];
1553
+ }))
1554
+ });
1555
+ return true;
1556
+ }
1078
1557
  const snapshot = readRecord(dir, files);
1079
1558
  switch (route) {
1080
- case "/api/record":
1081
- json(snapshot);
1559
+ case "/api/record": {
1560
+ const over = snapshot.ledger.length - RECORD_WINDOW;
1561
+ json(over > 0 ? {
1562
+ ...snapshot,
1563
+ ledger: snapshot.ledger.slice(over),
1564
+ trimmed: over
1565
+ } : snapshot);
1082
1566
  break;
1567
+ }
1083
1568
  case "/api/ledger":
1084
1569
  json(snapshot.ledger);
1085
1570
  break;
@@ -1095,20 +1580,6 @@ function handleApi(req, res, record) {
1095
1580
  case "/api/pack":
1096
1581
  json(snapshot.pack);
1097
1582
  break;
1098
- case "/api/status":
1099
- json({
1100
- dir,
1101
- files: Object.fromEntries(Object.entries(files).map(([name, file]) => {
1102
- const path = resolve(dir, file);
1103
- if (!existsSync(path)) return [name, null];
1104
- const stat$1 = statSync(path);
1105
- return [name, {
1106
- size: stat$1.size,
1107
- mtime: stat$1.mtimeMs
1108
- }];
1109
- }))
1110
- });
1111
- break;
1112
1583
  default: json({ error: "not found" }, 404);
1113
1584
  }
1114
1585
  return true;
@@ -1125,6 +1596,7 @@ function serveConsole(root, port, record) {
1125
1596
  res.end();
1126
1597
  return;
1127
1598
  }
1599
+ if ((req.url ?? "").startsWith("/api/")) apiVersion(res);
1128
1600
  if (record !== void 0 && handleApi(req, res, record)) return;
1129
1601
  const target = safePath(root, req.url ?? "/");
1130
1602
  if (target === void 0) {
@@ -1169,6 +1641,12 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1169
1641
  prae check <pack.json>
1170
1642
  Validate a policy pack and summarise what it enforces.
1171
1643
 
1644
+ prae check <dir> [--policy-key <policy.pub.pem>]
1645
+ Validate a workspace's packs (e.g. .prae/packs) exactly as the gate
1646
+ loads them: every *.prae.json / .yaml in name order, each rejection
1647
+ with its reason, and, with --policy-key, each .sig checked. Exits
1648
+ non-zero if any pack would be rejected.
1649
+
1172
1650
  prae explain <pack.json> <tool> [key=value ...]
1173
1651
  Judge one call against the pack and print the trace. Nothing is executed.
1174
1652
 
@@ -1195,6 +1673,14 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1195
1673
  --checkpoint, also check that nothing before that checkpoint's head
1196
1674
  has been removed or rewritten since it was taken.
1197
1675
 
1676
+ prae chain <ledger.jsonl> --pack <pack.json> [--guardians <guardians.json>]
1677
+ [--key <public.pem>] [--json]
1678
+ The Attack Chain Integrity Matrix: each attack-chain row from the
1679
+ attempted action to its seal — capability, Guardian, rule, decision,
1680
+ whether it ran, the stage and why, the hash and the signature. The
1681
+ stage is classified from the sealed row and the pack, never typed in.
1682
+ Exits non-zero if the chain or (with --key) any signature fails.
1683
+
1198
1684
  prae checkpoint <ledger.jsonl> [--key <private.pem>] [--out <file>]
1199
1685
  Record the ledger's length and head, signed, to keep somewhere the
1200
1686
  ledger's machine cannot write. A chain proves no row inside the file
@@ -1252,13 +1738,31 @@ const USAGE = `⌜▪⌟ prae — operator policy for agent harnesses
1252
1738
  Read the instruction files at <dir> and propose the rules they imply.
1253
1739
  Proposals only: an existing pack is never modified.
1254
1740
 
1255
- prae init [--pack <file>] [--profile <dir>] [--ledger <file>]
1741
+ prae init [--pack <file> | --pack-dir <dir>] [--profile <dir>] [--ledger <file>]
1256
1742
  (no --pack mounts the built-in baseline of 8 high-risk rules)
1257
1743
  [--approver <name>] [--link] [--shadow] [--apply]
1258
1744
  Compose the overlay row that mounts PRAE with a pack. Prints the
1259
1745
  diff; writes only with --apply. --link points the profile at the
1260
1746
  pack file instead of copying it in, so edits hot-reload; --shadow
1261
1747
  mounts in shadow mode: every verdict sealed, every call let through.
1748
+ --pack-dir adds the workspace's own packs (e.g. .prae/packs) beside
1749
+ that pack, loaded and watched as a set; a set that would not load is
1750
+ not mounted (see \`prae check <dir>\`).
1751
+
1752
+ prae init claude-code (--settings <file> | --scope user|project)
1753
+ [--shadow | --enforce] [--pack <file>] [--ledger <file>]
1754
+ [--dir <dir>] [--apply] [--remove]
1755
+ Register PRAE as a Claude Code PreToolUse hook: every tool call,
1756
+ built-in or MCP, judged by the pack and sealed in a signed ledger
1757
+ (default ~/.prae/claude-code/). Shadow (the default) records and
1758
+ never blocks; --enforce returns deny/ask to Claude Code. Prints the
1759
+ settings diff; writes only with --apply. --remove takes out PRAE's
1760
+ own entry and nothing else. See docs/pilot-claude-code.md.
1761
+
1762
+ prae hook claude-code [--shadow | --enforce] --pack <file> --ledger <file>
1763
+ [--key <private.pem>] [--settings <file>]
1764
+ The command that settings entry runs: reads one PreToolUse payload on
1765
+ stdin and answers through the hook's output contract.
1262
1766
  `;
1263
1767
  function fail(message) {
1264
1768
  console.error(`prae: ${message}`);
@@ -1279,7 +1783,25 @@ async function loadPack(path) {
1279
1783
  }
1280
1784
  return result.pack;
1281
1785
  }
1282
- async function check(path) {
1786
+ async function checkDir(dir, keyPath) {
1787
+ let key;
1788
+ if (keyPath !== void 0) try {
1789
+ key = await readFile(keyPath, "utf8");
1790
+ } catch {
1791
+ return fail(`cannot read ${keyPath}`);
1792
+ }
1793
+ const set = loadPackDir(dir, key);
1794
+ for (const p of set.packs) {
1795
+ 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}`}`);
1796
+ 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}`);
1797
+ }
1798
+ for (const r of set.rejected) console.error(`REJECTED ${r.file.split("/").at(-1)}\n ${r.reason}`);
1799
+ if (set.packs.length === 0) console.error("\nNo valid pack: the gate would refuse every call until one loads.");
1800
+ if (set.rejected.length > 0) process.exit(1);
1801
+ console.log(`\n${set.packs.length} pack${set.packs.length === 1 ? "" : "s"} valid · in force as ${set.effective.pack} ${set.effective.version}`);
1802
+ }
1803
+ async function check(path, argv = []) {
1804
+ if (existsSync(path) && statSync(path).isDirectory()) return checkDir(path, flag(argv, "policy-key"));
1283
1805
  const pack = await loadPack(path);
1284
1806
  console.log(`${pack.pack} ${pack.version} — valid`);
1285
1807
  const baseline = pack.baseline?.capabilities;
@@ -1300,7 +1822,7 @@ async function check(path) {
1300
1822
  * from the findings, so there is nothing here to be careful about printing.
1301
1823
  */
1302
1824
  async function scanFile(path, packPath, json) {
1303
- const { scan } = await import("./src-CwBEFhww.js");
1825
+ const { scan } = await import("./src-DMoYsdfo.js");
1304
1826
  let content;
1305
1827
  try {
1306
1828
  content = await readFile(path, "utf8");
@@ -1340,13 +1862,13 @@ async function scanFile(path, packPath, json) {
1340
1862
  * not come up inspecting nothing.
1341
1863
  */
1342
1864
  async function mcp(root, packPath, ledgerPath) {
1343
- const { serveMcp } = await import("./mcp-doNYU-1C.js");
1865
+ const { serveMcp } = await import("./mcp-D521EoP6.js");
1344
1866
  const { resolve: resolve$1 } = await import("node:path");
1345
1867
  const bundled = [join(import.meta.dirname, "secret-catcher.prae.json"), join(import.meta.dirname, "..", "..", "..", "examples", "secret-catcher.prae.json")].find((candidate) => existsSync(candidate));
1346
1868
  const chosen = packPath ?? bundled;
1347
1869
  if (chosen === void 0) return fail("mcp needs --pack <file>; no bundled secret-catcher found");
1348
1870
  const pack = await loadPack(chosen);
1349
- const { Ledger: Ledger$1 } = await import("./src-d82lSKVQ.js");
1871
+ const { Ledger: Ledger$1 } = await import("./src-wG36kT6K.js");
1350
1872
  const ledger = ledgerPath === void 0 ? void 0 : Ledger$1.open(ledgerPath);
1351
1873
  console.error(`prae mcp: serving ${resolve$1(root)} under ${pack.pack} ${pack.version}` + (ledgerPath === void 0 ? " (no ledger)" : ` · ledger ${ledgerPath}`));
1352
1874
  await serveMcp({
@@ -1398,7 +1920,7 @@ async function explain(path, tool, pairs) {
1398
1920
  * a key that arrived beside it is checked against nothing.
1399
1921
  */
1400
1922
  async function keygen(dir) {
1401
- const { generateKeypair, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
1923
+ const { generateKeypair, fingerprint: fingerprint$1 } = await import("./src-wG36kT6K.js");
1402
1924
  const { writeFileSync: writeFileSync$1 } = await import("node:fs");
1403
1925
  const keys = generateKeypair();
1404
1926
  const priv = join(dir, "prae-signing.pem");
@@ -1421,7 +1943,7 @@ async function keygen(dir) {
1421
1943
  async function keygenSealed(role, dir) {
1422
1944
  if (role !== "operator" && role !== "policy" && role !== "ledger") fail("--role is operator, policy or ledger");
1423
1945
  const stem = role === "ledger" ? "prae-signing" : role;
1424
- const { sealedKeypair, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
1946
+ const { sealedKeypair, fingerprint: fingerprint$1 } = await import("./src-wG36kT6K.js");
1425
1947
  const { writeFileSync: writeFileSync$1 } = await import("node:fs");
1426
1948
  const keys = sealedKeypair(process.env.PRAE_KEY_PASSPHRASE ?? await askHidden("passphrase: "));
1427
1949
  const pub = join(dir, `${stem}.pub.pem`);
@@ -1434,11 +1956,11 @@ async function keygenSealed(role, dir) {
1434
1956
  }
1435
1957
  /** A detached signature over a pack's bytes (PRA-107). */
1436
1958
  async function signPack(packPath, keyPath) {
1437
- const { signBytes, unsealPrivateKey, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
1438
- const { createPublicKey } = await import("node:crypto");
1959
+ const { signBytes, unsealPrivateKey, fingerprint: fingerprint$1 } = await import("./src-wG36kT6K.js");
1960
+ const { createPublicKey: createPublicKey$1 } = await import("node:crypto");
1439
1961
  const bytes = readFileSync(packPath);
1440
- const parsed = parsePack(bytes.toString("utf8"));
1441
- if (!parsed.ok) fail(`${packPath} does not parse; nothing signed`);
1962
+ const parsed$1 = parsePack(bytes.toString("utf8"));
1963
+ if (!parsed$1.ok) fail(`${packPath} does not parse; nothing signed`);
1442
1964
  const passphrase = process.env.PRAE_KEY_PASSPHRASE ?? await askHidden("passphrase: ");
1443
1965
  let key;
1444
1966
  try {
@@ -1447,11 +1969,11 @@ async function signPack(packPath, keyPath) {
1447
1969
  fail("wrong passphrase, or not a sealed key");
1448
1970
  }
1449
1971
  await writeFile(`${packPath}.sig`, `${signBytes(bytes, key)}\n`);
1450
- const pub = createPublicKey(key).export({
1972
+ const pub = createPublicKey$1(key).export({
1451
1973
  type: "spki",
1452
1974
  format: "pem"
1453
1975
  });
1454
- console.log(`signed ${packPath}.sig (${parsed.pack.pack} ${parsed.pack.version})`);
1976
+ console.log(`signed ${packPath}.sig (${parsed$1.pack.pack} ${parsed$1.pack.version})`);
1455
1977
  console.log(`policy key ${fingerprint$1(pub.toString())}`);
1456
1978
  }
1457
1979
  /** Read a line from the terminal without echoing it. */
@@ -1479,11 +2001,11 @@ function askHidden(prompt) {
1479
2001
  });
1480
2002
  }
1481
2003
  async function receipt(path, seq, keyPath, out) {
1482
- const { Ledger: Ledger$1, makeReceipt, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
2004
+ const { Ledger: Ledger$1, makeReceipt, fingerprint: fingerprint$1 } = await import("./src-wG36kT6K.js");
1483
2005
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1484
- const { createPublicKey } = await import("node:crypto");
2006
+ const { createPublicKey: createPublicKey$1 } = await import("node:crypto");
1485
2007
  const privateKey = readFileSync$1(keyPath, "utf8");
1486
- const publicKey = createPublicKey(privateKey).export({
2008
+ const publicKey = createPublicKey$1(privateKey).export({
1487
2009
  type: "spki",
1488
2010
  format: "pem"
1489
2011
  }).toString();
@@ -1509,14 +2031,14 @@ async function receipt(path, seq, keyPath, out) {
1509
2031
  * file and sets the exit code, so scripts can branch on it.
1510
2032
  */
1511
2033
  async function verifyReceiptCmd(path) {
1512
- const { checkReceipt } = await import("./receipt-check-CD-W1wXs.js");
1513
- let parsed;
2034
+ const { checkReceipt } = await import("./receipt-check-CUuEjGHL.js");
2035
+ let parsed$1;
1514
2036
  try {
1515
- parsed = JSON.parse(await readFile(path, "utf8"));
2037
+ parsed$1 = JSON.parse(await readFile(path, "utf8"));
1516
2038
  } catch {
1517
2039
  return fail(`cannot read a receipt from ${path}`);
1518
2040
  }
1519
- const result = checkReceipt(parsed);
2041
+ const result = checkReceipt(parsed$1);
1520
2042
  for (const line of result.lines) if (result.ok) console.log(line);
1521
2043
  else console.error(line);
1522
2044
  if (!result.ok) process.exit(1);
@@ -1534,14 +2056,14 @@ async function test(suitePath, packOverride, asJson) {
1534
2056
  } catch {
1535
2057
  return fail(`cannot read ${suitePath}`);
1536
2058
  }
1537
- let parsed;
2059
+ let parsed$1;
1538
2060
  try {
1539
- parsed = JSON.parse(raw);
2061
+ parsed$1 = JSON.parse(raw);
1540
2062
  } catch (error) {
1541
2063
  return fail(`${suitePath} is not valid JSON: ${error instanceof Error ? error.message : error}`);
1542
2064
  }
1543
- const { parseSuite, renderSuite, runSuite } = await import("./test-run-XNIRHKOu.js");
1544
- const suite = parseSuite(parsed);
2065
+ const { parseSuite, renderSuite, runSuite } = await import("./test-run-C8dHjIE2.js");
2066
+ const suite = parseSuite(parsed$1);
1545
2067
  if (!suite.ok) {
1546
2068
  console.error(`prae: ${suitePath} is not a valid test suite`);
1547
2069
  for (const message of suite.errors) console.error(` ${message}`);
@@ -1551,7 +2073,7 @@ async function test(suitePath, packOverride, asJson) {
1551
2073
  if (chosen === void 0) return fail("test needs a pack: set `pack` in the suite or pass --pack");
1552
2074
  const pack = await loadPack(chosen);
1553
2075
  const { capabilityForTool } = await import("capmark");
1554
- const { scan } = await import("./src-CwBEFhww.js");
2076
+ const { scan } = await import("./src-DMoYsdfo.js");
1555
2077
  const inspect = pack.rules.some((rule) => rule.when.secrets !== void 0) ? (path) => {
1556
2078
  try {
1557
2079
  return scan(readFileSync(path, "utf8"));
@@ -1567,7 +2089,7 @@ async function test(suitePath, packOverride, asJson) {
1567
2089
  else for (const line of renderSuite(result)) console.log(line);
1568
2090
  if (!result.ok) process.exit(1);
1569
2091
  }
1570
- async function verify(path, keyPath, checkpointPath) {
2092
+ async function verify$1(path, keyPath, checkpointPath) {
1571
2093
  let ledger;
1572
2094
  try {
1573
2095
  ledger = Ledger.open(path);
@@ -1580,14 +2102,14 @@ async function verify(path, keyPath, checkpointPath) {
1580
2102
  const last = entries.at(-1);
1581
2103
  console.log(`${result.entries} entries · chain intact${last === void 0 ? "" : ` · seal ${seal(last)}`}`);
1582
2104
  if (checkpointPath !== void 0) {
1583
- const { checkCheckpoint } = await import("./checkpoint-BWSuhZ5h.js");
2105
+ const { checkCheckpoint } = await import("./checkpoint-BqJ8xwky.js");
1584
2106
  const publicKey = keyPath === void 0 ? void 0 : readFileSync(keyPath, "utf8");
1585
2107
  const verdict = checkCheckpoint(entries, JSON.parse(readFileSync(checkpointPath, "utf8")), publicKey);
1586
2108
  for (const line of verdict.lines) (verdict.ok ? console.log : console.error)(`checkpoint: ${line}`);
1587
2109
  if (!verdict.ok) process.exitCode = 1;
1588
2110
  }
1589
2111
  if (keyPath !== void 0) {
1590
- const { verifySignature: verifySignature$1, fingerprint: fingerprint$1 } = await import("./src-d82lSKVQ.js");
2112
+ const { verifySignature: verifySignature$1, fingerprint: fingerprint$1 } = await import("./src-wG36kT6K.js");
1591
2113
  const { readFileSync: readFileSync$1 } = await import("node:fs");
1592
2114
  const publicKey = readFileSync$1(keyPath, "utf8");
1593
2115
  let signed = 0;
@@ -1665,7 +2187,7 @@ async function report(path, out, packPath) {
1665
2187
  * that step is a proposal a human approves, and it starts from this list.
1666
2188
  */
1667
2189
  async function instructions(from, asJson) {
1668
- const { ingest } = await import("./src-d4c_n6wd.js");
2190
+ const { ingest } = await import("./src-CDYyZyeU.js");
1669
2191
  const result = ingest(from);
1670
2192
  if (asJson) {
1671
2193
  console.log(JSON.stringify(result, null, 2));
@@ -1701,7 +2223,7 @@ async function instructions(from, asJson) {
1701
2223
  * on purpose rather than something that happened to their policy.
1702
2224
  */
1703
2225
  async function propose(from, options) {
1704
- const [{ ingest }, { extract }] = await Promise.all([import("./src-d4c_n6wd.js"), import("./src-CBGZVvt7.js")]);
2226
+ const [{ ingest }, { extract }] = await Promise.all([import("./src-CDYyZyeU.js"), import("./src-BlmsTGdY.js")]);
1705
2227
  const read = ingest(from);
1706
2228
  if (read.files.length === 0) {
1707
2229
  console.log(`No instruction files found at or above ${from}.`);
@@ -1761,9 +2283,9 @@ async function consoleOut(path, argv) {
1761
2283
  const instructionsDir = flag(argv, "instructions");
1762
2284
  if (instructionsDir !== void 0) {
1763
2285
  const [{ ingest }, { extract }, { renderInstructions }] = await Promise.all([
1764
- import("./src-d4c_n6wd.js"),
1765
- import("./src-CBGZVvt7.js"),
1766
- import("./instructions-Bg5puOen.js")
2286
+ import("./src-CDYyZyeU.js"),
2287
+ import("./src-BlmsTGdY.js"),
2288
+ import("./instructions-DihPiiC2.js")
1767
2289
  ]);
1768
2290
  const read = ingest(instructionsDir);
1769
2291
  instructionsView = renderInstructions(data, read, extract(read.statements));
@@ -1804,9 +2326,12 @@ async function consoleOut(path, argv) {
1804
2326
  console.log(`record at ${url}api/record · events at ${url}api/events`);
1805
2327
  console.log("loopback only, read-only. ctrl-c to stop.");
1806
2328
  }
2329
+ /** The bundled baseline: beside the built code, or in examples/ in source. */
2330
+ function bundledBaseline() {
2331
+ return [join(import.meta.dirname, "prae-baseline.prae.json"), join(import.meta.dirname, "..", "..", "..", "examples", "prae-baseline.prae.json")].find((candidate) => existsSync(candidate));
2332
+ }
1807
2333
  async function init(argv) {
1808
- const bundledBaseline = [join(import.meta.dirname, "prae-baseline.prae.json"), join(import.meta.dirname, "..", "..", "..", "examples", "prae-baseline.prae.json")].find((candidate) => existsSync(candidate));
1809
- const packPath = flag(argv, "pack") ?? bundledBaseline;
2334
+ const packPath = flag(argv, "pack") ?? bundledBaseline();
1810
2335
  if (packPath === void 0) fail("init needs --pack <file>; no bundled baseline found");
1811
2336
  if (flag(argv, "pack") === void 0) {
1812
2337
  console.log("No --pack given; mounting the built-in baseline (8 rules).");
@@ -1814,14 +2339,29 @@ async function init(argv) {
1814
2339
  }
1815
2340
  const pack = await loadPack(packPath);
1816
2341
  const source = await readFile(packPath, "utf8");
2342
+ const link = argv.includes("--link");
2343
+ const builtin = link ? { packFile: resolve(packPath) } : { pack: source };
2344
+ const builtinLine = `${pack.pack} ${pack.version} · ${pack.rules.length} rules${link ? ` · linked, edits hot-reload (${resolve(packPath)})` : ""}`;
2345
+ const packDirFlag = flag(argv, "pack-dir");
2346
+ if (packDirFlag === void 0) return writeRow(argv, builtin, builtinLine);
2347
+ const packDir = resolve(packDirFlag);
2348
+ const set = loadPackDir(packDir, void 0, void 0, readPackFile(packPath, void 0, "builtin"));
2349
+ for (const r of set.rejected) console.error(`REJECTED ${r.file.split("/").at(-1)}: ${r.reason}`);
2350
+ if (set.rejected.length > 0) fail(`not mounting ${packDir}: fix the packs above (prae check ${packDir})`);
2351
+ const custom = set.packs.filter((p) => p.origin === "workspace");
2352
+ return writeRow(argv, {
2353
+ ...builtin,
2354
+ packDir
2355
+ }, `${builtinLine}\n + ${custom.map((p) => `${p.pack.pack} ${p.pack.version}`).join(", ")} · workspace, watched (${packDir})`);
2356
+ }
2357
+ async function writeRow(argv, source, packLine) {
1817
2358
  const profileDir = flag(argv, "profile");
1818
2359
  const profile = profileDir === void 0 ? defaultProfile() : profileAt(profileDir);
1819
2360
  const ledger = flag(argv, "ledger");
1820
2361
  const approver = flag(argv, "approver");
1821
- const link = argv.includes("--link");
1822
2362
  const shadow = argv.includes("--shadow");
1823
2363
  const row$1 = praeRow({
1824
- ...link ? { packFile: resolve(packPath) } : { pack: source },
2364
+ ...source,
1825
2365
  ...shadow ? { mode: "shadow" } : {},
1826
2366
  ...ledger === void 0 ? {} : { ledger },
1827
2367
  ...approver === void 0 ? {} : { approver }
@@ -1834,7 +2374,7 @@ async function init(argv) {
1834
2374
  }
1835
2375
  console.log(`profile ${profile.dir}`);
1836
2376
  console.log(`overlay ${plan.patchPath}`);
1837
- console.log(`pack ${pack.pack} ${pack.version} · ${pack.rules.length} rules${link ? ` · linked, edits hot-reload (${resolve(packPath)})` : ""}`);
2377
+ console.log(`pack ${packLine}`);
1838
2378
  if (shadow) console.log("mode shadow · every verdict sealed, every call proceeds");
1839
2379
  if (ledger !== void 0) console.log(`ledger ${ledger}`);
1840
2380
  if (plan.findings.length > 0) {
@@ -1861,8 +2401,55 @@ async function init(argv) {
1861
2401
  return fail(error instanceof Error ? error.message : String(error));
1862
2402
  }
1863
2403
  }
2404
+ /** `prae chain`: the Attack Chain Integrity Matrix, from the record alone (PRA-128). */
2405
+ async function chainMatrix(path, argv) {
2406
+ const packPath = flag(argv, "pack");
2407
+ if (packPath === void 0) fail("chain needs --pack <pack.json>");
2408
+ const pack = await loadPack(packPath);
2409
+ let ledger;
2410
+ try {
2411
+ ledger = Ledger.open(path);
2412
+ } catch (error) {
2413
+ return fail(error instanceof Error ? error.message : String(error));
2414
+ }
2415
+ const entries = ledger.read();
2416
+ const chainOk = ledger.verify().ok;
2417
+ const guardiansPath = flag(argv, "guardians");
2418
+ const catalogue = guardiansPath === void 0 ? [] : JSON.parse(readFileSync(guardiansPath, "utf8")).guardians;
2419
+ const guardianOf = (rule) => rule === void 0 ? void 0 : catalogue.find((g) => g.rules.includes(rule) || (g.armed ?? []).includes(rule))?.name;
2420
+ const keyPath = flag(argv, "key");
2421
+ const { verifySignature: verifySignature$1 } = await import("./src-wG36kT6K.js");
2422
+ const publicKey = keyPath === void 0 ? void 0 : readFileSync(keyPath, "utf8");
2423
+ const matrix = integrityMatrix(entries, pack, guardianOf).map((m) => {
2424
+ const entry = entries.find((e) => e.seq === m.seq);
2425
+ return {
2426
+ ...m,
2427
+ verified: publicKey === void 0 || entry?.sig === void 0 ? void 0 : verifySignature$1(entry, entry.sig, publicKey)
2428
+ };
2429
+ });
2430
+ const sigFailed = matrix.some((m) => m.verified === false);
2431
+ if (argv.includes("--json")) console.log(JSON.stringify({
2432
+ chain: chainOk,
2433
+ rows: matrix
2434
+ }, null, 2));
2435
+ else {
2436
+ for (const m of matrix) {
2437
+ console.log(`#${m.seq} ${m.stage}`);
2438
+ console.log(` attempted ${m.action}${m.target ? ` ${m.target}` : ""}`);
2439
+ console.log(` capability ${m.capability ?? "—"}`);
2440
+ console.log(` guardian ${m.guardian ?? "—"}`);
2441
+ console.log(` rule ${m.rule === void 0 ? "— (no rule matched)" : m.ruleRole === "recorded" ? `${m.rule} (matched and recorded; did not decide)` : m.rule}`);
2442
+ console.log(` decision ${m.decision} · ${m.execution}`);
2443
+ console.log(` basis ${m.basis}`);
2444
+ if (m.mismatch !== void 0) console.log(` mismatch row #${m.mismatch} (sealed link)`);
2445
+ 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"}`);
2446
+ }
2447
+ console.log(`\n${matrix.length} attack-chain rows · chain ${chainOk ? "intact" : "BROKEN"}${publicKey === void 0 ? "" : sigFailed ? " · signatures FAIL" : " · signatures verify"}`);
2448
+ }
2449
+ if (!chainOk || sigFailed) process.exitCode = 1;
2450
+ }
1864
2451
  async function checkpoint(path, keyPath, out) {
1865
- const { makeCheckpoint } = await import("./checkpoint-BWSuhZ5h.js");
2452
+ const { makeCheckpoint } = await import("./checkpoint-BqJ8xwky.js");
1866
2453
  let ledger;
1867
2454
  try {
1868
2455
  ledger = Ledger.open(path);
@@ -1883,9 +2470,9 @@ async function checkpoint(path, keyPath, out) {
1883
2470
  }
1884
2471
  }
1885
2472
  async function exportLedger(path, options) {
1886
- const { toOcsf, ndjson, sendToHec } = await import("./ocsf-DhVGbBUw.js");
1887
- const { createRequire } = await import("node:module");
1888
- const { version } = createRequire(import.meta.url)("../package.json");
2473
+ const { toOcsf, ndjson, sendToHec } = await import("./ocsf-DF9cMiQJ.js");
2474
+ const { createRequire: createRequire$1 } = await import("node:module");
2475
+ const { version } = createRequire$1(import.meta.url)("../package.json");
1889
2476
  let ledger;
1890
2477
  try {
1891
2478
  ledger = Ledger.open(path);
@@ -1920,8 +2507,8 @@ async function main(argv) {
1920
2507
  switch (command) {
1921
2508
  case "check": {
1922
2509
  const path = rest[0];
1923
- if (path === void 0) fail("check needs a pack file");
1924
- return check(path);
2510
+ if (path === void 0) fail("check needs a pack file or directory");
2511
+ return check(path, rest);
1925
2512
  }
1926
2513
  case "explain": {
1927
2514
  const [path, tool, ...pairs] = rest;
@@ -1964,7 +2551,12 @@ async function main(argv) {
1964
2551
  case "verify": {
1965
2552
  const path = rest[0];
1966
2553
  if (path === void 0) fail("verify needs a ledger file");
1967
- return verify(path, flag(rest, "key"), flag(rest, "checkpoint"));
2554
+ return verify$1(path, flag(rest, "key"), flag(rest, "checkpoint"));
2555
+ }
2556
+ case "chain": {
2557
+ const path = rest[0];
2558
+ if (path === void 0) fail("chain needs a ledger file");
2559
+ return chainMatrix(path, rest);
1968
2560
  }
1969
2561
  case "checkpoint": {
1970
2562
  const path = rest[0];
@@ -2007,7 +2599,26 @@ async function main(argv) {
2007
2599
  ...flag(rest, "out") === void 0 ? {} : { out: flag(rest, "out") }
2008
2600
  });
2009
2601
  }
2010
- case "init": return init(rest);
2602
+ case "init":
2603
+ if (rest[0] === "claude-code") {
2604
+ const { initClaudeCode, stableNode } = await import("./init-DRd-IRR6.js");
2605
+ try {
2606
+ return initClaudeCode(rest.slice(1), {
2607
+ cwd: process.cwd(),
2608
+ node: stableNode(process.execPath, process.env.PATH),
2609
+ bin: resolve(process.argv[1] ?? ""),
2610
+ baseline: bundledBaseline()
2611
+ });
2612
+ } catch (error) {
2613
+ return fail(error instanceof Error ? error.message : String(error));
2614
+ }
2615
+ }
2616
+ return init(rest);
2617
+ case "hook": {
2618
+ if (rest[0] !== "claude-code") fail("hook takes claude-code");
2619
+ const { runHook } = await import("./hook-Gll2spTP.js");
2620
+ return runHook(rest.slice(1));
2621
+ }
2011
2622
  case "--help":
2012
2623
  case "-h":
2013
2624
  case void 0: