prae-cli 0.1.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.
@@ -0,0 +1,2405 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, extname, join, normalize, resolve, sep } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { composePatchFile, preflightOps, revisionOf, takeSnapshot } from "dsh-overlay-check";
7
+ import { createServer } from "node:http";
8
+
9
+ //#region ../kernel/src/path.ts
10
+ /**
11
+ * Path normalization and glob containment, with no filesystem access.
12
+ *
13
+ * The contract, stated here because getting it wrong is how path policies
14
+ * become theatre: **this module compares strings.** It resolves `.` and `..`
15
+ * lexically so that `./src/../../etc/passwd` cannot pose as a workspace path,
16
+ * but it cannot see a symlink. A link inside an allowed directory pointing out
17
+ * of it will pass every check here.
18
+ *
19
+ * That is not a gap to paper over in the matcher — it is a gap the caller
20
+ * closes by resolving before it asks. `dsh-overlay-check` learned this the
21
+ * hard way during the plugin-store gate review: lexical containment accepted a
22
+ * symlink escape cleanly, and the fix was to resolve both the path and its
23
+ * nearest existing ancestor before deciding. Adapters that can touch the
24
+ * filesystem must resolve first and pass the resolved path in.
25
+ */
26
+ /** Split a path into segments, resolving `.` and `..` lexically. */
27
+ function normalize$1(path) {
28
+ const absolute = path.startsWith("/");
29
+ const out = [];
30
+ let escaped = false;
31
+ for (const raw of path.split(/[/\\]+/)) {
32
+ if (raw === "" || raw === ".") continue;
33
+ if (raw === "..") {
34
+ if (out.length > 0) out.pop();
35
+ else escaped = true;
36
+ continue;
37
+ }
38
+ out.push(raw);
39
+ }
40
+ return {
41
+ segments: out,
42
+ absolute,
43
+ escaped
44
+ };
45
+ }
46
+ /** Turn one glob segment into an anchored regular expression. */
47
+ function segmentPattern(segment) {
48
+ const body = segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
49
+ return /* @__PURE__ */ new RegExp(`^${body}$`);
50
+ }
51
+ /**
52
+ * Wildcard segment matching, where `**` spans zero or more segments.
53
+ *
54
+ * Two-pointer with backtracking rather than a recursive walk: the pattern
55
+ * comes from a policy pack and the path from a tool call, and a catastrophic
56
+ * backtrack in the hot path of every call would be a denial of service handed
57
+ * to whoever writes the pack.
58
+ */
59
+ function matchSegments(path, pattern) {
60
+ let i = 0;
61
+ let j = 0;
62
+ let starPattern = -1;
63
+ let starPath = 0;
64
+ while (i < path.length) {
65
+ const segment = j < pattern.length ? pattern[j] : void 0;
66
+ const here = path[i];
67
+ if (segment === "**") {
68
+ starPattern = j;
69
+ starPath = i;
70
+ j += 1;
71
+ continue;
72
+ }
73
+ if (segment !== void 0 && here !== void 0 && segmentPattern(segment).test(here)) {
74
+ i += 1;
75
+ j += 1;
76
+ continue;
77
+ }
78
+ if (starPattern >= 0) {
79
+ starPath += 1;
80
+ i = starPath;
81
+ j = starPattern + 1;
82
+ continue;
83
+ }
84
+ return false;
85
+ }
86
+ while (j < pattern.length && pattern[j] === "**") j += 1;
87
+ return j === pattern.length;
88
+ }
89
+ /**
90
+ * Is `path` contained by `glob`?
91
+ *
92
+ * A glob naming a directory contains everything under it: `./src` and
93
+ * `./src/**` both match `src/app/main.ts`. Writing the shorter form is the
94
+ * common intent, and treating it as "this exact directory entry only" is a
95
+ * trap that silently grants the whole subtree past the rule.
96
+ */
97
+ function within(path, glob) {
98
+ const target = normalize$1(path);
99
+ if (target.escaped) return false;
100
+ const pattern = normalize$1(glob);
101
+ if (matchSegments(target.segments, pattern.segments)) return true;
102
+ return matchSegments(target.segments, [...pattern.segments, "**"]);
103
+ }
104
+ /** Is `path` inside any of these globs? */
105
+ function withinAny(path, globs) {
106
+ return globs.some((glob) => within(path, glob));
107
+ }
108
+ /**
109
+ * The host of a URL, or undefined when it is not a URL we can read.
110
+ *
111
+ * Unparseable input returns undefined, and callers treat that as "no host" —
112
+ * which for a `notIn` rule means the rule matches. Failing toward the rule is
113
+ * the only safe direction: a string we cannot parse is exactly the shape an
114
+ * evasion attempt takes.
115
+ */
116
+ function hostOf(url) {
117
+ try {
118
+ return new URL(url).hostname.toLowerCase();
119
+ } catch {
120
+ return;
121
+ }
122
+ }
123
+ /**
124
+ * Host list membership, where a leading dot means "this domain or below".
125
+ *
126
+ * `example.com` matches only that host. `.example.com` also matches
127
+ * `api.example.com`, and deliberately still matches `example.com` itself —
128
+ * an operator writing the suffix form means the organisation, not a rule that
129
+ * covers every subdomain except the one they first thought of.
130
+ */
131
+ function hostIn(host, list) {
132
+ return list.some((entry) => {
133
+ const want = entry.toLowerCase();
134
+ if (want.startsWith(".")) return host === want.slice(1) || host.endsWith(want);
135
+ return host === want;
136
+ });
137
+ }
138
+
139
+ //#endregion
140
+ //#region ../kernel/src/rule.ts
141
+ /**
142
+ * Strength ordering, used when several rules match one call.
143
+ *
144
+ * Higher wins. This is the property that makes a pack safe to extend: adding a
145
+ * rule can raise the strength of a decision and never lower it, so an operator
146
+ * appending to a pack can never accidentally widen what an agent may do.
147
+ */
148
+ const STRENGTH = {
149
+ warn: 1,
150
+ correct: 2,
151
+ ask: 3,
152
+ deny: 4
153
+ };
154
+
155
+ //#endregion
156
+ //#region ../kernel/src/evaluate.ts
157
+ function listed(value, want) {
158
+ if (want === void 0) return true;
159
+ if (value === void 0) return false;
160
+ return typeof want === "string" ? value === want : want.includes(value);
161
+ }
162
+ function argMatches(match, args) {
163
+ const has = Object.hasOwn(args, match.name);
164
+ if (match.present !== void 0 && match.present !== has) return false;
165
+ if (!has) return match.above === void 0 && match.below === void 0 && match.equals === void 0 && match.oneOf === void 0;
166
+ const value = args[match.name];
167
+ if (match.above !== void 0) {
168
+ if (typeof value !== "number" || !(value > match.above)) return false;
169
+ }
170
+ if (match.below !== void 0) {
171
+ if (typeof value !== "number" || !(value < match.below)) return false;
172
+ }
173
+ if (match.equals !== void 0 && value !== match.equals) return false;
174
+ if (match.oneOf !== void 0) {
175
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return false;
176
+ if (!match.oneOf.includes(value)) return false;
177
+ }
178
+ return true;
179
+ }
180
+ function matches(when, facts) {
181
+ if (!listed(facts.tool, when.tool)) return false;
182
+ if (!listed(facts.capability, when.capability)) return false;
183
+ if (!listed(facts.plugin, when.plugin)) return false;
184
+ if (!listed(facts.agent, when.agent)) return false;
185
+ if (when.path !== void 0) {
186
+ const raw = facts.args[when.path.arg ?? "path"];
187
+ if (typeof raw !== "string") return false;
188
+ if (when.path.inside !== void 0 && !withinAny(raw, when.path.inside)) return false;
189
+ if (when.path.outside !== void 0 && withinAny(raw, when.path.outside)) return false;
190
+ }
191
+ if (when.host !== void 0) {
192
+ const raw = facts.args[when.host.arg ?? "url"];
193
+ if (typeof raw !== "string") return false;
194
+ const host = hostOf(raw);
195
+ if (host === void 0) return when.host.notIn !== void 0;
196
+ if (when.host.in !== void 0 && !hostIn(host, when.host.in)) return false;
197
+ if (when.host.notIn !== void 0 && hostIn(host, when.host.notIn)) return false;
198
+ }
199
+ if (when.arg !== void 0) {
200
+ for (const one of when.arg) if (!argMatches(one, facts.args)) return false;
201
+ }
202
+ if (when.count !== void 0) {
203
+ const key = facts.capability;
204
+ if (key === void 0) return false;
205
+ if (!((facts.counts?.[key] ?? 0) > when.count.above)) return false;
206
+ }
207
+ return true;
208
+ }
209
+ function step(rule, effect) {
210
+ return {
211
+ rule: rule.id,
212
+ ...rule.title === void 0 ? {} : { title: rule.title },
213
+ effect: effect.kind,
214
+ because: effect.because
215
+ };
216
+ }
217
+ /**
218
+ * Decide one call against one pack.
219
+ *
220
+ * Every matching rule lands in the trace; the strongest effect decides. Ties
221
+ * go to the first rule in pack order, so a pack reads top-down the way its
222
+ * author wrote it.
223
+ *
224
+ * `correct` is the effect the rest of this ecosystem does not have. A rival
225
+ * guardrail can allow a call or refuse it; a rule here can also rewrite an
226
+ * argument and let it through, recording what it changed. Refusing is not
227
+ * always the safe option — an agent denied mid-task retries, and the retry is
228
+ * rarely safer than the clamp would have been.
229
+ */
230
+ function evaluate(pack, facts) {
231
+ const hits = [];
232
+ for (const rule of pack.rules) if (matches(rule.when, facts)) hits.push({
233
+ rule,
234
+ effect: rule.effect
235
+ });
236
+ if (hits.length === 0) return {
237
+ kind: "allow",
238
+ trace: [],
239
+ evaluated: pack.rules.length
240
+ };
241
+ hits.sort((a, b) => STRENGTH[b.effect.kind] - STRENGTH[a.effect.kind]);
242
+ const trace = hits.map((hit) => step(hit.rule, hit.effect));
243
+ const evaluated = pack.rules.length;
244
+ const strongest = hits[0];
245
+ if (strongest === void 0) return {
246
+ kind: "allow",
247
+ trace: [],
248
+ evaluated
249
+ };
250
+ const winner = strongest.effect.kind;
251
+ if (winner === "deny" || winner === "ask") return {
252
+ kind: winner,
253
+ rule: strongest.rule.id,
254
+ reason: strongest.effect.because,
255
+ trace,
256
+ evaluated
257
+ };
258
+ if (winner === "correct") {
259
+ const args = { ...facts.args };
260
+ for (const hit of hits) if (hit.effect.kind === "correct") Object.assign(args, hit.effect.set);
261
+ return {
262
+ kind: "correct",
263
+ rule: strongest.rule.id,
264
+ reason: strongest.effect.because,
265
+ args,
266
+ trace,
267
+ evaluated
268
+ };
269
+ }
270
+ return {
271
+ kind: "allow",
272
+ trace,
273
+ evaluated
274
+ };
275
+ }
276
+
277
+ //#endregion
278
+ //#region ../kernel/src/pack.ts
279
+ function isObject(value) {
280
+ return typeof value === "object" && value !== null && !Array.isArray(value);
281
+ }
282
+ var Check = class {
283
+ errors = [];
284
+ fail(path, message) {
285
+ this.errors.push({
286
+ path,
287
+ message
288
+ });
289
+ }
290
+ /** Reject any key the shape does not define. */
291
+ keys(path, value, allowed) {
292
+ 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(", ")}`);
293
+ }
294
+ text(path, value) {
295
+ if (typeof value !== "string" || value.trim() === "") {
296
+ this.fail(path, "expected a non-empty string");
297
+ return;
298
+ }
299
+ return value;
300
+ }
301
+ /** A field that accepts one name or a list of them. */
302
+ names(path, value) {
303
+ if (typeof value === "string") return value;
304
+ if (Array.isArray(value)) {
305
+ const out = [];
306
+ value.forEach((entry, index) => {
307
+ const one = this.text(`${path}[${index}]`, entry);
308
+ if (one !== void 0) out.push(one);
309
+ });
310
+ return out;
311
+ }
312
+ this.fail(path, "expected a string or an array of strings");
313
+ }
314
+ globs(path, value) {
315
+ if (!Array.isArray(value)) {
316
+ this.fail(path, "expected an array of path globs");
317
+ return;
318
+ }
319
+ const out = [];
320
+ value.forEach((entry, index) => {
321
+ const one = this.text(`${path}[${index}]`, entry);
322
+ if (one !== void 0) out.push(one);
323
+ });
324
+ return out;
325
+ }
326
+ number(path, value) {
327
+ if (typeof value !== "number" || !Number.isFinite(value)) {
328
+ this.fail(path, "expected a finite number");
329
+ return;
330
+ }
331
+ return value;
332
+ }
333
+ };
334
+ function readPathMatch(check$1, path, value) {
335
+ if (!isObject(value)) {
336
+ check$1.fail(path, "expected an object");
337
+ return;
338
+ }
339
+ check$1.keys(path, value, [
340
+ "arg",
341
+ "outside",
342
+ "inside"
343
+ ]);
344
+ if (value.outside === void 0 && value.inside === void 0) check$1.fail(path, "expected `inside` or `outside`; a path matcher with neither matches nothing");
345
+ const out = {};
346
+ if (value.arg !== void 0) {
347
+ const arg = check$1.text(`${path}.arg`, value.arg);
348
+ if (arg !== void 0) out.arg = arg;
349
+ }
350
+ if (value.outside !== void 0) {
351
+ const globs = check$1.globs(`${path}.outside`, value.outside);
352
+ if (globs !== void 0) out.outside = globs;
353
+ }
354
+ if (value.inside !== void 0) {
355
+ const globs = check$1.globs(`${path}.inside`, value.inside);
356
+ if (globs !== void 0) out.inside = globs;
357
+ }
358
+ return out;
359
+ }
360
+ function readHostMatch(check$1, path, value) {
361
+ if (!isObject(value)) {
362
+ check$1.fail(path, "expected an object");
363
+ return;
364
+ }
365
+ check$1.keys(path, value, [
366
+ "arg",
367
+ "in",
368
+ "notIn"
369
+ ]);
370
+ if (value.in === void 0 && value.notIn === void 0) check$1.fail(path, "expected `in` or `notIn`; a host matcher with neither matches nothing");
371
+ const out = {};
372
+ if (value.arg !== void 0) {
373
+ const arg = check$1.text(`${path}.arg`, value.arg);
374
+ if (arg !== void 0) out.arg = arg;
375
+ }
376
+ if (value.in !== void 0) {
377
+ const list = check$1.globs(`${path}.in`, value.in);
378
+ if (list !== void 0) out.in = list;
379
+ }
380
+ if (value.notIn !== void 0) {
381
+ const list = check$1.globs(`${path}.notIn`, value.notIn);
382
+ if (list !== void 0) out.notIn = list;
383
+ }
384
+ return out;
385
+ }
386
+ function readArgMatch(check$1, path, value) {
387
+ if (!isObject(value)) {
388
+ check$1.fail(path, "expected an object");
389
+ return;
390
+ }
391
+ check$1.keys(path, value, [
392
+ "name",
393
+ "above",
394
+ "below",
395
+ "equals",
396
+ "oneOf",
397
+ "present"
398
+ ]);
399
+ const name = check$1.text(`${path}.name`, value.name);
400
+ if (name === void 0) return void 0;
401
+ const out = { name };
402
+ if (value.above !== void 0) {
403
+ const n = check$1.number(`${path}.above`, value.above);
404
+ if (n !== void 0) out.above = n;
405
+ }
406
+ if (value.below !== void 0) {
407
+ const n = check$1.number(`${path}.below`, value.below);
408
+ if (n !== void 0) out.below = n;
409
+ }
410
+ if (value.equals !== void 0) out.equals = value.equals;
411
+ if (value.oneOf !== void 0) if (!Array.isArray(value.oneOf)) check$1.fail(`${path}.oneOf`, "expected an array");
412
+ else out.oneOf = value.oneOf;
413
+ if (value.present !== void 0) if (typeof value.present !== "boolean") check$1.fail(`${path}.present`, "expected a boolean");
414
+ else out.present = value.present;
415
+ return out;
416
+ }
417
+ function readMatch(check$1, path, value) {
418
+ if (!isObject(value)) {
419
+ check$1.fail(path, "expected an object");
420
+ return {};
421
+ }
422
+ check$1.keys(path, value, [
423
+ "tool",
424
+ "capability",
425
+ "plugin",
426
+ "agent",
427
+ "path",
428
+ "host",
429
+ "arg",
430
+ "count"
431
+ ]);
432
+ const out = {};
433
+ for (const key of [
434
+ "tool",
435
+ "capability",
436
+ "plugin",
437
+ "agent"
438
+ ]) if (value[key] !== void 0) {
439
+ const names = check$1.names(`${path}.${key}`, value[key]);
440
+ if (names !== void 0) out[key] = names;
441
+ }
442
+ if (value.path !== void 0) {
443
+ const match = readPathMatch(check$1, `${path}.path`, value.path);
444
+ if (match !== void 0) out.path = match;
445
+ }
446
+ if (value.host !== void 0) {
447
+ const match = readHostMatch(check$1, `${path}.host`, value.host);
448
+ if (match !== void 0) out.host = match;
449
+ }
450
+ if (value.arg !== void 0) if (!Array.isArray(value.arg)) check$1.fail(`${path}.arg`, "expected an array of argument matchers");
451
+ else {
452
+ const list = [];
453
+ value.arg.forEach((entry, index) => {
454
+ const one = readArgMatch(check$1, `${path}.arg[${index}]`, entry);
455
+ if (one !== void 0) list.push(one);
456
+ });
457
+ out.arg = list;
458
+ }
459
+ if (value.count !== void 0) if (!isObject(value.count)) check$1.fail(`${path}.count`, "expected an object");
460
+ else {
461
+ check$1.keys(`${path}.count`, value.count, ["above"]);
462
+ const above = check$1.number(`${path}.count.above`, value.count.above);
463
+ if (above !== void 0) out.count = { above };
464
+ }
465
+ return out;
466
+ }
467
+ function readEffect(check$1, path, value) {
468
+ if (!isObject(value)) {
469
+ check$1.fail(path, "expected an object");
470
+ return;
471
+ }
472
+ const kind = value.kind;
473
+ if (kind !== "deny" && kind !== "ask" && kind !== "correct" && kind !== "warn") {
474
+ check$1.fail(`${path}.kind`, "expected `deny`, `ask`, `correct`, or `warn`");
475
+ return;
476
+ }
477
+ check$1.keys(path, value, kind === "correct" ? [
478
+ "kind",
479
+ "set",
480
+ "because"
481
+ ] : ["kind", "because"]);
482
+ const because = check$1.text(`${path}.because`, value.because);
483
+ if (because === void 0) return void 0;
484
+ if (kind === "correct") {
485
+ if (!isObject(value.set) || Object.keys(value.set).length === 0) {
486
+ check$1.fail(`${path}.set`, "expected a non-empty object of arguments to set");
487
+ return;
488
+ }
489
+ return {
490
+ kind,
491
+ set: value.set,
492
+ because
493
+ };
494
+ }
495
+ return {
496
+ kind,
497
+ because
498
+ };
499
+ }
500
+ function readRule(check$1, path, value) {
501
+ if (!isObject(value)) {
502
+ check$1.fail(path, "expected an object");
503
+ return;
504
+ }
505
+ check$1.keys(path, value, [
506
+ "id",
507
+ "title",
508
+ "when",
509
+ "effect"
510
+ ]);
511
+ const id = check$1.text(`${path}.id`, value.id);
512
+ const effect = readEffect(check$1, `${path}.effect`, value.effect);
513
+ if (value.when === void 0) check$1.fail(`${path}.when`, "expected an object");
514
+ const when = readMatch(check$1, `${path}.when`, value.when ?? {});
515
+ if (id === void 0 || effect === void 0) return void 0;
516
+ const rule = {
517
+ id,
518
+ when,
519
+ effect
520
+ };
521
+ if (value.title !== void 0) {
522
+ const title = check$1.text(`${path}.title`, value.title);
523
+ if (title !== void 0) rule.title = title;
524
+ }
525
+ return rule;
526
+ }
527
+ /** Parse and validate a pack. Never throws; every problem lands in `errors`. */
528
+ function parsePack(source) {
529
+ let raw;
530
+ try {
531
+ raw = JSON.parse(source);
532
+ } catch (error) {
533
+ return {
534
+ ok: false,
535
+ errors: [{
536
+ path: "",
537
+ message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}`
538
+ }]
539
+ };
540
+ }
541
+ const check$1 = new Check();
542
+ if (!isObject(raw)) return {
543
+ ok: false,
544
+ errors: [{
545
+ path: "",
546
+ message: "expected an object"
547
+ }]
548
+ };
549
+ check$1.keys("", raw, [
550
+ "prae",
551
+ "pack",
552
+ "version",
553
+ "baseline",
554
+ "rules"
555
+ ]);
556
+ if (raw.prae !== "0.1") check$1.fail(".prae", "expected `\"0.1\"`; this is the only pack version this build reads");
557
+ const name = check$1.text(".pack", raw.pack);
558
+ const version = check$1.text(".version", raw.version);
559
+ let baseline;
560
+ if (raw.baseline !== void 0) if (!isObject(raw.baseline)) check$1.fail(".baseline", "expected an object");
561
+ else {
562
+ check$1.keys(".baseline", raw.baseline, ["capabilities"]);
563
+ const caps = check$1.globs(".baseline.capabilities", raw.baseline.capabilities);
564
+ if (caps !== void 0) baseline = { capabilities: caps };
565
+ }
566
+ const rules = [];
567
+ if (!Array.isArray(raw.rules)) check$1.fail(".rules", "expected an array");
568
+ else {
569
+ const seen = /* @__PURE__ */ new Set();
570
+ raw.rules.forEach((entry, index) => {
571
+ const rule = readRule(check$1, `rules[${index}]`, entry);
572
+ if (rule === void 0) return;
573
+ if (seen.has(rule.id)) {
574
+ check$1.fail(`rules[${index}].id`, `duplicate rule id \`${rule.id}\``);
575
+ return;
576
+ }
577
+ seen.add(rule.id);
578
+ rules.push(rule);
579
+ });
580
+ }
581
+ if (check$1.errors.length > 0) return {
582
+ ok: false,
583
+ errors: check$1.errors
584
+ };
585
+ if (name === void 0 || version === void 0) return {
586
+ ok: false,
587
+ errors: check$1.errors
588
+ };
589
+ return {
590
+ ok: true,
591
+ pack: {
592
+ prae: "0.1",
593
+ pack: name,
594
+ version,
595
+ ...baseline === void 0 ? {} : { baseline },
596
+ rules
597
+ }
598
+ };
599
+ }
600
+
601
+ //#endregion
602
+ //#region ../ledger/src/entry.ts
603
+ /** The hash a chain starts from. */
604
+ const GENESIS = "0".repeat(64);
605
+ /**
606
+ * Deterministic serialization: object keys sorted at every depth.
607
+ *
608
+ * A hash over `JSON.stringify(entry)` would depend on key insertion order,
609
+ * which means a re-serialized entry could hash differently while saying
610
+ * exactly the same thing — and a verifier that reports tampering on a
611
+ * faithful copy trains its operators to ignore it.
612
+ */
613
+ function canonical(value) {
614
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
615
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
616
+ return `{${Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
617
+ }
618
+ /** Hash one entry's content together with its position and predecessor. */
619
+ function hashEntry(record, seq, prev) {
620
+ return createHash("sha256").update(canonical({
621
+ ...record,
622
+ seq,
623
+ prev
624
+ })).digest("hex");
625
+ }
626
+ /** Chain a record onto a predecessor. */
627
+ function chain(record, seq, prev) {
628
+ return {
629
+ ...record,
630
+ seq,
631
+ prev,
632
+ hash: hashEntry(record, seq, prev)
633
+ };
634
+ }
635
+ /** The short form an auditor quotes from a screenshot. */
636
+ function seal(entry) {
637
+ return `${entry.hash.slice(0, 4)}…${entry.hash.slice(-4)}`;
638
+ }
639
+
640
+ //#endregion
641
+ //#region ../ledger/src/ledger.ts
642
+ /**
643
+ * Read a ledger held as text.
644
+ *
645
+ * Split out from the class because a ledger is not always a file being
646
+ * appended to: a build step renders one it imported, and a reader pastes one
647
+ * in. Those callers need the same parsing and the same verification the CLI
648
+ * runs, and a second implementation of either is how two answers to "does this
649
+ * chain hold" come to exist.
650
+ */
651
+ function readEntries(text) {
652
+ return text.split("\n").filter((line) => line.trim() !== "").map((line) => JSON.parse(line));
653
+ }
654
+ /** Re-derive every hash and confirm each entry names its predecessor. */
655
+ function verifyEntries(text) {
656
+ const problems = [];
657
+ let expectedPrev = GENESIS;
658
+ let expectedSeq = 0;
659
+ let count = 0;
660
+ const lines = text.split("\n").filter((line) => line.trim() !== "");
661
+ for (const [index, line] of lines.entries()) {
662
+ const at = index + 1;
663
+ count += 1;
664
+ let entry;
665
+ try {
666
+ entry = JSON.parse(line);
667
+ } catch {
668
+ problems.push({
669
+ line: at,
670
+ message: "not valid JSON"
671
+ });
672
+ return {
673
+ ok: false,
674
+ entries: count,
675
+ problems
676
+ };
677
+ }
678
+ if (entry.seq !== expectedSeq) problems.push({
679
+ line: at,
680
+ message: `expected seq ${expectedSeq}, found ${entry.seq}`
681
+ });
682
+ if (entry.prev !== expectedPrev) problems.push({
683
+ line: at,
684
+ message: "does not name the previous entry; a row was removed or reordered"
685
+ });
686
+ const { seq, prev, hash,...record } = entry;
687
+ if (hashEntry(record, seq, prev) !== hash) problems.push({
688
+ line: at,
689
+ message: "contents do not match the sealed hash"
690
+ });
691
+ expectedPrev = entry.hash;
692
+ expectedSeq = entry.seq + 1;
693
+ }
694
+ return {
695
+ ok: problems.length === 0,
696
+ entries: count,
697
+ problems
698
+ };
699
+ }
700
+ var Ledger = class Ledger {
701
+ path;
702
+ seq;
703
+ last;
704
+ constructor(path, seq, last) {
705
+ this.path = path;
706
+ this.seq = seq;
707
+ this.last = last;
708
+ }
709
+ /**
710
+ * Open a ledger, reading the tail to continue an existing chain.
711
+ *
712
+ * A corrupt or truncated final line is a hard error rather than something to
713
+ * skip past: appending onto a chain whose end we could not read would bury
714
+ * the damage under valid-looking entries.
715
+ */
716
+ static open(path) {
717
+ mkdirSync(dirname(path), { recursive: true });
718
+ let text = "";
719
+ try {
720
+ text = readFileSync(path, "utf8");
721
+ } catch (error) {
722
+ if (error.code !== "ENOENT") throw error;
723
+ writeFileSync(path, "", { flag: "wx" });
724
+ return new Ledger(path, 0, GENESIS);
725
+ }
726
+ const tail = text.split("\n").filter((line) => line.trim() !== "").at(-1);
727
+ if (tail === void 0) return new Ledger(path, 0, GENESIS);
728
+ let entry;
729
+ try {
730
+ entry = JSON.parse(tail);
731
+ } catch {
732
+ throw new Error(`prae ledger: last line of ${path} is not readable; refusing to append onto a chain that cannot be continued`);
733
+ }
734
+ return new Ledger(path, entry.seq + 1, entry.hash);
735
+ }
736
+ /** Append one decision and return the entry as written. */
737
+ append(record) {
738
+ const entry = chain(record, this.seq, this.last);
739
+ appendFileSync(this.path, `${JSON.stringify(entry)}\n`);
740
+ this.seq += 1;
741
+ this.last = entry.hash;
742
+ return entry;
743
+ }
744
+ /** Every entry, in order. */
745
+ read() {
746
+ return readEntries(readFileSync(this.path, "utf8"));
747
+ }
748
+ /**
749
+ * Re-derive every hash and confirm each entry names its predecessor.
750
+ *
751
+ * Catches a deleted row, an edited field, and a reordering. Does not catch a
752
+ * wholesale rewrite of the file by someone holding the same code — see the
753
+ * note in `entry.ts`.
754
+ */
755
+ verify() {
756
+ return verifyEntries(readFileSync(this.path, "utf8"));
757
+ }
758
+ };
759
+
760
+ //#endregion
761
+ //#region src/console/logo.ts
762
+ /**
763
+ * The mark, inlined.
764
+ *
765
+ * A P drawn as a door: the product is the moment permission is granted,
766
+ * and the glyph is the gate it comes through. Inlined as a data URI rather
767
+ * than linked because these pages are read offline and mailed around as
768
+ * evidence — a page that fetches an image renders wrong on the machine that
769
+ * matters.
770
+ */
771
+ const LOGO = "data:image/png;base64,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";
772
+
773
+ //#endregion
774
+ //#region ../tokens/src/palette.ts
775
+ /**
776
+ * The two grounds, as declaration blocks.
777
+ *
778
+ * The console renders twice from one design: `prae console --out` inlines its
779
+ * stylesheet into standalone documents, and the web app links a real one. Both
780
+ * need these values, so they are defined here once and the test in this
781
+ * package asserts the stylesheet agrees — the same guard the rest of the
782
+ * tokens already have, for the same reason.
783
+ *
784
+ * Names are unprefixed because these are consumed as a surface's own palette
785
+ * rather than alongside another product's. `tokens.css` carries the prefixed
786
+ * `--prae-*` set for anyone embedding PRAE inside their own page.
787
+ */
788
+ /** §06 — Surface B, the ledger, on paper. Printable and high-contrast. */
789
+ const PAPER = ` --signal: oklch(0.45 0.16 140);
790
+ --signal-bright: oklch(0.5 0.16 140);
791
+ --signal-edge: oklch(0.5 0.13 140);
792
+ --signal-text: oklch(0.42 0.14 140);
793
+ --signal-on: oklch(0.985 0.003 100);
794
+ --hold: oklch(0.55 0.15 55);
795
+ --veto: oklch(0.52 0.19 25);
796
+ --veto-text: oklch(0.5 0.19 25);
797
+ --veto-edge: oklch(0.62 0.17 25);
798
+
799
+ --ground: oklch(0.975 0.003 100);
800
+ --raised: oklch(0.99 0.002 100);
801
+ --sunk: oklch(0.955 0.004 100);
802
+ --active: oklch(0.955 0.006 130);
803
+ --line: oklch(0.88 0.004 100);
804
+ --line-soft: oklch(0.92 0.004 100);
805
+ --line-faint: oklch(0.93 0.004 100);
806
+ --line-card: oklch(0.86 0.005 100);
807
+ --line-strong: oklch(0.8 0.006 100);
808
+ --edge: oklch(0.32 0.01 255);
809
+ --ink: oklch(0.22 0.01 255);
810
+ --ink-row: oklch(0.24 0.01 255);
811
+ --secondary: oklch(0.4 0.01 255);
812
+ --muted: oklch(0.5 0.01 255);
813
+ --muted-soft: oklch(0.55 0.01 255);
814
+ --dim: oklch(0.45 0.01 255);
815
+ --seal: oklch(0.6 0.01 255);
816
+
817
+ --mark: none;`;
818
+ /** §02 — the graphite ramp and the neon signal it is drawn for. */
819
+ const FENCE = ` --signal: oklch(0.86 0.21 133);
820
+ --signal-bright: oklch(0.95 0.15 133);
821
+ --signal-edge: oklch(0.4 0.06 133);
822
+ --signal-text: oklch(0.86 0.21 133);
823
+ --signal-on: oklch(0.155 0.008 255);
824
+ --hold: oklch(0.83 0.16 78);
825
+ --veto: oklch(0.66 0.2 25);
826
+ --veto-text: oklch(0.75 0.16 25);
827
+ --veto-edge: oklch(0.45 0.12 25);
828
+
829
+ --ground: oklch(0.155 0.008 255);
830
+ --raised: oklch(0.185 0.009 255);
831
+ --sunk: oklch(0.135 0.008 255);
832
+ --active: oklch(0.215 0.022 133);
833
+ --line: oklch(0.28 0.01 255);
834
+ --line-soft: oklch(0.26 0.01 255);
835
+ --line-faint: oklch(0.235 0.01 255);
836
+ --line-card: oklch(0.3 0.01 255);
837
+ --line-strong: oklch(0.32 0.01 255);
838
+ --edge: oklch(0.72 0.01 255);
839
+ --ink: oklch(0.96 0.004 255);
840
+ --ink-row: oklch(0.9 0.006 255);
841
+ --secondary: oklch(0.72 0.01 255);
842
+ --muted: oklch(0.6 0.012 255);
843
+ /* Lands where paper's does, at AA on its own ground: 0.55 measures 4.03
844
+ against graphite, and this token labels pending and absent states that
845
+ still have to be read. */
846
+ --muted-soft: oklch(0.575 0.012 255);
847
+ --dim: oklch(0.62 0.012 255);
848
+ --seal: oklch(0.58 0.012 255);
849
+
850
+ /* The mark is a solid black glyph, so it disappears on the graphite ground.
851
+ Inverting it yields the white it would have been drawn as. */
852
+ --mark: invert(1);`;
853
+
854
+ //#endregion
855
+ //#region ../tokens/src/views.ts
856
+ /**
857
+ * The console's view styles, as one definition.
858
+ *
859
+ * Header, stat band, ledger grid, rule and mapping tables, the awaiting-human
860
+ * panel, and the limits block — everything that makes a PRAE view look like a
861
+ * PRAE view rather than a generic table.
862
+ *
863
+ * Both renderers need these. `prae console --out` inlines them into standalone
864
+ * documents; the web app injects them into a global style tag. Exported as a
865
+ * string rather than a stylesheet so there is exactly one copy: a `.css` file
866
+ * plus a mirrored constant would be two, and two copies of a stylesheet drift
867
+ * the same way two copies of a palette do.
868
+ *
869
+ * What is *not* here is layout — the rail, the scroll containment, the routing.
870
+ * The app has a persistent frame and the exporter produces separate documents,
871
+ * and that is the one thing the two are genuinely allowed to disagree about.
872
+ */
873
+ const VIEWS_CSS = `/* ── header ───────────────────────────────────────────────────────────── */
874
+ .head {
875
+ display: flex; align-items: flex-end; justify-content: space-between;
876
+ gap: 24px; padding: 26px 30px 22px; border-bottom: 1px solid var(--line);
877
+ }
878
+ .head h1 { font-size: 30px; font-weight: 700; letter-spacing: -0.03em; margin: 0; }
879
+ .head .meta {
880
+ font-family: var(--proof); font-size: 11.5px; color: var(--muted); margin-top: 6px;
881
+ }
882
+ .actions { display: flex; align-items: center; gap: 10px; }
883
+ .chain {
884
+ display: flex; align-items: center; gap: 8px; padding: 7px 12px;
885
+ border: 1px solid var(--signal-edge); border-radius: 999px;
886
+ font-family: var(--proof); font-size: 10.5px; letter-spacing: 0.12em;
887
+ color: var(--signal-text); white-space: nowrap;
888
+ }
889
+ .chain i { width: 6px; height: 6px; background: var(--signal-bright); border-radius: 999px; }
890
+ .chain.broken { border-color: var(--veto-edge); color: var(--veto-text); }
891
+ .chain.broken i { background: var(--veto); }
892
+ .btn {
893
+ padding: 9px 16px; border: 1px solid var(--line-strong); font-size: 13.5px;
894
+ border-radius: 2px; background: none; color: inherit; font-family: inherit;
895
+ white-space: nowrap;
896
+ }
897
+
898
+ /* ── stat band ────────────────────────────────────────────────────────── */
899
+ .stats { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid var(--line); }
900
+ .stat { padding: 20px 24px; border-right: 1px solid var(--line-soft); }
901
+ .stat:first-child { padding-left: 30px; }
902
+ .stat:last-child { border-right: none; }
903
+ .stat .v {
904
+ font-size: 28px; font-weight: 700; letter-spacing: -0.03em;
905
+ font-variant-numeric: tabular-nums;
906
+ }
907
+ .stat .v.veto { color: var(--veto); }
908
+ .stat .v.hold { color: var(--hold); }
909
+ .stat .k {
910
+ font-family: var(--proof); font-size: 10px; letter-spacing: 0.14em;
911
+ color: var(--muted); margin-top: 4px;
912
+ }
913
+
914
+ /* ── ledger table ─────────────────────────────────────────────────────── */
915
+ .grid {
916
+ display: grid;
917
+ grid-template-columns: 96px 1fr 150px 130px 104px 120px;
918
+ gap: 16px;
919
+ }
920
+ .thead {
921
+ padding: 12px 30px; font-family: var(--proof); font-size: 10px;
922
+ letter-spacing: 0.14em; color: var(--muted); border-bottom: 1px solid var(--line);
923
+ }
924
+ .row {
925
+ padding: 14px 30px; font-family: var(--proof); font-size: 12px;
926
+ border-bottom: 1px solid var(--line-faint); align-items: center;
927
+ }
928
+ .row .seq { color: var(--muted-soft); font-variant-numeric: tabular-nums; }
929
+ .row .call { color: var(--ink-row); overflow-wrap: anywhere; }
930
+ .row .actor, .row .clause { color: var(--dim); }
931
+ .row .seal { color: var(--seal); }
932
+ .row .verdict { letter-spacing: 0.1em; }
933
+ .v-allow { color: var(--signal-text); }
934
+ .v-deny { color: var(--veto-text); }
935
+ .v-ask { color: var(--hold); }
936
+ .v-correct { color: var(--muted); }
937
+ .empty { padding: 30px; font-family: var(--proof); font-size: 12px; color: var(--muted); }
938
+
939
+ /* ── awaiting-human panel ─────────────────────────────────────────────── */
940
+ .await { padding: 26px 30px 30px; background: var(--sunk); border-top: 1px solid var(--line); }
941
+ .await .k {
942
+ font-family: var(--proof); font-size: 10px; letter-spacing: 0.16em;
943
+ color: var(--muted); margin-bottom: 16px;
944
+ }
945
+ .card {
946
+ display: flex; gap: 24px; align-items: flex-start; background: var(--raised);
947
+ border: 1px solid var(--line-card); padding: 22px 24px;
948
+ }
949
+ .card h2 { font-size: 20px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 8px; }
950
+ .card .detail { font-family: var(--proof); font-size: 12px; color: var(--muted); line-height: 1.8; }
951
+ .decide { display: flex; flex-direction: column; gap: 8px; width: 200px; flex: none; }
952
+ .approve {
953
+ padding: 11px 18px; background: var(--signal); color: var(--signal-on);
954
+ font-weight: 600; font-size: 13.5px; text-align: center; border-radius: 2px;
955
+ border: none; font-family: inherit;
956
+ }
957
+ .deny {
958
+ padding: 11px 18px; border: 1px solid var(--veto-edge); color: var(--veto-text);
959
+ font-weight: 600; font-size: 13.5px; text-align: center; border-radius: 2px;
960
+ background: none; font-family: inherit;
961
+ }
962
+ /*
963
+ * A control that cannot act must not look like one that can. These render so
964
+ * the reader knows where the decision lives, and read as inert so they never
965
+ * reach for it — the harness settles approvals, and a button that looks live
966
+ * here would be an invitation to a second audit trail.
967
+ *
968
+ * The fill is dropped rather than faded. Fading a filled button works against
969
+ * paper and fails against graphite, where a dimmed green block still reads as
970
+ * a live primary action; an outline reads the same on either ground.
971
+ */
972
+ .approve[disabled], .deny[disabled] {
973
+ background: none; border: 1px solid var(--line-strong);
974
+ color: var(--muted-soft); font-weight: 500; cursor: not-allowed;
975
+ }
976
+ .recorded {
977
+ font-family: var(--proof); font-size: 10px; color: var(--muted-soft);
978
+ text-align: center; letter-spacing: 0.08em;
979
+ }
980
+
981
+ /* ── limits ───────────────────────────────────────────────────────────── */
982
+ .limits { padding: 22px 30px 28px; border-top: 1px solid var(--line); }
983
+ .limits .k {
984
+ font-family: var(--proof); font-size: 10px; letter-spacing: 0.16em;
985
+ color: var(--muted); margin-bottom: 10px;
986
+ }
987
+ .limits ul { margin: 0; padding-left: 18px; font-size: 13px; line-height: 1.7; color: var(--secondary); }
988
+ .limits code { font-family: var(--proof); font-size: 12px; }
989
+
990
+ /* ── rule / mapping tables, shared by the packs and compliance views ──── */
991
+ .rules { display: grid; grid-template-columns: 110px 1fr 96px; gap: 16px; }
992
+ .map { display: grid; grid-template-columns: 210px 1fr 96px; gap: 16px; }
993
+ .sect { padding: 22px 30px 8px; }
994
+ .sect h2 {
995
+ font-size: 20px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 4px;
996
+ }
997
+ .sect p {
998
+ margin: 0 0 4px; font-size: 14px; line-height: 1.6; color: var(--secondary);
999
+ max-width: 76ch;
1000
+ }
1001
+ .caps { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 30px 22px; }
1002
+ .cap {
1003
+ font-family: var(--proof); font-size: 12px; padding: 6px 10px;
1004
+ border: 1px solid var(--signal-edge); color: var(--signal-text);
1005
+ border-radius: 2px;
1006
+ }
1007
+ .cap.off {
1008
+ border-color: var(--line-strong); color: var(--muted-soft);
1009
+ text-decoration: line-through;
1010
+ }
1011
+ .effect { letter-spacing: 0.1em; font-size: 11px; }
1012
+ .e-deny { color: var(--veto-text); }
1013
+ .e-ask { color: var(--hold); }
1014
+ .e-warn { color: var(--muted); }
1015
+ .e-correct { color: var(--signal-text); }
1016
+ .covered { color: var(--signal-text); }
1017
+ .partial { color: var(--hold); }
1018
+ .absent { color: var(--muted-soft); }
1019
+
1020
+ button { cursor: pointer; }
1021
+ a:focus-visible, button:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }
1022
+ `;
1023
+
1024
+ //#endregion
1025
+ //#region ../tokens/src/index.ts
1026
+ /** The Google Fonts stylesheet both surfaces link. */
1027
+ const FONT_HREF = "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap";
1028
+ /**
1029
+ * Verdict chip labels. Uppercase, mono, and never rendered without a clause
1030
+ * reference within 8px — §04 is explicit that a chip alone is not allowed.
1031
+ */
1032
+ const VERDICT_LABEL$1 = {
1033
+ allow: "ALLOWED",
1034
+ ask: "HELD",
1035
+ deny: "DENIED",
1036
+ correct: "CORRECTED"
1037
+ };
1038
+
1039
+ //#endregion
1040
+ //#region src/console/shell.ts
1041
+ function esc(value) {
1042
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1043
+ }
1044
+ /** The stylesheet both console views share. Tokens first, then the surface. */
1045
+ /** The stylesheet both console views share. Tokens first, then the surface. */
1046
+ const SHEET = `
1047
+ :root {
1048
+ ${PAPER}
1049
+ --claim: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif;
1050
+ --proof: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
1051
+ --ease: cubic-bezier(0.2, 0, 0, 1);
1052
+ --surface-motion: 180ms;
1053
+ }
1054
+
1055
+ /* An explicit choice, remembered. */
1056
+ :root[data-theme='dark'] {
1057
+ ${FENCE}
1058
+ }
1059
+
1060
+ /* No choice made: follow the machine, unless the reader has said light. */
1061
+ @media (prefers-color-scheme: dark) {
1062
+ :root:not([data-theme='light']) {
1063
+ ${FENCE}
1064
+ }
1065
+ }
1066
+
1067
+ /*
1068
+ * The ground moves at the surface budget, and only while it is moving.
1069
+ *
1070
+ * Arming this permanently would put a 180ms fade on every hover and every
1071
+ * verdict that renders, so the class goes on for one toggle and comes off.
1072
+ * §01's 0ms rule is about a decision animating in; nothing is deciding here,
1073
+ * the page is repainting, and a verdict that snapped while the page behind it
1074
+ * faded would read as a glitch.
1075
+ */
1076
+ html.theming, html.theming * {
1077
+ transition:
1078
+ background-color var(--surface-motion) var(--ease),
1079
+ border-color var(--surface-motion) var(--ease),
1080
+ color var(--surface-motion) var(--ease),
1081
+ fill var(--surface-motion) var(--ease) !important;
1082
+ }
1083
+
1084
+ * { box-sizing: border-box; }
1085
+
1086
+ /*
1087
+ * An application, not a document: the shell fills the viewport and only the
1088
+ * content column scrolls, so the rail and the header stay where a reader left
1089
+ * them. Print undoes all of it further down — the same page still has to
1090
+ * flatten into evidence.
1091
+ */
1092
+ html, body { height: 100%; }
1093
+ body {
1094
+ margin: 0;
1095
+ background: var(--ground);
1096
+ color: var(--ink);
1097
+ font-family: var(--claim);
1098
+ -webkit-font-smoothing: antialiased;
1099
+ overflow: hidden;
1100
+ }
1101
+
1102
+ .wrap { height: 100%; }
1103
+ .scroll { height: 100%; }
1104
+
1105
+ .console {
1106
+ background: var(--ground);
1107
+ display: grid;
1108
+ grid-template-columns: 232px 1fr;
1109
+ height: 100%;
1110
+ min-height: 0;
1111
+ }
1112
+
1113
+ /* The one column that moves. */
1114
+ .console > main {
1115
+ overflow-y: auto;
1116
+ overflow-x: auto;
1117
+ min-height: 0;
1118
+ scroll-behavior: smooth;
1119
+ }
1120
+
1121
+ /* ── rail ─────────────────────────────────────────────────────────────── */
1122
+ .rail {
1123
+ border-right: 1px solid var(--line); padding: 22px 0;
1124
+ overflow-y: auto; min-height: 0;
1125
+ display: flex; flex-direction: column;
1126
+ }
1127
+ .rail .compliance { margin-top: auto; }
1128
+
1129
+ /*
1130
+ * Two positions, named, rather than a switch with a state you have to infer.
1131
+ * Hidden until the head script has run: without scripting it would be a
1132
+ * control that does nothing.
1133
+ */
1134
+ .ground { display: none; margin: auto 20px 14px; }
1135
+ html.js .ground { display: grid; grid-template-columns: 1fr 1fr; }
1136
+ html.js .rail .compliance { margin-top: 0; }
1137
+ .ground button {
1138
+ font-family: var(--proof); font-size: 10px; letter-spacing: 0.14em;
1139
+ padding: 6px 0; background: none; color: var(--muted);
1140
+ border: 1px solid var(--line); border-radius: 0;
1141
+ transition: color 120ms var(--ease), border-color 120ms var(--ease);
1142
+ }
1143
+ .ground button:first-child { border-radius: 2px 0 0 2px; border-right: none; }
1144
+ .ground button:last-child { border-radius: 0 2px 2px 0; }
1145
+ .ground button:hover { color: var(--secondary); }
1146
+ :root:not([data-theme='dark']) .ground [data-ground='paper'],
1147
+ :root[data-theme='dark'] .ground [data-ground='fence'] {
1148
+ color: var(--ink); border-color: var(--line-strong); background: var(--active);
1149
+ }
1150
+ .brand { display: flex; align-items: center; gap: 10px; padding: 0 20px 22px; }
1151
+ /* The canvas sizes the mark at 26px; the glyph is square and needs no frame. */
1152
+ .mark { width: 26px; height: 26px; display: block; filter: var(--mark); }
1153
+ .brand b {
1154
+ font-weight: 700; font-size: 16px; letter-spacing: 0.04em;
1155
+ }
1156
+ .eyebrow {
1157
+ font-family: var(--proof); font-size: 10px; letter-spacing: 0.16em;
1158
+ color: var(--muted-soft); padding: 0 20px 10px;
1159
+ }
1160
+ .nav { display: grid; }
1161
+ .nav a, .nav span {
1162
+ padding: 10px 20px; font-size: 14.5px; color: var(--secondary);
1163
+ text-decoration: none; display: flex; justify-content: space-between;
1164
+ align-items: center;
1165
+ }
1166
+ .nav a { transition: background var(--t-state, 120ms) var(--ease, cubic-bezier(.2,0,0,1)),
1167
+ color var(--t-state, 120ms) var(--ease, cubic-bezier(.2,0,0,1)); }
1168
+ .nav a:hover { background: var(--sunk); }
1169
+
1170
+ /*
1171
+ * The surface transition, spent where the language says to spend it. Verdicts
1172
+ * are deliberately excluded: a decision appears, it never animates in.
1173
+ */
1174
+ main > * { animation: surface 180ms cubic-bezier(0.2, 0, 0, 1) both; }
1175
+ @keyframes surface {
1176
+ from { opacity: 0; transform: translateY(4px); }
1177
+ to { opacity: 1; transform: none; }
1178
+ }
1179
+ main.leaving { opacity: 0; transition: opacity 120ms cubic-bezier(0.2, 0, 0, 1); }
1180
+ @media (prefers-reduced-motion: reduce) {
1181
+ main > *, main.leaving { animation: none; transition: none; }
1182
+ .console > main { scroll-behavior: auto; }
1183
+ :root { --surface-motion: 0ms; }
1184
+ }
1185
+ .nav .pending { color: var(--muted-soft); }
1186
+ .nav .badge.soon {
1187
+ font-size: 9px; letter-spacing: 0.14em; color: var(--muted-soft);
1188
+ border: 1px solid var(--line); border-radius: 999px; padding: 1px 6px;
1189
+ }
1190
+ .nav a.on {
1191
+ font-weight: 600; color: var(--ink);
1192
+ border-left: 2px solid var(--signal-bright);
1193
+ background: var(--active); margin-left: -1px;
1194
+ }
1195
+ .nav .badge { font-family: var(--proof); font-size: 11px; color: var(--hold); }
1196
+ .compliance {
1197
+ margin: 22px 20px 0; padding-top: 18px; border-top: 1px solid var(--line-soft);
1198
+ font-family: var(--proof); font-size: 10.5px; line-height: 1.9; color: var(--muted);
1199
+ }
1200
+
1201
+ ${VIEWS_CSS}
1202
+
1203
+ /* Print is a first-class state, not an afterthought. */
1204
+ @media print {
1205
+ @page { margin: 12mm; }
1206
+ /* Evidence prints on paper. The fence has no printed form. */
1207
+ :root, :root[data-theme='dark'] {
1208
+ ${PAPER}
1209
+ }
1210
+ html.theming, html.theming * { transition: none !important; }
1211
+ html, body { height: auto; overflow: visible; }
1212
+ body { padding: 0; background: #fff; }
1213
+ .wrap, .scroll { height: auto; }
1214
+ .console > main { overflow: visible; height: auto; }
1215
+ main > * { animation: none; }
1216
+ .console {
1217
+ border: none; grid-template-columns: 1fr; min-width: 0;
1218
+ height: auto; display: block;
1219
+ }
1220
+ .rail, .actions, .decide, .ground { display: none; }
1221
+ .thead { display: grid; }
1222
+ thead { display: table-header-group; }
1223
+ .row { break-inside: avoid; }
1224
+ /* A chip is colour; a printed page may not be. Keep the word. */
1225
+ .chain { border-color: currentColor !important; }
1226
+ .stats { break-inside: avoid; }
1227
+ }
1228
+ `;
1229
+ /**
1230
+ * Resolve the ground before the first paint.
1231
+ *
1232
+ * This runs in `<head>`, synchronously, on purpose: read the choice after the
1233
+ * body has painted and the reader watches the page flash the wrong palette on
1234
+ * every navigation. It also marks the document as scripted, which is what
1235
+ * reveals the toggle — a control that cannot work should not be offered.
1236
+ */
1237
+ const GROUND_SCRIPT = `
1238
+ (() => {
1239
+ const root = document.documentElement;
1240
+ root.classList.add('js');
1241
+ let choice = null;
1242
+ try { choice = localStorage.getItem('prae-ground'); } catch (e) {}
1243
+ if (choice !== 'dark' && choice !== 'light') {
1244
+ choice = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1245
+ }
1246
+ root.dataset.theme = choice;
1247
+ })();
1248
+ `;
1249
+ /**
1250
+ * Client-side navigation, as progressive enhancement.
1251
+ *
1252
+ * Every view is still a complete document that opens, prints and mails on its
1253
+ * own — that requirement has not moved. This only intercepts a click when the
1254
+ * page is being served, swaps the main column, and leaves the rail alone, so
1255
+ * the frame stays put and the surface transition the language budgets actually
1256
+ * has somewhere to happen. With scripting off, the links are ordinary links.
1257
+ */
1258
+ const NAV_SCRIPT = `
1259
+ (() => {
1260
+ const main = () => document.querySelector('main');
1261
+ const rail = () => document.querySelector('.rail');
1262
+ if (!main() || !window.fetch) return;
1263
+
1264
+ const swap = async (href, push) => {
1265
+ const target = main();
1266
+ target.classList.add('leaving');
1267
+ try {
1268
+ const res = await fetch(href, { headers: { accept: 'text/html' } });
1269
+ if (!res.ok) throw new Error(String(res.status));
1270
+ const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
1271
+ const next = doc.querySelector('main');
1272
+ const nextRail = doc.querySelector('.rail');
1273
+ if (!next) throw new Error('no main');
1274
+
1275
+ // Collect the view's scripts BEFORE moving it: replaceWith detaches the
1276
+ // element from the parsed document, so querying that document afterwards
1277
+ // finds nothing and the view arrives inert. A parser-created script never
1278
+ // executes on insertion either, which is why each is re-created below.
1279
+ const scripts = [...next.querySelectorAll('script')].map((s) => s.textContent);
1280
+ for (const s of next.querySelectorAll('script')) s.remove();
1281
+
1282
+ target.replaceWith(next);
1283
+ // Only the rail's active marker changes; the element itself stays, so
1284
+ // nothing in it flickers or loses scroll position.
1285
+ if (nextRail) rail().innerHTML = nextRail.innerHTML;
1286
+ if (doc.title) document.title = doc.title;
1287
+ if (push) history.pushState({}, '', href);
1288
+ next.scrollTop = 0;
1289
+ wire();
1290
+ for (const code of scripts) {
1291
+ const el = document.createElement('script');
1292
+ el.textContent = code;
1293
+ document.body.appendChild(el);
1294
+ }
1295
+ } catch {
1296
+ // A failed swap falls back to a real navigation rather than stranding
1297
+ // the reader on a half-updated page.
1298
+ location.href = href;
1299
+ }
1300
+ };
1301
+
1302
+ const wire = () => {
1303
+ for (const a of document.querySelectorAll('.nav a[href$=".html"]')) {
1304
+ a.addEventListener('click', (e) => {
1305
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
1306
+ e.preventDefault();
1307
+ swap(a.getAttribute('href'), true);
1308
+ });
1309
+ }
1310
+ };
1311
+
1312
+ // Delegated, because a client-side swap replaces the rail's markup and a
1313
+ // handler bound to the old buttons would go with it.
1314
+ let settle;
1315
+ document.addEventListener('click', (e) => {
1316
+ const button = e.target.closest('[data-ground]');
1317
+ if (!button) return;
1318
+ const next = button.dataset.ground === 'fence' ? 'dark' : 'light';
1319
+ const root = document.documentElement;
1320
+ if (root.dataset.theme === next) return;
1321
+
1322
+ // Arm the transition, let the current palette paint once, then move. Doing
1323
+ // both in one frame gives the browser no 'from' state and it jumps.
1324
+ root.classList.add('theming');
1325
+ void root.offsetHeight;
1326
+ root.dataset.theme = next;
1327
+ try { localStorage.setItem('prae-ground', next); } catch (err) {}
1328
+ clearTimeout(settle);
1329
+ settle = setTimeout(() => root.classList.remove('theming'), 240);
1330
+ });
1331
+
1332
+ addEventListener('popstate', () => swap(location.pathname, false));
1333
+ wire();
1334
+ })();
1335
+ `;
1336
+ /** Wrap a view in the console shell: rail, then the view's own header and body. */
1337
+ function renderShell(page) {
1338
+ return `<!doctype html>
1339
+ <html lang="en" data-prae="ledger"><head>
1340
+ <meta charset="utf-8">
1341
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1342
+ <title>${esc(page.title)}</title>
1343
+ <link rel="preconnect" href="https://fonts.googleapis.com">
1344
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1345
+ <link href="${FONT_HREF}" rel="stylesheet">
1346
+ <script>${GROUND_SCRIPT}<\/script>
1347
+ <style>${SHEET}</style>
1348
+ </head><body>
1349
+ <div class="wrap"><div class="scroll">
1350
+ <div class="console">
1351
+ <nav class="rail">
1352
+ <div class="brand"><img class="mark" src="${LOGO}" alt=""><b>PRAE</b></div>
1353
+ <div class="eyebrow">CONTROL PLANE</div>
1354
+ ${page.nav}
1355
+ <div class="ground" role="group" aria-label="Ground">
1356
+ <button type="button" data-ground="paper">PAPER</button>
1357
+ <button type="button" data-ground="fence">FENCE</button>
1358
+ </div>
1359
+ <div class="compliance">EU AI ACT art. 12<br>NIST AI 600-1<br>SOC 2 CC7.2</div>
1360
+ </nav>
1361
+ <main>
1362
+ ${page.head}
1363
+ ${page.body}
1364
+ </main>
1365
+ </div>
1366
+ </div></div>
1367
+ <script>${NAV_SCRIPT}<\/script>
1368
+ </body></html>
1369
+ `;
1370
+ }
1371
+ function renderNav(items, active) {
1372
+ return `<div class="nav">\n${items.map((item) => {
1373
+ const classes = [item.label === active ? "on" : "", item.pending === true ? "pending" : ""].filter(Boolean).join(" ");
1374
+ const attr = classes === "" ? "" : ` class="${classes}"`;
1375
+ const badge = item.pending === true ? "<span class=\"badge soon\">SOON</span>" : item.badge === void 0 || item.badge === 0 ? "" : `<span class="badge">${item.badge}</span>`;
1376
+ const href = item.pending === true ? "" : ` href="${esc(item.href ?? "#")}"`;
1377
+ const tag = item.pending === true ? "span" : "a";
1378
+ return ` <${tag}${href}${attr}>${esc(item.label)}${badge}</${tag}>`;
1379
+ }).join("\n")}\n </div>`;
1380
+ }
1381
+
1382
+ //#endregion
1383
+ //#region ../controls/src/index.ts
1384
+ /**
1385
+ * What each control asks for, and what this profile actually produces.
1386
+ *
1387
+ * Framed as evidence toward a control, never as compliance with it. Nothing
1388
+ * here certifies anything: an auditor decides whether the evidence satisfies
1389
+ * the obligation, and a tool that told them the answer would be selling the
1390
+ * one thing it cannot supply.
1391
+ */
1392
+ const CONTROLS = [
1393
+ {
1394
+ id: "EU AI Act art. 12",
1395
+ asks: "Automatic recording of events over the lifetime of the system, to a degree that enables tracing.",
1396
+ evidence: (data) => ({
1397
+ state: data.entries.length > 0 ? "covered" : "partial",
1398
+ note: data.entries.length > 0 ? `${data.entries.length} decisions recorded, each with the clause that produced it and a hash linking it to the one before.` : "Recording is configured but nothing has been logged yet."
1399
+ })
1400
+ },
1401
+ {
1402
+ id: "NIST AI 600-1 · GV-1.2",
1403
+ asks: "Characteristics of trustworthy AI are integrated into organisational policies.",
1404
+ evidence: (data) => ({
1405
+ state: data.pack === void 0 ? "absent" : "covered",
1406
+ note: data.pack === void 0 ? "No pack supplied to this view." : `${data.pack.rules.length} rules are declared as data and applied to every tool call, rather than living in a runbook.`
1407
+ })
1408
+ },
1409
+ {
1410
+ id: "NIST AI 600-1 · MS-2.6",
1411
+ asks: "AI system is evaluated regularly for safety risks, with results documented.",
1412
+ evidence: () => ({
1413
+ state: "partial",
1414
+ note: "Decisions are documented per call. Periodic evaluation of the policy itself is an operator process this tool does not perform."
1415
+ })
1416
+ },
1417
+ {
1418
+ id: "SOC 2 · CC7.2",
1419
+ asks: "The entity monitors system components for anomalies indicative of malicious acts.",
1420
+ evidence: (data) => ({
1421
+ state: data.entries.some((e) => e.verdict === "deny") ? "covered" : "partial",
1422
+ note: `Refusals and held calls are recorded with their clause. ${data.entries.some((e) => e.verdict === "deny") ? "This record contains refusals." : "This record contains none yet, so the monitoring is unproven in practice."}`
1423
+ })
1424
+ },
1425
+ {
1426
+ id: "SOC 2 · CC6.1",
1427
+ asks: "Logical access to protected information assets is restricted to authorised users.",
1428
+ evidence: () => ({
1429
+ state: "partial",
1430
+ note: "Capabilities are restricted per agent. Authorisation of the human is not: approvals record what was decided, never who decided it."
1431
+ })
1432
+ }
1433
+ ];
1434
+
1435
+ //#endregion
1436
+ //#region src/console/views.ts
1437
+ /** One rail for every view, so the shape of the product stays consistent. */
1438
+ function navFor(data) {
1439
+ return [
1440
+ {
1441
+ label: "Overview",
1442
+ href: "overview.html"
1443
+ },
1444
+ {
1445
+ label: "Policy packs",
1446
+ href: "packs.html"
1447
+ },
1448
+ {
1449
+ label: "Playground",
1450
+ href: "playground.html"
1451
+ },
1452
+ {
1453
+ label: "Approvals",
1454
+ href: "approvals.html",
1455
+ badge: data.entries.filter((e) => e.verdict === "ask").length
1456
+ },
1457
+ {
1458
+ label: "Audit ledger",
1459
+ href: "ledger.html"
1460
+ },
1461
+ {
1462
+ label: "Fleet",
1463
+ pending: true
1464
+ },
1465
+ {
1466
+ label: "Compliance map",
1467
+ href: "compliance.html"
1468
+ }
1469
+ ];
1470
+ }
1471
+ function chainPill(ok) {
1472
+ return `<div class="chain${ok ? "" : " broken"}"><i></i>${ok ? "CHAIN INTACT" : "CHAIN BROKEN"}</div>`;
1473
+ }
1474
+ function header(title, meta, ok) {
1475
+ return ` <header class="head">
1476
+ <div><h1>${esc(title)}</h1><div class="meta">${esc(meta)}</div></div>
1477
+ <div class="actions">${chainPill(ok)}
1478
+ <button type="button" class="btn" onclick="window.print()">Export evidence</button>
1479
+ </div>
1480
+ </header>`;
1481
+ }
1482
+ function renderOverview(data) {
1483
+ const counts = {};
1484
+ for (const entry of data.entries) counts[entry.verdict] = (counts[entry.verdict] ?? 0) + 1;
1485
+ const held = counts.ask ?? 0;
1486
+ const pack = data.pack;
1487
+ const enforcing = pack?.rules.some((r) => r.effect.kind === "deny") ?? false;
1488
+ const body = `${` <section class="stats">
1489
+ <div class="stat"><div class="v">${data.entries.length}</div><div class="k">DECISIONS</div></div>
1490
+ <div class="stat"><div class="v veto">${counts.deny ?? 0}</div><div class="k">VETOED</div></div>
1491
+ <div class="stat"><div class="v hold">${held}</div><div class="k">AWAITING HUMAN</div></div>
1492
+ <div class="stat"><div class="v">${pack === void 0 ? "—" : esc(pack.rules.length)}</div><div class="k">RULES IN FORCE</div></div>
1493
+ </section>`}
1494
+ <section class="sect">
1495
+ <h2>Posture</h2>
1496
+ <p>${pack === void 0 ? "No pack was supplied to this view, so nothing here describes a policy in force." : enforcing ? `<strong>${esc(pack.pack)} ${esc(pack.version)}</strong> is enforcing: some calls are refused outright.` : `<strong>${esc(pack.pack)} ${esc(pack.version)}</strong> is observing: it records what an enforcing pack would refuse, and denies nothing.`}</p>
1497
+ <p>${data.verification.ok ? "The decision chain verifies: no row has been removed, edited, or reordered since it was written." : "The decision chain does <strong>not</strong> verify. This record is not continuous, and the Audit ledger names where it breaks."}</p>
1498
+ </section>
1499
+ <section class="sect" style="padding-bottom:26px">
1500
+ <h2>What is not covered</h2>
1501
+ <p>This gate decides tool calls. It does not sandbox plugin code — a
1502
+ plugin's <code>apply()</code> runs with full privileges before any tool
1503
+ call exists — and it does not secure the harness itself. Approvals record
1504
+ what was decided, never who decided it.</p>
1505
+ </section>`;
1506
+ return renderShell({
1507
+ title: `Overview — ${pack?.pack ?? "PRAE"}`,
1508
+ nav: renderNav(navFor(data), "Overview"),
1509
+ head: header("Overview", `${data.entries.length} decisions · ${pack === void 0 ? "no pack" : `${pack.pack} ${pack.version}`}`, data.verification.ok),
1510
+ body
1511
+ });
1512
+ }
1513
+ function matcherOf(rule) {
1514
+ const when = rule.when;
1515
+ const parts = [];
1516
+ const list = (v) => v === void 0 ? void 0 : Array.isArray(v) ? v.join(", ") : String(v);
1517
+ const cap = list(when.capability);
1518
+ if (cap !== void 0) parts.push(`capability ${cap}`);
1519
+ const tool = list(when.tool);
1520
+ if (tool !== void 0) parts.push(`tool ${tool}`);
1521
+ const plugin = list(when.plugin);
1522
+ if (plugin !== void 0) parts.push(`plugin ${plugin}`);
1523
+ if (when.path?.outside !== void 0) parts.push(`path outside ${when.path.outside.join(", ")}`);
1524
+ if (when.path?.inside !== void 0) parts.push(`path inside ${when.path.inside.join(", ")}`);
1525
+ if (when.host?.notIn !== void 0) parts.push(`host not in ${when.host.notIn.join(", ")}`);
1526
+ if (when.host?.in !== void 0) parts.push(`host in ${when.host.in.join(", ")}`);
1527
+ if (when.count !== void 0) parts.push(`more than ${when.count.above}`);
1528
+ for (const arg of when.arg ?? []) {
1529
+ const bits = [
1530
+ arg.above === void 0 ? "" : `above ${arg.above}`,
1531
+ arg.below === void 0 ? "" : `below ${arg.below}`,
1532
+ arg.equals === void 0 ? "" : `= ${String(arg.equals)}`
1533
+ ].filter(Boolean);
1534
+ parts.push(`${arg.name} ${bits.join(" ")}`.trim());
1535
+ }
1536
+ return parts.length === 0 ? "every call" : parts.join(" · ");
1537
+ }
1538
+ function renderPacks(data) {
1539
+ const pack = data.pack;
1540
+ if (pack === void 0) return renderShell({
1541
+ title: "Policy packs",
1542
+ nav: renderNav(navFor(data), "Policy packs"),
1543
+ head: header("Policy packs", "no pack supplied", data.verification.ok),
1544
+ body: " <div class=\"empty\">Pass --pack to render the policy in force.</div>"
1545
+ });
1546
+ const baseline = pack.baseline?.capabilities;
1547
+ const caps = baseline === void 0 ? `<p style="padding:0 30px 22px;font-size:14px;color:var(--secondary);max-width:76ch">This pack sets no capability ceiling. It governs through its rules alone, and withholds nothing an author declares.</p>` : `<div class="caps">${baseline.map((c) => `<span class="cap">${esc(c)}</span>`).join("")}</div>`;
1548
+ const rows = pack.rules.map((rule) => ` <div class="grid rules row">
1549
+ <div class="seq">${esc(rule.id)}</div>
1550
+ <div class="call">${esc(rule.title ?? rule.effect.because)}<br><span style="color:var(--muted)">${esc(matcherOf(rule))}</span></div>
1551
+ <div class="effect e-${esc(rule.effect.kind)}">${esc(rule.effect.kind.toUpperCase())}</div>
1552
+ </div>`).join("\n");
1553
+ const body = ` <section class="sect">
1554
+ <h2>${esc(pack.pack)} <span style="font-family:var(--proof);font-size:14px;color:var(--muted)">${esc(pack.version)}</span></h2>
1555
+ <p>The operator baseline is intersected with whatever a plugin's own
1556
+ manifest declares, and the operator can only ever narrow. A pack cannot
1557
+ grant a capability an author never asked for.</p>
1558
+ </section>
1559
+ ${caps}
1560
+ <div class="grid rules thead">
1561
+ <div>CLAUSE</div><div>RULE</div><div>EFFECT</div>
1562
+ </div>
1563
+ ${rows}
1564
+ <section class="sect" style="padding-bottom:26px">
1565
+ <h2>Ordering</h2>
1566
+ <p>When several rules match one call the strongest effect wins —
1567
+ deny over ask over correct over warn — and every match stays in the
1568
+ trace. That is what makes a pack safe to extend: appending a rule can
1569
+ raise a decision's strength and never lower it.</p>
1570
+ </section>`;
1571
+ return renderShell({
1572
+ title: `Policy packs — ${pack.pack}`,
1573
+ nav: renderNav(navFor(data), "Policy packs"),
1574
+ head: header("Policy packs", `${pack.pack} ${pack.version} · ${pack.rules.length} rules`, data.verification.ok),
1575
+ body
1576
+ });
1577
+ }
1578
+ function renderCompliance(data) {
1579
+ const body = ` <section class="sect">
1580
+ <h2>Evidence, not compliance</h2>
1581
+ <p>Each row states what a control asks for and what this profile actually
1582
+ produces. Nothing here certifies anything — whether the evidence satisfies
1583
+ the obligation is an auditor's judgement, and a tool that answered it for
1584
+ them would be selling the one thing it cannot supply.</p>
1585
+ </section>
1586
+ <div class="grid map thead">
1587
+ <div>CONTROL</div><div>ASKS FOR · WHAT THIS PROFILE PRODUCES</div><div>STATE</div>
1588
+ </div>
1589
+ ${CONTROLS.map((control) => {
1590
+ const found = control.evidence(data);
1591
+ const label = found.state === "covered" ? "EVIDENCE" : found.state === "partial" ? "PARTIAL" : "NONE";
1592
+ return ` <div class="grid map row">
1593
+ <div class="call">${esc(control.id)}</div>
1594
+ <div class="actor">${esc(control.asks)}<br><span style="color:var(--ink-row)">${esc(found.note)}</span></div>
1595
+ <div class="effect ${esc(found.state)}">${label}</div>
1596
+ </div>`;
1597
+ }).join("\n")}
1598
+ <section class="sect" style="padding-bottom:26px">
1599
+ <h2>The gap that recurs</h2>
1600
+ <p>Three of these rows stop at <em>partial</em> for the same reason: the
1601
+ harness records what was decided and never who decided it. Attributable
1602
+ approval needs an identity anchor outside this machine, which is a
1603
+ control-plane concern this build does not claim.</p>
1604
+ </section>`;
1605
+ return renderShell({
1606
+ title: "Compliance map",
1607
+ nav: renderNav(navFor(data), "Compliance map"),
1608
+ head: header("Compliance map", `${CONTROLS.length} controls · evidence from this profile`, data.verification.ok),
1609
+ body
1610
+ });
1611
+ }
1612
+ /**
1613
+ * Every call the policy held, and what became of it.
1614
+ *
1615
+ * This is a queue you read, not one you act on. The gate returns `ask` into the
1616
+ * harness's own approval seam and the harness settles it there; a second set of
1617
+ * buttons here would mean two places to approve from and two audit trails to
1618
+ * reconcile. So the view shows what is waiting and what was decided, and says
1619
+ * plainly where the decision happens.
1620
+ */
1621
+ function renderApprovals(data) {
1622
+ const held = data.entries.filter((e) => e.verdict === "ask");
1623
+ const settled = data.entries.filter((e) => e.rule === "approval");
1624
+ const now = Date.parse(data.generatedAt);
1625
+ const waited = (iso) => {
1626
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
1627
+ if (!Number.isFinite(s)) return "";
1628
+ const h = Math.floor(s / 3600);
1629
+ const m = Math.floor(s % 3600 / 60);
1630
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
1631
+ };
1632
+ const queue = held.length === 0 ? " <div class=\"empty\">Nothing is waiting on a human.</div>" : held.map((e) => ` <section class="await" style="border-top:none">
1633
+ <div class="card">
1634
+ <div style="flex:1">
1635
+ <h2>${esc(e.tool)}${e.subject === void 0 ? "" : ` on ${esc(e.subject)}`}</h2>
1636
+ <div class="detail">
1637
+ ${esc(e.rule ?? "held")} · ${esc(e.session ?? "local session")} · waiting ${esc(waited(e.at))}<br>
1638
+ ${esc(e.reason ?? "")}
1639
+ </div>
1640
+ </div>
1641
+ <div class="decide">
1642
+ <button type="button" class="approve" disabled title="The harness settles approvals; a second prompt would mean two audit trails">Approve</button>
1643
+ <button type="button" class="deny" disabled title="The harness settles approvals; a second prompt would mean two audit trails">Deny</button>
1644
+ <div class="recorded">SETTLED IN THE HARNESS</div>
1645
+ </div>
1646
+ </div>
1647
+ </section>`).join("\n");
1648
+ const history = settled.length === 0 ? "" : ` <div class="grid map thead">
1649
+ <div>WHEN</div><div>CALL · WHY</div><div>OUTCOME</div>
1650
+ </div>
1651
+ ${settled.map((e) => ` <div class="grid map row">
1652
+ <div class="seq">${esc(e.at.replace("T", " ").replace(/\.\d+Z$/, "Z"))}</div>
1653
+ <div class="call">${esc(e.tool)}${e.subject === void 0 ? "" : ` ${esc(e.subject)}`}<br><span style="color:var(--muted)">${esc(e.reason ?? "")}${e.actor === void 0 ? "" : ` · ${esc(e.actor)}`}</span></div>
1654
+ <div class="effect ${e.verdict === "allow" ? "covered" : "absent"}">${e.verdict === "allow" ? "APPROVED" : "REFUSED"}</div>
1655
+ </div>`).join("\n")}`;
1656
+ const body = ` <section class="stats">
1657
+ <div class="stat"><div class="v hold">${held.length}</div><div class="k">AWAITING HUMAN</div></div>
1658
+ <div class="stat"><div class="v">${settled.length}</div><div class="k">SETTLED</div></div>
1659
+ <div class="stat"><div class="v">${settled.filter((e) => e.actor !== void 0).length}</div><div class="k">WITH AN ACTOR</div></div>
1660
+ <div class="stat"><div class="v">${data.entries.length}</div><div class="k">DECISIONS</div></div>
1661
+ </section>
1662
+ <section class="sect">
1663
+ <h2>Waiting</h2>
1664
+ <p>A held call is one a clause would not decide alone. The harness owns
1665
+ the prompt and settles it; this is the queue, not a second place to
1666
+ approve from.</p>
1667
+ </section>
1668
+ ${queue}
1669
+ <section class="sect">
1670
+ <h2>Settled</h2>
1671
+ <p>Where an entry names an actor, that name is asserted by configuration.
1672
+ The harness records what was decided and never who decided it, so an
1673
+ attributable approval needs an identity anchor this build does not have.</p>
1674
+ </section>
1675
+ ${history}`;
1676
+ return renderShell({
1677
+ title: "Approvals",
1678
+ nav: renderNav(navFor(data), "Approvals"),
1679
+ head: header("Approvals", `${held.length} waiting · ${settled.length} settled`, data.verification.ok),
1680
+ body
1681
+ });
1682
+ }
1683
+
1684
+ //#endregion
1685
+ //#region src/console/ledger.ts
1686
+ function ago(iso, now) {
1687
+ const seconds = Math.max(0, Math.round((now - Date.parse(iso)) / 1e3));
1688
+ if (Number.isNaN(seconds)) return "";
1689
+ const h = Math.floor(seconds / 3600);
1690
+ const m = Math.floor(seconds % 3600 / 60);
1691
+ const s = seconds % 60;
1692
+ return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
1693
+ }
1694
+ function row(entry) {
1695
+ const call = entry.subject === void 0 ? entry.tool : `${entry.tool} ${entry.subject}`;
1696
+ const actor = entry.actor ?? entry.agent ?? "—";
1697
+ return ` <div class="grid row">
1698
+ <div class="seq">${entry.seq}</div>
1699
+ <div class="call">${esc(call)}</div>
1700
+ <div class="actor">${esc(actor)}</div>
1701
+ <div class="clause">${esc(entry.rule ?? "—")}</div>
1702
+ <div class="verdict v-${esc(entry.verdict)}">${esc(VERDICT_LABEL$1[entry.verdict] ?? entry.verdict)}</div>
1703
+ <div class="seal">${esc(seal(entry))}</div>
1704
+ </div>`;
1705
+ }
1706
+ function renderLedgerView(entries, verification, options) {
1707
+ const counts = {};
1708
+ const packs = /* @__PURE__ */ new Set();
1709
+ const sessions = /* @__PURE__ */ new Set();
1710
+ for (const entry of entries) {
1711
+ counts[entry.verdict] = (counts[entry.verdict] ?? 0) + 1;
1712
+ packs.add(`${entry.pack.name} ${entry.pack.version}`);
1713
+ if (entry.session !== void 0) sessions.add(entry.session);
1714
+ }
1715
+ const held = entries.filter((e) => e.verdict === "ask");
1716
+ const pack = [...packs][0] ?? "—";
1717
+ const now = Date.parse(options.generatedAt);
1718
+ const chainOk = verification.ok;
1719
+ const head = ` <header class="head">
1720
+ <div>
1721
+ <h1>Audit ledger</h1>
1722
+ <div class="meta">${esc([
1723
+ `${sessions.size || 1} session${sessions.size === 1 ? "" : "s"}`,
1724
+ `${entries.length.toLocaleString("en-US")} decisions`,
1725
+ options.retention === void 0 ? "retention local" : `retention ${options.retention}`
1726
+ ].join(" · "))}</div>
1727
+ </div>
1728
+ <div class="actions">
1729
+ <div class="chain${chainOk ? "" : " broken"}"><i></i>${chainOk ? "CHAIN INTACT" : "CHAIN BROKEN"}</div>
1730
+ <button type="button" class="btn" onclick="window.print()">Export evidence</button>
1731
+ </div>
1732
+ </header>`;
1733
+ const stats = ` <section class="stats">
1734
+ <div class="stat"><div class="v">${entries.length.toLocaleString("en-US")}</div><div class="k">DECISIONS</div></div>
1735
+ <div class="stat"><div class="v veto">${counts.deny ?? 0}</div><div class="k">VETOED</div></div>
1736
+ <div class="stat"><div class="v hold">${held.length}</div><div class="k">AWAITING HUMAN</div></div>
1737
+ <div class="stat"><div class="v">${esc(pack.split(" ")[1] ?? "—")}</div><div class="k">PACK · ${esc(pack.split(" ")[0] ?? "")}</div></div>
1738
+ </section>`;
1739
+ const table = ` <div class="grid thead">
1740
+ <div>SEQ</div><div>CALL</div><div>ACTOR</div><div>CLAUSE</div><div>VERDICT</div><div>SEAL</div>
1741
+ </div>
1742
+ ${entries.length === 0 ? " <div class=\"empty\">No decisions recorded yet.</div>" : [...entries].reverse().map(row).join("\n")}`;
1743
+ const first = held.at(-1);
1744
+ const awaiting = first === void 0 ? "" : ` <section class="await">
1745
+ <div class="k">AWAITING HUMAN · 1 OF ${held.length}</div>
1746
+ <div class="card">
1747
+ <div style="flex:1">
1748
+ <h2>${esc(first.tool)}${first.subject === void 0 ? "" : ` on ${esc(first.subject)}`}</h2>
1749
+ <div class="detail">
1750
+ ${esc(first.rule ?? "held")} · ${esc(first.session ?? "local session")} · requested ${esc(ago(first.at, now))} ago<br>
1751
+ ${esc(first.reason ?? "")}
1752
+ </div>
1753
+ </div>
1754
+ <div class="decide">
1755
+ <button type="button" class="approve" disabled title="Approvals are settled in the harness, not here">Approve</button>
1756
+ <button type="button" class="deny" disabled title="Approvals are settled in the harness, not here">Deny</button>
1757
+ <div class="recorded">SETTLED IN THE HARNESS</div>
1758
+ </div>
1759
+ </div>
1760
+ </section>`;
1761
+ const limits = ` <section class="limits">
1762
+ <div class="k">WHAT THIS RECORD DOES NOT PROVE</div>
1763
+ <ul>
1764
+ <li>Tamper-<em>evident</em>, not tamper-proof: a removed, edited or reordered row breaks the chain, but anyone able to rewrite the whole file with this code can recompute every hash.</li>
1765
+ <li>Where a row names an actor, that name is asserted by configuration — the harness records what was decided, never who decided it.</li>
1766
+ <li><code>run_code</code> cannot be masked, and path rules compare normalized strings without resolving symlinks.</li>
1767
+ </ul>
1768
+ </section>`;
1769
+ const problems = chainOk ? "" : ` <section class="limits" style="border-top:1px solid var(--veto-edge)">
1770
+ <div class="k" style="color:var(--veto-text)">CHAIN BROKEN — THIS RECORD IS NOT CONTINUOUS</div>
1771
+ <ul>${verification.problems.map((p) => `<li>line ${p.line}: ${esc(p.message)}</li>`).join("")}</ul>
1772
+ </section>`;
1773
+ return renderShell({
1774
+ title: `Audit ledger — ${pack}`,
1775
+ nav: renderNav(navFor({
1776
+ entries,
1777
+ verification,
1778
+ generatedAt: options.generatedAt,
1779
+ ...options.pack === void 0 ? {} : { pack: options.pack }
1780
+ }), "Audit ledger"),
1781
+ head,
1782
+ body: `${stats}\n${problems}${table}\n${awaiting}\n${limits}`
1783
+ });
1784
+ }
1785
+
1786
+ //#endregion
1787
+ //#region src/console/playground.ts
1788
+ /**
1789
+ * Load the browser bundle from this package's own `lib`.
1790
+ *
1791
+ * Read at render time rather than imported, because it is a built artifact of a
1792
+ * different target — the Node half cannot execute it, it only ships it.
1793
+ */
1794
+ function bundle() {
1795
+ const here = import.meta.dirname;
1796
+ for (const candidate of [
1797
+ join(here, "playground.js"),
1798
+ join(here, "..", "playground.js"),
1799
+ join(here, "..", "..", "lib", "playground.js")
1800
+ ]) try {
1801
+ return readFileSync(candidate, "utf8");
1802
+ } catch {}
1803
+ throw new Error("prae: playground bundle not found; run `pnpm build` before rendering the console");
1804
+ }
1805
+ const PRESETS = [
1806
+ {
1807
+ label: "write outside the workspace",
1808
+ tool: "write",
1809
+ capability: "fs:write",
1810
+ args: "path=/etc/hosts"
1811
+ },
1812
+ {
1813
+ label: "write inside it",
1814
+ tool: "write",
1815
+ capability: "fs:write",
1816
+ args: "path=./src/server/auth.ts"
1817
+ },
1818
+ {
1819
+ label: "read a secret",
1820
+ tool: "read",
1821
+ capability: "fs:read",
1822
+ args: "path=./.env"
1823
+ },
1824
+ {
1825
+ label: "fetch an unlisted host",
1826
+ tool: "web_fetch",
1827
+ capability: "net:fetch",
1828
+ args: "url=https://pastebin.example.net/raw/xK9"
1829
+ },
1830
+ {
1831
+ label: "traverse out with ..",
1832
+ tool: "write",
1833
+ capability: "fs:write",
1834
+ args: "path=./src/../../etc/passwd"
1835
+ },
1836
+ {
1837
+ label: "exceed the network budget",
1838
+ tool: "web_fetch",
1839
+ capability: "net:fetch",
1840
+ args: "url=https://build.acme.internal/x",
1841
+ counts: "25"
1842
+ }
1843
+ ];
1844
+ const STYLE = `
1845
+ .pg { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line); }
1846
+ .pg > div { background: var(--ground); padding: 22px 30px 26px; }
1847
+ .pg h2 {
1848
+ font-size: 20px; font-weight: 600; letter-spacing: -0.02em; margin: 0 0 4px;
1849
+ }
1850
+ .pg .hint {
1851
+ font-size: 13px; color: var(--secondary); margin: 0 0 14px; max-width: 60ch;
1852
+ line-height: 1.6;
1853
+ }
1854
+ .pg label {
1855
+ display: block; font-family: var(--proof); font-size: 10px;
1856
+ letter-spacing: 0.14em; color: var(--muted); margin: 14px 0 6px;
1857
+ }
1858
+ .pg textarea, .pg input {
1859
+ width: 100%; font-family: var(--proof); font-size: 12px; line-height: 1.7;
1860
+ color: var(--ink); background: var(--raised);
1861
+ border: 1px solid var(--line-strong); border-radius: 2px; padding: 10px 12px;
1862
+ }
1863
+ .pg textarea { resize: vertical; }
1864
+ #pack { min-height: 300px; }
1865
+ #args { min-height: 74px; }
1866
+ .pg .two { display: grid; grid-template-columns: 1fr 120px; gap: 12px; }
1867
+ .tries { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
1868
+ .tries button {
1869
+ font-family: var(--proof); font-size: 11px; padding: 6px 10px;
1870
+ border: 1px solid var(--line-strong); background: none; color: var(--secondary);
1871
+ border-radius: 2px;
1872
+ }
1873
+ .tries button:hover { border-color: var(--signal-edge); color: var(--signal-text); }
1874
+
1875
+ #verdict {
1876
+ display: flex; flex-direction: column; gap: 6px;
1877
+ padding: 20px 0 18px; border-bottom: 1px solid var(--line);
1878
+ }
1879
+ #verdict .big {
1880
+ font-size: 34px; font-weight: 700; letter-spacing: -0.03em; line-height: 1;
1881
+ }
1882
+ #verdict .clause { font-family: var(--proof); font-size: 12px; color: var(--muted); }
1883
+ .step {
1884
+ display: grid; grid-template-columns: 86px 96px 1fr; gap: 12px;
1885
+ padding: 10px 0; border-bottom: 1px solid var(--line-faint);
1886
+ font-family: var(--proof); font-size: 12px; align-items: baseline;
1887
+ }
1888
+ .step .id { color: var(--dim); }
1889
+ .step .why { color: var(--ink-row); }
1890
+ .none {
1891
+ font-family: var(--proof); font-size: 11px; color: var(--muted); padding-top: 12px;
1892
+ }
1893
+ #errors { padding-top: 16px; }
1894
+ #errors ul { margin: 6px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.7; }
1895
+ #errors code { font-family: var(--proof); }
1896
+
1897
+ @media print { .pg { grid-template-columns: 1fr; } .tries { display: none; } }
1898
+ `;
1899
+ function renderPlayground(data, packSource) {
1900
+ const tries = PRESETS.map((p) => {
1901
+ return `<button type="button" data-try='${esc(JSON.stringify({
1902
+ tool: p.tool,
1903
+ capability: p.capability,
1904
+ args: p.args,
1905
+ counts: p.counts ?? ""
1906
+ }))}'>${esc(p.label)}</button>`;
1907
+ }).join("");
1908
+ const body = `<style>${STYLE}</style>
1909
+ <div class="pg">
1910
+ <div>
1911
+ <h2>The pack</h2>
1912
+ <p class="hint">Edit it. Unknown fields are errors here exactly as they
1913
+ are at load time, so a typo fails in front of you rather than loading
1914
+ clean and enforcing nothing.</p>
1915
+ <label for="pack">POLICY PACK · JSON</label>
1916
+ <textarea id="pack" spellcheck="false">${esc(packSource)}</textarea>
1917
+ </div>
1918
+ <div>
1919
+ <h2>A call</h2>
1920
+ <p class="hint">This runs the same evaluator the gate calls on every
1921
+ tool call in a live profile — not a model of it.</p>
1922
+
1923
+ <div class="two">
1924
+ <div>
1925
+ <label for="tool">TOOL</label>
1926
+ <input id="tool" value="write" spellcheck="false">
1927
+ </div>
1928
+ <div>
1929
+ <label for="counts">PRIOR CALLS</label>
1930
+ <input id="counts" value="" placeholder="0" spellcheck="false">
1931
+ </div>
1932
+ </div>
1933
+
1934
+ <label for="capability">CAPABILITY</label>
1935
+ <input id="capability" value="fs:write" spellcheck="false">
1936
+
1937
+ <label for="args">ARGUMENTS · KEY=VALUE PER LINE</label>
1938
+ <textarea id="args" spellcheck="false">path=/etc/hosts</textarea>
1939
+
1940
+ <div class="tries">${tries}</div>
1941
+
1942
+ <div id="verdict"></div>
1943
+ <div id="trace"></div>
1944
+ <div id="errors" hidden></div>
1945
+ </div>
1946
+ </div>
1947
+ <script>${bundle()}<\/script>`;
1948
+ return renderShell({
1949
+ title: "Playground",
1950
+ nav: renderNav(navFor(data), "Playground"),
1951
+ head: ` <header class="head">
1952
+ <div><h1>Playground</h1><div class="meta">the real kernel, in the page · nothing is executed</div></div>
1953
+ <div class="actions"><button type="button" class="btn" onclick="window.print()">Export evidence</button></div>
1954
+ </header>`,
1955
+ body
1956
+ });
1957
+ }
1958
+
1959
+ //#endregion
1960
+ //#region src/profile.ts
1961
+ /**
1962
+ * Our own region of the overlay.
1963
+ *
1964
+ * A managed block is replaced wholesale on every write, so sharing one with
1965
+ * another writer would mean whichever ran second deleted the other's rows.
1966
+ * Blueprint manages the same file; naming the owner keeps both usable on one
1967
+ * profile, and tells a reader who to go back to.
1968
+ */
1969
+ const OWNER = {
1970
+ name: "prae",
1971
+ wrote: "Written by `prae init`. Edit the pack and re-run, or delete the block"
1972
+ };
1973
+ /** Where dsh keeps its home, honouring the same env var the CLI does. */
1974
+ function dshHome() {
1975
+ return process.env.DSH_HOME ?? join(homedir(), ".dsh");
1976
+ }
1977
+ function profileAt(dir) {
1978
+ return {
1979
+ dir,
1980
+ patchPath: join(dir, "cordis.patch.yml")
1981
+ };
1982
+ }
1983
+ /** The default profile, unless a caller names one. */
1984
+ function defaultProfile(name = "web") {
1985
+ return profileAt(join(dshHome(), "profiles", name));
1986
+ }
1987
+ /**
1988
+ * The overlay row that mounts PRAE with a policy.
1989
+ *
1990
+ * The pack is embedded rather than referenced by path. A row pointing at a
1991
+ * file that later moves or changes is a policy nobody can reconstruct from the
1992
+ * profile alone, and the whole point of this plugin is being able to say what
1993
+ * was in force at the time.
1994
+ */
1995
+ function praeRow(options) {
1996
+ const indent = (text, spaces) => text.split("\n").map((line) => line.trim() === "" ? "" : " ".repeat(spaces) + line).join("\n");
1997
+ const lines = [
1998
+ "# Holds every tool call on this profile to the pack below.",
1999
+ "- insert:",
2000
+ " - id: prae",
2001
+ " name: dsh-prae",
2002
+ " config:",
2003
+ " strict: true"
2004
+ ];
2005
+ if (options.ledger !== void 0) lines.push(` ledger: ${JSON.stringify(options.ledger)}`);
2006
+ if (options.approver !== void 0) lines.push(` approver: ${JSON.stringify(options.approver)}`);
2007
+ lines.push(" pack: |", indent(options.pack, 10));
2008
+ return lines.join("\n");
2009
+ }
2010
+ /** Compose the write and check it, without touching the file. */
2011
+ async function planWrite(profile, row$1) {
2012
+ const before = await readFile(profile.patchPath, "utf8");
2013
+ const after = composePatchFile(before, row$1, OWNER);
2014
+ const findings = await preflightOps(profile.dir, [{ insert: [{
2015
+ id: "prae",
2016
+ name: "dsh-prae"
2017
+ }] }]);
2018
+ const blocking = findings.some((finding) => finding.level === "blocking");
2019
+ return {
2020
+ patchPath: profile.patchPath,
2021
+ before,
2022
+ after,
2023
+ revision: revisionOf(before),
2024
+ findings,
2025
+ blocking
2026
+ };
2027
+ }
2028
+ /**
2029
+ * Apply a plan, snapshotting first.
2030
+ *
2031
+ * `expectedRevision` is a precondition rather than a formality: between
2032
+ * previewing and applying, the user may have edited the file, and writing over
2033
+ * an edit they made after reviewing our diff would hand them a profile nobody
2034
+ * chose.
2035
+ */
2036
+ async function applyWrite(profile, plan, expectedRevision) {
2037
+ if (revisionOf(await readFile(profile.patchPath, "utf8")) !== expectedRevision) throw new Error("the overlay changed since it was previewed; re-run to see the current diff");
2038
+ if (plan.blocking) throw new Error("refusing to write: a blocking finding means this row would stop the profile from booting");
2039
+ const manifest = await takeSnapshot({
2040
+ dir: join(profile.dir, ".prae", "snapshots"),
2041
+ root: profile.dir
2042
+ }, "prae-init", [profile.patchPath]);
2043
+ await writeFile(profile.patchPath, plan.after, "utf8");
2044
+ return { snapshot: manifest.id };
2045
+ }
2046
+
2047
+ //#endregion
2048
+ //#region src/serve.ts
2049
+ const TYPES = {
2050
+ ".html": "text/html; charset=utf-8",
2051
+ ".css": "text/css; charset=utf-8",
2052
+ ".js": "text/javascript; charset=utf-8",
2053
+ ".json": "application/json; charset=utf-8",
2054
+ ".png": "image/png",
2055
+ ".svg": "image/svg+xml"
2056
+ };
2057
+ /**
2058
+ * Resolve a URL path inside the root, or return undefined.
2059
+ *
2060
+ * Decoded first, then normalized, then checked against the resolved root with a
2061
+ * separator — `..` and encoded traversal both have to fail, and a sibling
2062
+ * directory whose name merely starts with the root's must not pass as inside it.
2063
+ */
2064
+ function safePath(root, urlPath) {
2065
+ let decoded;
2066
+ try {
2067
+ decoded = decodeURIComponent(urlPath.split("?")[0] ?? "/");
2068
+ } catch {
2069
+ return;
2070
+ }
2071
+ if (decoded.includes("\0")) return void 0;
2072
+ if (normalize(decoded).split(/[/\\]/).includes("..")) return void 0;
2073
+ const base = resolve(root);
2074
+ const target = resolve(join(base, decoded === "/" ? "overview.html" : decoded));
2075
+ if (target !== base && !target.startsWith(base + sep)) return void 0;
2076
+ return target;
2077
+ }
2078
+ /** Start the reader. Resolves once it is listening. */
2079
+ function serveConsole(root, port) {
2080
+ const server = createServer((req, res) => {
2081
+ if (req.method !== "GET" && req.method !== "HEAD") {
2082
+ res.statusCode = 405;
2083
+ res.setHeader("allow", "GET, HEAD");
2084
+ res.end();
2085
+ return;
2086
+ }
2087
+ const target = safePath(root, req.url ?? "/");
2088
+ if (target === void 0) {
2089
+ res.statusCode = 403;
2090
+ res.end();
2091
+ return;
2092
+ }
2093
+ readFile(target).then((body) => {
2094
+ res.statusCode = 200;
2095
+ res.setHeader("content-type", TYPES[extname(target)] ?? "application/octet-stream");
2096
+ res.setHeader("cache-control", "no-store");
2097
+ res.end(req.method === "HEAD" ? void 0 : body);
2098
+ }, () => {
2099
+ res.statusCode = 404;
2100
+ res.setHeader("content-type", "text/plain; charset=utf-8");
2101
+ res.end("not found\n");
2102
+ });
2103
+ });
2104
+ return new Promise((ok, fail$1) => {
2105
+ server.once("error", fail$1);
2106
+ server.listen(port, "127.0.0.1", () => {
2107
+ ok({
2108
+ server,
2109
+ url: `http://127.0.0.1:${port}/`
2110
+ });
2111
+ });
2112
+ });
2113
+ }
2114
+
2115
+ //#endregion
2116
+ //#region src/cli.ts
2117
+ /** Verdict labels, uppercase and fixed-width so a terminal column lines up. */
2118
+ const VERDICT_LABEL = {
2119
+ allow: "ALLOWED",
2120
+ ask: "HELD",
2121
+ deny: "DENIED",
2122
+ correct: "CORRECTED"
2123
+ };
2124
+ const USAGE = `prae — operator policy for agent harnesses
2125
+
2126
+ prae check <pack.json>
2127
+ Validate a policy pack and summarise what it enforces.
2128
+
2129
+ prae explain <pack.json> <tool> [key=value ...]
2130
+ Judge one call against the pack and print the trace. Nothing is executed.
2131
+
2132
+ prae verify <ledger.jsonl>
2133
+ Re-derive every hash in a decision log and report any break.
2134
+
2135
+ prae report <ledger.jsonl> [--out <file.html>]
2136
+ Render the decision record as a printable evidence report.
2137
+
2138
+ prae console <ledger.jsonl> --out <dir> [--pack <file>] [--serve [port]]
2139
+ Write the control plane: overview, policy packs, playground, ledger,
2140
+ compliance map. The playground needs --pack.
2141
+
2142
+ prae init [--pack <file>] [--profile <dir>] [--ledger <file>]
2143
+ [--approver <name>] [--apply]
2144
+ Compose the overlay row that mounts PRAE with a pack. Prints the
2145
+ diff; writes only with --apply.
2146
+ `;
2147
+ function fail(message) {
2148
+ console.error(`prae: ${message}`);
2149
+ process.exit(1);
2150
+ }
2151
+ async function loadPack(path) {
2152
+ let source;
2153
+ try {
2154
+ source = await readFile(path, "utf8");
2155
+ } catch {
2156
+ return fail(`cannot read ${path}`);
2157
+ }
2158
+ const result = parsePack(source);
2159
+ if (!result.ok) {
2160
+ console.error(`prae: ${path} is not a valid pack`);
2161
+ for (const error of result.errors) console.error(` ${error.path || "<root>"}: ${error.message}`);
2162
+ process.exit(1);
2163
+ }
2164
+ return result.pack;
2165
+ }
2166
+ async function check(path) {
2167
+ const pack = await loadPack(path);
2168
+ console.log(`${pack.pack} ${pack.version} — valid`);
2169
+ const baseline = pack.baseline?.capabilities;
2170
+ console.log(baseline === void 0 ? "\nbaseline (none — this pack governs through rules alone, and\n withholds no capability an author declares)" : `\nbaseline ${baseline.join(", ")}`);
2171
+ console.log(`\nrules ${pack.rules.length}`);
2172
+ for (const rule of pack.rules) {
2173
+ const label = rule.title ?? rule.effect.because;
2174
+ console.log(` ${rule.id.padEnd(10)} ${rule.effect.kind.toUpperCase().padEnd(8)} ${label}`);
2175
+ }
2176
+ }
2177
+ /** `key=value` pairs, with numbers and booleans read as themselves. */
2178
+ function parseArgs(pairs) {
2179
+ const args = {};
2180
+ for (const pair of pairs) {
2181
+ const at = pair.indexOf("=");
2182
+ if (at === -1) fail(`expected key=value, got \`${pair}\``);
2183
+ const key = pair.slice(0, at);
2184
+ const raw = pair.slice(at + 1);
2185
+ if (raw === "true") args[key] = true;
2186
+ else if (raw === "false") args[key] = false;
2187
+ else if (raw !== "" && !Number.isNaN(Number(raw))) args[key] = Number(raw);
2188
+ else args[key] = raw;
2189
+ }
2190
+ return args;
2191
+ }
2192
+ async function explain(path, tool, pairs) {
2193
+ const pack = await loadPack(path);
2194
+ const { capabilityForTool } = await import("capmark");
2195
+ const capability = capabilityForTool(tool)?.id;
2196
+ const decision = evaluate(pack, {
2197
+ tool,
2198
+ ...capability === void 0 ? {} : { capability },
2199
+ args: parseArgs(pairs)
2200
+ });
2201
+ const label = VERDICT_LABEL[decision.kind];
2202
+ console.log(`${label} ${tool}${capability ? ` (${capability})` : ""}`);
2203
+ if (decision.reason !== void 0) console.log(` ${decision.rule} · ${decision.reason}`);
2204
+ if (decision.trace.length === 0) console.log("\n no rule matched this call");
2205
+ else {
2206
+ console.log("");
2207
+ for (const step$1 of decision.trace) console.log(` ${step$1.rule.padEnd(10)} ${step$1.effect.padEnd(8)} ${step$1.because}`);
2208
+ }
2209
+ console.log(`\n${pack.pack} ${pack.version} · ${decision.evaluated} rules considered`);
2210
+ if (capability === void 0) console.log(`\nnote: no capability claims \`${tool}\`, so capability rules did not apply`);
2211
+ }
2212
+ async function verify(path) {
2213
+ let ledger;
2214
+ try {
2215
+ ledger = Ledger.open(path);
2216
+ } catch (error) {
2217
+ return fail(error instanceof Error ? error.message : String(error));
2218
+ }
2219
+ const result = ledger.verify();
2220
+ if (result.ok) {
2221
+ const last = ledger.read().at(-1);
2222
+ console.log(`${result.entries} entries · chain intact${last === void 0 ? "" : ` · seal ${seal(last)}`}`);
2223
+ console.log("tamper-evident: a removed, edited, or reordered row breaks this chain.");
2224
+ console.log("It does not prove against a rewrite of the whole file by someone holding this code.");
2225
+ return;
2226
+ }
2227
+ console.error(`${result.entries} entries · CHAIN BROKEN`);
2228
+ for (const problem of result.problems) console.error(` line ${problem.line}: ${problem.message}`);
2229
+ process.exit(1);
2230
+ }
2231
+ function flag(argv, name) {
2232
+ const at = argv.indexOf(`--${name}`);
2233
+ if (at === -1) return void 0;
2234
+ const value = argv[at + 1];
2235
+ if (value === void 0 || value.startsWith("--")) fail(`--${name} needs a value`);
2236
+ return value;
2237
+ }
2238
+ /**
2239
+ * Render the decision record as a report.
2240
+ *
2241
+ * The chain is verified before rendering rather than after, so a broken chain
2242
+ * is stated at the top of the document instead of being something the reader
2243
+ * has to go and check separately. A report that looks authoritative while
2244
+ * quietly sitting on a broken chain is worse than no report.
2245
+ */
2246
+ async function report(path, out) {
2247
+ let ledger;
2248
+ try {
2249
+ ledger = Ledger.open(path);
2250
+ } catch (error) {
2251
+ return fail(error instanceof Error ? error.message : String(error));
2252
+ }
2253
+ const entries = ledger.read();
2254
+ const verification = ledger.verify();
2255
+ const html = renderLedgerView(entries, verification, {
2256
+ ledgerPath: path,
2257
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
2258
+ });
2259
+ if (out === void 0) {
2260
+ process.stdout.write(html);
2261
+ return;
2262
+ }
2263
+ await writeFile(out, html, "utf8");
2264
+ console.log(`${out} · ${entries.length} decisions · chain ${verification.ok ? "intact" : "BROKEN"}`);
2265
+ if (!verification.ok) process.exitCode = 1;
2266
+ }
2267
+ /**
2268
+ * Write the whole control plane as linked files.
2269
+ *
2270
+ * Static output rather than a server: the views are a read of local evidence,
2271
+ * and a governance tool that opens a port to show you its own audit trail has
2272
+ * added an attack surface to solve a rendering problem.
2273
+ */
2274
+ async function consoleOut(path, argv) {
2275
+ const out = flag(argv, "out");
2276
+ if (out === void 0) fail("console needs --out <dir>");
2277
+ let ledger;
2278
+ try {
2279
+ ledger = Ledger.open(path);
2280
+ } catch (error) {
2281
+ return fail(error instanceof Error ? error.message : String(error));
2282
+ }
2283
+ const packPath = flag(argv, "pack");
2284
+ const packSource = packPath === void 0 ? void 0 : await readFile(packPath, "utf8");
2285
+ const data = {
2286
+ entries: ledger.read(),
2287
+ verification: ledger.verify(),
2288
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2289
+ ...packPath === void 0 ? {} : { pack: await loadPack(packPath) }
2290
+ };
2291
+ await mkdir(out, { recursive: true });
2292
+ const pages = [
2293
+ ["overview.html", renderOverview(data)],
2294
+ ["packs.html", renderPacks(data)],
2295
+ ["ledger.html", renderLedgerView(data.entries, data.verification, {
2296
+ ledgerPath: path,
2297
+ generatedAt: data.generatedAt,
2298
+ ...data.pack === void 0 ? {} : { pack: data.pack }
2299
+ })],
2300
+ ["approvals.html", renderApprovals(data)],
2301
+ ["compliance.html", renderCompliance(data)],
2302
+ ...packSource === void 0 ? [] : [["playground.html", renderPlayground(data, packSource)]]
2303
+ ];
2304
+ for (const [name, html] of pages) await writeFile(join(out, name), html, "utf8");
2305
+ console.log(`${out} · ${pages.length} views · ${data.entries.length} decisions · chain ${data.verification.ok ? "intact" : "BROKEN"}`);
2306
+ if (!data.verification.ok) process.exitCode = 1;
2307
+ if (!argv.includes("--serve")) {
2308
+ console.log(`open ${join(out, "overview.html")}`);
2309
+ return;
2310
+ }
2311
+ const portFlag = flag(argv, "serve");
2312
+ const port = portFlag === void 0 ? 4180 : Number(portFlag);
2313
+ if (!Number.isInteger(port) || port < 1 || port > 65535) fail(`--serve takes a port, got \`${portFlag}\``);
2314
+ const { url } = await serveConsole(out, port);
2315
+ console.log(`\nserving ${out} at ${url}`);
2316
+ console.log("loopback only, read-only. ctrl-c to stop.");
2317
+ }
2318
+ async function init(argv) {
2319
+ const packPath = flag(argv, "pack");
2320
+ if (packPath === void 0) fail("init needs --pack <file>");
2321
+ const pack = await loadPack(packPath);
2322
+ const source = await readFile(packPath, "utf8");
2323
+ const profileDir = flag(argv, "profile");
2324
+ const profile = profileDir === void 0 ? defaultProfile() : profileAt(profileDir);
2325
+ const ledger = flag(argv, "ledger");
2326
+ const approver = flag(argv, "approver");
2327
+ const row$1 = praeRow({
2328
+ pack: source,
2329
+ ...ledger === void 0 ? {} : { ledger },
2330
+ ...approver === void 0 ? {} : { approver }
2331
+ });
2332
+ let plan;
2333
+ try {
2334
+ plan = await planWrite(profile, row$1);
2335
+ } catch (error) {
2336
+ return fail(error instanceof Error ? error.message : String(error));
2337
+ }
2338
+ console.log(`profile ${profile.dir}`);
2339
+ console.log(`overlay ${plan.patchPath}`);
2340
+ console.log(`pack ${pack.pack} ${pack.version} · ${pack.rules.length} rules`);
2341
+ if (ledger !== void 0) console.log(`ledger ${ledger}`);
2342
+ if (plan.findings.length > 0) {
2343
+ console.log("");
2344
+ for (const finding of plan.findings) console.log(`${finding.level === "blocking" ? "BLOCKING" : "warning "} ${finding.text}`);
2345
+ }
2346
+ if (plan.blocking) {
2347
+ console.error("\nNot writing. A row naming a package the profile cannot resolve stops it");
2348
+ console.error("from booting entirely — install dsh-prae into the profile first.");
2349
+ process.exit(1);
2350
+ }
2351
+ if (!argv.includes("--apply")) {
2352
+ console.log("\n--- overlay after this change ---");
2353
+ console.log(plan.after);
2354
+ console.log("--- end ---");
2355
+ console.log("\nNothing was written. Re-run with --apply to write it.");
2356
+ return;
2357
+ }
2358
+ try {
2359
+ const { snapshot } = await applyWrite(profile, plan, plan.revision);
2360
+ console.log(`\nwritten · snapshot ${snapshot}`);
2361
+ console.log("Restart the harness for the row to take effect.");
2362
+ } catch (error) {
2363
+ return fail(error instanceof Error ? error.message : String(error));
2364
+ }
2365
+ }
2366
+ async function main(argv) {
2367
+ const [command, ...rest] = argv;
2368
+ switch (command) {
2369
+ case "check": {
2370
+ const path = rest[0];
2371
+ if (path === void 0) fail("check needs a pack file");
2372
+ return check(path);
2373
+ }
2374
+ case "explain": {
2375
+ const [path, tool, ...pairs] = rest;
2376
+ if (path === void 0 || tool === void 0) fail("explain needs a pack file and a tool name");
2377
+ return explain(path, tool, pairs);
2378
+ }
2379
+ case "verify": {
2380
+ const path = rest[0];
2381
+ if (path === void 0) fail("verify needs a ledger file");
2382
+ return verify(path);
2383
+ }
2384
+ case "report": {
2385
+ const path = rest[0];
2386
+ if (path === void 0) fail("report needs a ledger file");
2387
+ return report(path, flag(rest, "out"));
2388
+ }
2389
+ case "console": {
2390
+ const path = rest[0];
2391
+ if (path === void 0) fail("console needs a ledger file");
2392
+ return consoleOut(path, rest);
2393
+ }
2394
+ case "init": return init(rest);
2395
+ case "--help":
2396
+ case "-h":
2397
+ case void 0:
2398
+ console.log(USAGE);
2399
+ return;
2400
+ default: fail(`unknown command \`${command}\`\n\n${USAGE}`);
2401
+ }
2402
+ }
2403
+
2404
+ //#endregion
2405
+ export { planWrite as a, dshHome as i, applyWrite as n, praeRow as o, defaultProfile as r, profileAt as s, main as t };