@crewhaus/hangar-ui 0.5.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.
Files changed (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -0,0 +1,925 @@
1
+ /**
2
+ * Security & audit — the audit chain, the egress review console, the PII
3
+ * tuner, the justification console, the security corpus and sandbox checks,
4
+ * the onchain safety panels, compliance evidence, retention, and the SLO
5
+ * monitor.
6
+ *
7
+ * Honesty rules this screen is responsible for:
8
+ * - `audit verify` reports its DESIGNED limits (`anchorChecked` /
9
+ * `externalAnchorChecked: false`) as facts, not as failures, and the
10
+ * raw chain files are never shown — only rendered records.
11
+ * - an egress record carries LINEAGE, not the outbound payload. The
12
+ * console says so rather than rendering an empty body field that reads
13
+ * like data loss.
14
+ * - retention sweep and purge run `--dry-run` FIRST and show the plan; the
15
+ * real run is a second, typed-confirm gesture, and the plan names what
16
+ * the pins saved.
17
+ * - `transaction_policy` is human-owned spec, so its tuner previews and
18
+ * hands off to the spec write path rather than writing behind it.
19
+ */
20
+
21
+ import { api } from "../api.js";
22
+ import { clear, collapsible, dot, el, jsonPre, skeleton, toast } from "../dom.js";
23
+ import { fmtPct, fmtRelativeTime } from "../util.js";
24
+ import { m3, m3Card, m3Empty } from "./creds.js";
25
+
26
+ export async function renderSecurity(root, ctx) {
27
+ clear(root).appendChild(skeleton(8));
28
+ const [
29
+ auditAnswer,
30
+ egressAnswer,
31
+ piiAnswer,
32
+ justificationAnswer,
33
+ corpusAnswer,
34
+ sandboxAnswer,
35
+ onchainAnswer,
36
+ sentinelAnswer,
37
+ complianceAnswer,
38
+ retentionAnswer,
39
+ sloAnswer,
40
+ ] = await Promise.all([
41
+ m3(api.audit({ id: ctx.id })),
42
+ m3(api.egress({ id: ctx.id })),
43
+ m3(api.pii({ id: ctx.id })),
44
+ m3(api.justification({ id: ctx.id })),
45
+ m3(api.securityCorpus({ id: ctx.id })),
46
+ m3(api.sandboxDoctor({ id: ctx.id })),
47
+ m3(api.onchain({ id: ctx.id })),
48
+ m3(api.onchainSentinel({ id: ctx.id })),
49
+ m3(api.compliance({ id: ctx.id })),
50
+ m3(api.retention({ id: ctx.id })),
51
+ m3(api.slo({ id: ctx.id })),
52
+ ]);
53
+ clear(root);
54
+ const reload = () => renderSecurity(root, ctx);
55
+
56
+ root.appendChild(m3Card("Audit chain", auditAnswer, (body) => auditCard(body, ctx)));
57
+ root.appendChild(m3Card("Egress review", egressAnswer, (body) => egressCard(body, ctx, reload)));
58
+ root.appendChild(m3Card("SLO monitor", sloAnswer, (body) => sloCard(body)));
59
+ root.appendChild(
60
+ m3Card("Justification", justificationAnswer, (body) => justificationCard(body, ctx)),
61
+ );
62
+ root.appendChild(m3Card("PII policy", piiAnswer, (body) => piiCard(body, ctx, reload)));
63
+ root.appendChild(
64
+ m3Card("Security corpus", corpusAnswer, (body) => corpusCard(body, ctx, reload)),
65
+ );
66
+ root.appendChild(m3Card("Sandbox", sandboxAnswer, (body) => sandboxCard(body)));
67
+ root.appendChild(
68
+ m3Card("Onchain safety", onchainAnswer, (body) =>
69
+ onchainCard(body, sentinelAnswer.ok ? sentinelAnswer.body : null, ctx),
70
+ ),
71
+ );
72
+ root.appendChild(
73
+ m3Card("Compliance evidence", complianceAnswer, (body) => complianceCard(body, ctx, reload)),
74
+ );
75
+ root.appendChild(m3Card("Retention", retentionAnswer, (body) => retentionCard(body, ctx)));
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Audit
80
+ // ---------------------------------------------------------------------------
81
+
82
+ function auditCard(body, ctx) {
83
+ const records = Array.isArray(body.records) ? body.records : [];
84
+ const kinds = Array.isArray(body.kinds) ? body.kinds : [];
85
+ const encryption = body.encryption && typeof body.encryption === "object" ? body.encryption : {};
86
+ const verifyState = el("div", { class: "control-state" });
87
+
88
+ const verifyBtn = el("button", { class: "btn", type: "button", text: "Verify chain" });
89
+ verifyBtn.addEventListener("click", async () => {
90
+ verifyBtn.disabled = true;
91
+ const answer = await m3(api.auditVerify({ id: ctx.id }, {}));
92
+ verifyBtn.disabled = false;
93
+ if (!answer.ok) {
94
+ verifyState.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
95
+ return;
96
+ }
97
+ const result = answer.body;
98
+ const limitations = Array.isArray(result.limitations) ? result.limitations : [];
99
+ verifyState.replaceChildren(
100
+ result.ok === true
101
+ ? dot("ok", `chain intact — ${String(result.recordsChecked)} record(s) checked`)
102
+ : dot("bad", `chain broken: ${String(result.reason ?? "")}`),
103
+ // The two anchor flags are FACTS about what the verifier does, not
104
+ // failures — so they render as notes beside a green light, never as a
105
+ // downgrade of it.
106
+ ...limitations.map((line) => el("div", { class: "muted reason", text: String(line) })),
107
+ );
108
+ });
109
+
110
+ const nodes = [
111
+ el("div", { class: "rollup" }, [
112
+ dot(body.present === true ? "ok" : "off", `${records.length} record(s) rendered`),
113
+ encryption.on === true ? el("span", { class: "chip chip-warn", text: "encrypted" }) : null,
114
+ body.truncated === true ? el("span", { class: "chip", text: "truncated" }) : null,
115
+ Number(body.tornLines) > 0
116
+ ? el("span", { class: "chip", text: `${Number(body.tornLines)} torn line(s)` })
117
+ : null,
118
+ ]),
119
+ el("p", { class: "muted", text: String(body.rawFilesNote ?? "") }),
120
+ encryption.on === true && encryption.note
121
+ ? el("p", { class: "muted", text: String(encryption.note) })
122
+ : null,
123
+ el("div", { class: "control-action" }, [
124
+ el("div", { class: "control-row" }, [verifyBtn]),
125
+ verifyState,
126
+ ]),
127
+ ];
128
+
129
+ if (kinds.length > 0) {
130
+ nodes.push(
131
+ el(
132
+ "div",
133
+ { class: "chip-group" },
134
+ kinds.map((entry) =>
135
+ el("span", { class: "chip", text: `${String(entry.kind)} ${String(entry.count)}` }),
136
+ ),
137
+ ),
138
+ );
139
+ }
140
+ if (records.length === 0) {
141
+ nodes.push(m3Empty(body, "No audit records yet"));
142
+ return nodes;
143
+ }
144
+ const tbody = el("tbody");
145
+ for (const record of records.slice(-100).reverse()) {
146
+ tbody.appendChild(
147
+ el("tr", null, [
148
+ el("td", { class: "mono", text: String(record.seq) }),
149
+ el("td", {
150
+ class: "muted",
151
+ text: record.ts ? fmtRelativeTime(record.ts, Date.now()) : "—",
152
+ }),
153
+ el("td", { class: "mono", text: String(record.kind) }),
154
+ el("td", null, [
155
+ record.hashOk ? dot("ok", "body matches hash") : dot("warn", "hash mismatch"),
156
+ ]),
157
+ el("td", null, [
158
+ record.encrypted
159
+ ? el("span", { class: "muted", text: "sealed" })
160
+ : collapsible(
161
+ [el("span", { class: "muted", text: "payload" })],
162
+ [jsonPre(record.payload)],
163
+ ),
164
+ ]),
165
+ ]),
166
+ );
167
+ }
168
+ nodes.push(
169
+ el("div", { class: "table-scroll" }, [
170
+ el("table", { class: "fleet" }, [
171
+ el(
172
+ "thead",
173
+ null,
174
+ el(
175
+ "tr",
176
+ null,
177
+ ["Seq", "When", "Kind", "Integrity", "Payload"].map((h) => el("th", { text: h })),
178
+ ),
179
+ ),
180
+ tbody,
181
+ ]),
182
+ ]),
183
+ );
184
+ return nodes;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Egress
189
+ // ---------------------------------------------------------------------------
190
+
191
+ function egressCard(body, ctx, reload) {
192
+ const decisions = Array.isArray(body.decisions) ? body.decisions : [];
193
+ const nodes = [
194
+ el("div", { class: "rollup" }, [
195
+ dot(Number(body.open) > 0 ? "warn" : "ok", `${Number(body.open) || 0} untriaged`),
196
+ el("span", { class: "chip", text: `${decisions.length} decision(s)` }),
197
+ ]),
198
+ // The record's omission of the outbound body is DELIBERATE — saying so
199
+ // is the difference between "designed" and "broken".
200
+ el("p", { class: "muted", text: String(body.payloadNote ?? "") }),
201
+ ];
202
+ if (decisions.length === 0) {
203
+ nodes.push(m3Empty(body, "No egress decisions recorded"));
204
+ return nodes;
205
+ }
206
+ const tbody = el("tbody");
207
+ for (const decision of decisions.slice(-100).reverse()) {
208
+ tbody.appendChild(
209
+ el("tr", null, [
210
+ el("td", { class: "mono", text: String(decision.decisionId) }),
211
+ el("td", {
212
+ class: "muted",
213
+ text: decision.ts ? fmtRelativeTime(decision.ts, Date.now()) : "—",
214
+ }),
215
+ el("td", { class: "mono", text: String(decision.sinkId ?? "—") }),
216
+ el("td", null, [
217
+ decision.verdict === "block"
218
+ ? dot("bad", "block")
219
+ : dot("warn", String(decision.verdict ?? "—")),
220
+ ]),
221
+ el("td", { class: "muted sub", text: (decision.origins || []).join(" → ") || "—" }),
222
+ el("td", { class: "cell-actions" }, [triageControl(decision, ctx, reload)]),
223
+ ]),
224
+ );
225
+ }
226
+ nodes.push(
227
+ el("div", { class: "table-scroll" }, [
228
+ el("table", { class: "fleet" }, [
229
+ el(
230
+ "thead",
231
+ null,
232
+ el(
233
+ "tr",
234
+ null,
235
+ ["Id", "When", "Sink", "Verdict", "Lineage", "Triage"].map((h) =>
236
+ el("th", { text: h }),
237
+ ),
238
+ ),
239
+ ),
240
+ tbody,
241
+ ]),
242
+ ]),
243
+ );
244
+ return nodes;
245
+ }
246
+
247
+ function triageControl(decision, ctx, reload) {
248
+ if (decision.triage) {
249
+ return el("span", { class: "muted" }, [
250
+ dot("ok", String(decision.triage.verdict)),
251
+ el("span", { text: ` by ${String(decision.triage.by ?? "?")}` }),
252
+ ]);
253
+ }
254
+ const select = el("select", { class: "input", "aria-label": "triage verdict" }, [
255
+ el("option", { value: "acknowledge", text: "acknowledge" }),
256
+ el("option", { value: "allow", text: "allow" }),
257
+ el("option", { value: "block", text: "block" }),
258
+ el("option", { value: "needs-review", text: "needs-review" }),
259
+ ]);
260
+ const note = el("input", { class: "input", type: "text", placeholder: "note (optional)" });
261
+ const save = el("button", { class: "btn btn-ghost", type: "button", text: "Record" });
262
+ save.addEventListener("click", async () => {
263
+ save.disabled = true;
264
+ const answer = await m3(
265
+ api.egressReview(
266
+ { id: ctx.id, decisionId: String(decision.decisionId) },
267
+ { verdict: select.value, ...(note.value !== "" ? { note: note.value } : {}) },
268
+ ),
269
+ );
270
+ save.disabled = false;
271
+ if (!answer.ok || answer.body.recorded !== true) {
272
+ toast(String(answer.body.reason ?? answer.body.error ?? "could not record the triage"));
273
+ return;
274
+ }
275
+ reload();
276
+ });
277
+ return el("div", { class: "row-editor" }, [select, note, save]);
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // SLO
282
+ // ---------------------------------------------------------------------------
283
+
284
+ function sloCard(body) {
285
+ const targets = Array.isArray(body.targets) ? body.targets : [];
286
+ const alerts = Array.isArray(body.alerts) ? body.alerts : [];
287
+ const exporters = body.exporters && typeof body.exporters === "object" ? body.exporters : {};
288
+ const nodes = [
289
+ el("div", { class: "rollup" }, [
290
+ dot(
291
+ targets.some((target) => target.breached) ? "bad" : body.declared === true ? "ok" : "off",
292
+ body.declared === true
293
+ ? targets.some((target) => target.breached)
294
+ ? "a target is breached"
295
+ : "within every declared target"
296
+ : "no SLO declared",
297
+ ),
298
+ el("span", { class: "chip", text: `ladder: ${(body.ladder || []).join(" → ")}` }),
299
+ el("span", { class: "chip", text: `state: ${String(body.ladderState ?? "idle")}` }),
300
+ typeof body.observedAt === "string"
301
+ ? el("span", {
302
+ class: "asof",
303
+ title: body.observedAt,
304
+ text: `as of ${fmtRelativeTime(body.observedAt, Date.now())}`,
305
+ })
306
+ : null,
307
+ ]),
308
+ el("p", { class: "muted", text: String(body.ladderNote ?? "") }),
309
+ ];
310
+ if (targets.length === 0) {
311
+ nodes.push(m3Empty(body, "No SLO targets and no observations"));
312
+ } else {
313
+ const tbody = el("tbody");
314
+ for (const target of targets) {
315
+ tbody.appendChild(
316
+ el("tr", null, [
317
+ el("td", { class: "mono", text: String(target.metric) }),
318
+ el("td", { text: fmtTarget(target.threshold, target.unit) }),
319
+ el("td", { text: fmtTarget(target.observed, target.unit) }),
320
+ el("td", null, [
321
+ target.observed === null
322
+ ? dot("off", "no observation")
323
+ : target.breached
324
+ ? dot("bad", "breached")
325
+ : dot("ok", "within target"),
326
+ ]),
327
+ ]),
328
+ );
329
+ }
330
+ nodes.push(
331
+ el("div", { class: "table-scroll" }, [
332
+ el("table", { class: "fleet" }, [
333
+ el(
334
+ "thead",
335
+ null,
336
+ el(
337
+ "tr",
338
+ null,
339
+ ["Metric", "Target", "Observed", "State"].map((h) => el("th", { text: h })),
340
+ ),
341
+ ),
342
+ tbody,
343
+ ]),
344
+ ]),
345
+ );
346
+ }
347
+ nodes.push(
348
+ el("div", { class: "rollup" }, [
349
+ exporters.otlpEndpointConfigured
350
+ ? dot("ok", "OTLP endpoint set")
351
+ : dot("off", "no OTLP endpoint"),
352
+ exporters.metricsConfigured ? dot("ok", "metrics sink set") : dot("off", "no metrics sink"),
353
+ el("span", { class: "muted", text: String(exporters.note ?? "") }),
354
+ ]),
355
+ );
356
+ if (alerts.length > 0) {
357
+ nodes.push(
358
+ collapsible(
359
+ [el("span", { class: "muted", text: `${alerts.length} alert_raised record(s)` })],
360
+ [
361
+ el(
362
+ "ul",
363
+ { class: "check-list" },
364
+ alerts
365
+ .slice(-20)
366
+ .reverse()
367
+ .map((alert) =>
368
+ el("li", { class: "check" }, [
369
+ dot("warn", String(alert.metric ?? "alert")),
370
+ el("span", {
371
+ text: ` observed ${String(alert.observed)} vs ${String(alert.threshold)} (from ${String(alert.baselineSessions)} baseline session(s))`,
372
+ }),
373
+ alert.detail
374
+ ? el("div", { class: "muted reason", text: String(alert.detail) })
375
+ : null,
376
+ ]),
377
+ ),
378
+ ),
379
+ el("p", { class: "muted", text: String(body.baselineNote ?? "") }),
380
+ ],
381
+ ),
382
+ );
383
+ }
384
+ return nodes;
385
+ }
386
+
387
+ function fmtTarget(value, unit) {
388
+ if (value === null || value === undefined) return "—";
389
+ if (unit === "rate") return fmtPct(Number(value));
390
+ if (unit === "ms") return `${Math.round(Number(value))} ms`;
391
+ return `${Number(value).toFixed(2)} ${String(unit)}`;
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Justification
396
+ // ---------------------------------------------------------------------------
397
+
398
+ function justificationCard(body, ctx) {
399
+ const records = Array.isArray(body.records) ? body.records : [];
400
+ const byTool = Array.isArray(body.byTool) ? body.byTool : [];
401
+ const state = el("div", { class: "control-state" });
402
+
403
+ const tool = el("input", {
404
+ class: "input mono",
405
+ type: "text",
406
+ placeholder: "tool name",
407
+ "aria-label": "tool name",
408
+ });
409
+ const preflight = el("button", {
410
+ class: "btn",
411
+ type: "button",
412
+ text: "Preflight (never runs the tool)",
413
+ });
414
+ preflight.addEventListener("click", async () => {
415
+ if (tool.value === "") {
416
+ toast("name a tool first", "info");
417
+ return;
418
+ }
419
+ preflight.disabled = true;
420
+ const answer = await m3(api.justificationPreflight({ id: ctx.id }, { tool: tool.value }));
421
+ preflight.disabled = false;
422
+ if (!answer.ok) {
423
+ state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
424
+ return;
425
+ }
426
+ const result = answer.body;
427
+ const history = result.history && typeof result.history === "object" ? result.history : {};
428
+ state.replaceChildren(
429
+ dot(
430
+ result.likelyVerdict === "deny" ? "bad" : result.likelyVerdict === "allow" ? "ok" : "off",
431
+ `likely ${String(result.likelyVerdict)}`,
432
+ ),
433
+ el("span", {
434
+ class: "chip",
435
+ text: `${String(history.evaluations ?? 0)} past evaluation(s), ${String(history.denials ?? 0)} denied`,
436
+ }),
437
+ el("span", { class: "chip", text: "the tool was NOT executed" }),
438
+ el("div", { class: "muted reason", text: String(result.note ?? "") }),
439
+ );
440
+ });
441
+
442
+ const calibrate = el("button", { class: "btn btn-ghost", type: "button", text: "Calibrate" });
443
+ calibrate.addEventListener("click", async () => {
444
+ calibrate.disabled = true;
445
+ const answer = await m3(api.justificationCalibrate({ id: ctx.id }, { apply: true }));
446
+ calibrate.disabled = false;
447
+ if (!answer.ok) {
448
+ toast(String(answer.body.error ?? answer.status));
449
+ return;
450
+ }
451
+ state.replaceChildren(
452
+ dot("ok", "calibration queued"),
453
+ el("div", { class: "muted reason", text: String(answer.body.applyNote ?? "") }),
454
+ );
455
+ });
456
+
457
+ const nodes = [
458
+ el("div", { class: "rollup" }, [
459
+ dot(records.length > 0 ? "ok" : "off", `${records.length} evaluated call(s)`),
460
+ body.config
461
+ ? el("span", { class: "chip", text: `judge: ${String(body.config.judge ?? "declared")}` })
462
+ : el("span", { class: "chip", text: "no security.justification block" }),
463
+ ]),
464
+ el("div", { class: "control-action" }, [
465
+ el("div", { class: "control-row" }, [tool, preflight, calibrate]),
466
+ state,
467
+ ]),
468
+ el("p", { class: "muted", text: String(body.linkNote ?? "") }),
469
+ ];
470
+ if (byTool.length > 0) {
471
+ nodes.push(
472
+ el(
473
+ "div",
474
+ { class: "chip-group" },
475
+ byTool.map((entry) =>
476
+ el("span", {
477
+ class: "chip",
478
+ text: `${String(entry.toolName)}: ${String(entry.allow)}✓ / ${String(entry.deny)}✗`,
479
+ }),
480
+ ),
481
+ ),
482
+ );
483
+ }
484
+ if (records.length === 0) {
485
+ nodes.push(m3Empty(body, "No justification records yet"));
486
+ return nodes;
487
+ }
488
+ nodes.push(
489
+ collapsible(
490
+ [el("span", { class: "muted", text: "verbatim justifications" })],
491
+ [
492
+ el(
493
+ "ul",
494
+ { class: "check-list" },
495
+ records
496
+ .slice(-30)
497
+ .reverse()
498
+ .map((record) =>
499
+ el("li", { class: "check" }, [
500
+ record.verdict === "deny" ? dot("bad", "deny") : dot("ok", "allow"),
501
+ el("code", { text: String(record.toolName) }),
502
+ el("div", { class: "muted", text: String(record.justification) }),
503
+ record.reason
504
+ ? el("div", { class: "muted reason", text: String(record.reason) })
505
+ : null,
506
+ ]),
507
+ ),
508
+ ),
509
+ ],
510
+ ),
511
+ );
512
+ return nodes;
513
+ }
514
+
515
+ // ---------------------------------------------------------------------------
516
+ // PII
517
+ // ---------------------------------------------------------------------------
518
+
519
+ function piiCard(body, ctx, reload) {
520
+ const nodes = [
521
+ el("div", { class: "rollup" }, [
522
+ dot(
523
+ body.present === true ? "ok" : "off",
524
+ `${Number(body.allowEntries) || 0} allow entr(ies)`,
525
+ ),
526
+ ]),
527
+ el("p", { class: "muted", text: String(body.valuesNote ?? "") }),
528
+ el("p", { class: "muted", text: String(body.hitCountsNote ?? "") }),
529
+ ];
530
+ if (body.present !== true) {
531
+ nodes.push(m3Empty(body, "No pii-policy.json — the built-in detector set applies"));
532
+ } else {
533
+ nodes.push(
534
+ collapsible([el("span", { class: "muted", text: "policy" })], [jsonPre(body.policy)]),
535
+ );
536
+ }
537
+ nodes.push(
538
+ policyTuner("PII policy", body.policy, ctx, reload, (policy, dryRun, confirmName) =>
539
+ api.piiTune({ id: ctx.id }, { policy, dryRun, ...(confirmName ? { confirmName } : {}) }),
540
+ ),
541
+ );
542
+ return nodes;
543
+ }
544
+
545
+ /**
546
+ * The shared policy tuner: paste a policy, see the BEFORE/AFTER, and only
547
+ * then confirm. `dryRun` defaults to true on the server too — an omitted
548
+ * flag must never mean "do it".
549
+ */
550
+ function policyTuner(label, current, ctx, reload, call) {
551
+ const area = el("textarea", {
552
+ class: "input",
553
+ rows: "6",
554
+ spellcheck: "false",
555
+ "aria-label": `${label} JSON`,
556
+ });
557
+ area.value = JSON.stringify(current ?? {}, null, 2);
558
+ const confirm = el("input", {
559
+ class: "input mono",
560
+ type: "text",
561
+ placeholder: "type the harness name to apply",
562
+ "aria-label": "typed confirmation",
563
+ });
564
+ const state = el("div", { class: "control-state" });
565
+ const previewBtn = el("button", { class: "btn", type: "button", text: "Preview" });
566
+ const applyBtn = el("button", { class: "btn btn-danger", type: "button", text: "Apply" });
567
+
568
+ const parse = () => {
569
+ try {
570
+ return JSON.parse(area.value);
571
+ } catch {
572
+ toast("that is not valid JSON");
573
+ return undefined;
574
+ }
575
+ };
576
+
577
+ previewBtn.addEventListener("click", async () => {
578
+ const policy = parse();
579
+ if (policy === undefined) return;
580
+ previewBtn.disabled = true;
581
+ const answer = await m3(call(policy, true, ""));
582
+ previewBtn.disabled = false;
583
+ if (!answer.ok) {
584
+ state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
585
+ return;
586
+ }
587
+ const preview =
588
+ answer.body.preview && typeof answer.body.preview === "object" ? answer.body.preview : {};
589
+ state.replaceChildren(
590
+ preview.changed ? dot("warn", "this would change the policy") : dot("off", "no change"),
591
+ collapsible([el("span", { class: "muted", text: "before" })], [jsonPre(preview.before)]),
592
+ collapsible([el("span", { class: "muted", text: "after" })], [jsonPre(preview.after)]),
593
+ el("div", { class: "muted reason", text: String(answer.body.note ?? "") }),
594
+ );
595
+ });
596
+
597
+ applyBtn.addEventListener("click", async () => {
598
+ const policy = parse();
599
+ if (policy === undefined) return;
600
+ applyBtn.disabled = true;
601
+ const answer = await m3(call(policy, false, confirm.value));
602
+ applyBtn.disabled = false;
603
+ if (!answer.ok) {
604
+ state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
605
+ return;
606
+ }
607
+ state.replaceChildren(
608
+ answer.body.wrote === true
609
+ ? dot("ok", "written")
610
+ : dot("warn", String(answer.body.note ?? "handed off")),
611
+ answer.body.specEdit
612
+ ? collapsible(
613
+ [el("span", { class: "muted", text: "the edit, for the spec write path" })],
614
+ [jsonPre(answer.body.specEdit)],
615
+ )
616
+ : null,
617
+ );
618
+ if (answer.body.wrote === true) reload();
619
+ });
620
+
621
+ return el("div", { class: "control-action" }, [
622
+ el("p", { class: "muted", text: `${label} — dry run first, then a typed confirmation` }),
623
+ area,
624
+ el("div", { class: "row-editor" }, [confirm, previewBtn, applyBtn]),
625
+ state,
626
+ ]);
627
+ }
628
+
629
+ // ---------------------------------------------------------------------------
630
+ // Corpus + sandbox
631
+ // ---------------------------------------------------------------------------
632
+
633
+ function corpusCard(body, ctx, reload) {
634
+ const run = el("button", { class: "btn", type: "button", text: "Run corpus check" });
635
+ run.addEventListener("click", async () => {
636
+ run.disabled = true;
637
+ const answer = await m3(api.securityCorpusCheck({ id: ctx.id }, {}));
638
+ run.disabled = false;
639
+ if (!answer.ok) {
640
+ toast(String(answer.body.error ?? answer.status));
641
+ return;
642
+ }
643
+ toast("corpus check queued", "info");
644
+ reload();
645
+ });
646
+ const last = body.lastCheck && typeof body.lastCheck === "object" ? body.lastCheck : null;
647
+ const nodes = [
648
+ el("div", { class: "rollup" }, [
649
+ dot(body.present === true ? "ok" : "off", `${Number(body.cases) || 0} case(s)`),
650
+ el("span", { class: "chip", text: `${Number(body.candidateRules) || 0} candidate rule(s)` }),
651
+ typeof body.builtAt === "string"
652
+ ? el("span", {
653
+ class: "asof",
654
+ title: body.builtAt,
655
+ text: `built ${fmtRelativeTime(body.builtAt, Date.now())}`,
656
+ })
657
+ : null,
658
+ ]),
659
+ el("p", { class: "muted", text: String(body.payloadNote ?? "") }),
660
+ el("div", { class: "control-action" }, [
661
+ el("div", { class: "control-row" }, [run]),
662
+ el("div", { class: "control-state" }, [
663
+ last === null
664
+ ? el("span", { class: "muted", text: "no check has run from this manager yet" })
665
+ : dot(last.exitCode === 0 ? "ok" : "warn", `last check ${String(last.state)}`),
666
+ ]),
667
+ ]),
668
+ ];
669
+ if (body.present !== true) nodes.push(m3Empty(body, "No security corpus yet"));
670
+ return nodes;
671
+ }
672
+
673
+ function sandboxCard(body) {
674
+ const backends = Array.isArray(body.backends) ? body.backends : [];
675
+ const nodes = [
676
+ el("div", { class: "rollup" }, [
677
+ body.declared === true ? dot("ok", "sandbox declared") : dot("off", "no sandbox block"),
678
+ ...backends.map((backend) =>
679
+ backend.available
680
+ ? dot("ok", `${String(backend.name)} available`)
681
+ : dot("off", `${String(backend.name)} not on PATH`),
682
+ ),
683
+ ]),
684
+ el("p", { text: String(body.wouldHappen ?? "") }),
685
+ el("p", { class: "muted", text: String(body.probeNote ?? "") }),
686
+ ];
687
+ if (body.config) {
688
+ nodes.push(
689
+ collapsible([el("span", { class: "muted", text: "sandbox config" })], [jsonPre(body.config)]),
690
+ );
691
+ }
692
+ if (body.declared !== true) nodes.push(m3Empty(body, "This spec declares no sandbox"));
693
+ return nodes;
694
+ }
695
+
696
+ // ---------------------------------------------------------------------------
697
+ // Onchain
698
+ // ---------------------------------------------------------------------------
699
+
700
+ function onchainCard(body, sentinel, ctx) {
701
+ if (body.shapeGated !== true && body.transactionPolicy === null) {
702
+ return [m3Empty(body, `Not an onchain shape (target: ${String(body.target)})`)];
703
+ }
704
+ const nodes = [
705
+ el("div", { class: "rollup" }, [
706
+ // transaction_policy is the ceiling on what value the agent can move,
707
+ // so it leads this panel rather than sitting in a fold.
708
+ body.transactionPolicy
709
+ ? dot("ok", `approval: ${String(body.approvalMode ?? "declared")}`)
710
+ : dot("bad", "no transaction_policy — nothing caps what this agent can move"),
711
+ el("span", { class: "chip", text: `${(body.receipts || []).length} broadcast(s)` }),
712
+ ]),
713
+ el("p", { class: "muted", text: String(body.keyNote ?? "") }),
714
+ body.transactionPolicy
715
+ ? collapsible(
716
+ [el("span", { class: "muted", text: "transaction_policy" })],
717
+ [jsonPre(body.transactionPolicy)],
718
+ )
719
+ : null,
720
+ ];
721
+ for (const [label, value] of [
722
+ ["chains", body.chains],
723
+ ["wallets", body.wallets],
724
+ ["contracts", body.contracts],
725
+ ["triggers", body.triggers],
726
+ ]) {
727
+ if (value === null || value === undefined) continue;
728
+ nodes.push(collapsible([el("span", { class: "muted", text: label })], [jsonPre(value)]));
729
+ }
730
+ if (sentinel) {
731
+ const baselines = Array.isArray(sentinel.baselines) ? sentinel.baselines : [];
732
+ nodes.push(
733
+ el("div", { class: "rollup" }, [
734
+ dot(sentinel.present === true ? "ok" : "off", `${baselines.length} contract baseline(s)`),
735
+ el("span", { class: "muted", text: String(sentinel.note ?? "") }),
736
+ ]),
737
+ );
738
+ }
739
+ nodes.push(
740
+ policyTuner(
741
+ "transaction_policy",
742
+ body.transactionPolicy,
743
+ ctx,
744
+ () => {},
745
+ (policy, dryRun, confirmName) =>
746
+ api.onchainTune(
747
+ { id: ctx.id },
748
+ { policy, dryRun, ...(confirmName ? { confirmName } : {}) },
749
+ ),
750
+ ),
751
+ );
752
+ return nodes;
753
+ }
754
+
755
+ // ---------------------------------------------------------------------------
756
+ // Compliance + retention
757
+ // ---------------------------------------------------------------------------
758
+
759
+ function complianceCard(body, ctx, reload) {
760
+ const bundles = Array.isArray(body.bundles) ? body.bundles : [];
761
+ const frameworks = Array.isArray(body.frameworks) ? body.frameworks : [];
762
+ const select = el(
763
+ "select",
764
+ { class: "input", "aria-label": "framework" },
765
+ frameworks.map((framework) =>
766
+ el("option", { value: String(framework), text: String(framework) }),
767
+ ),
768
+ );
769
+ const generate = el("button", { class: "btn", type: "button", text: "Generate evidence" });
770
+ generate.addEventListener("click", async () => {
771
+ generate.disabled = true;
772
+ const answer = await m3(api.complianceEvidence({ id: ctx.id }, { framework: select.value }));
773
+ generate.disabled = false;
774
+ if (!answer.ok) {
775
+ toast(String(answer.body.error ?? answer.status));
776
+ return;
777
+ }
778
+ toast("evidence collection queued", "info");
779
+ reload();
780
+ });
781
+ const nodes = [
782
+ el("div", { class: "rollup" }, [
783
+ dot(bundles.length > 0 ? "ok" : "off", `${bundles.length} bundle(s)`),
784
+ ]),
785
+ el("div", { class: "row-editor" }, [select, generate]),
786
+ el("p", { class: "muted", text: String(body.retireNote ?? "") }),
787
+ ];
788
+ if (bundles.length === 0) {
789
+ nodes.push(m3Empty(body, "No evidence bundles generated yet"));
790
+ return nodes;
791
+ }
792
+ const tbody = el("tbody");
793
+ for (const bundle of bundles) {
794
+ tbody.appendChild(
795
+ el("tr", null, [
796
+ el("td", { class: "mono", text: String(bundle.name) }),
797
+ el("td", { text: String(bundle.framework ?? "—") }),
798
+ el("td", { text: String(bundle.period ?? "—") }),
799
+ el("td", {
800
+ class: "muted",
801
+ text: bundle.generatedAt ? fmtRelativeTime(bundle.generatedAt, Date.now()) : "—",
802
+ }),
803
+ ]),
804
+ );
805
+ }
806
+ nodes.push(
807
+ el("div", { class: "table-scroll" }, [
808
+ el("table", { class: "fleet" }, [
809
+ el(
810
+ "thead",
811
+ null,
812
+ el(
813
+ "tr",
814
+ null,
815
+ ["Bundle", "Framework", "Period", "Generated"].map((h) => el("th", { text: h })),
816
+ ),
817
+ ),
818
+ tbody,
819
+ ]),
820
+ ]),
821
+ );
822
+ return nodes;
823
+ }
824
+
825
+ function retentionCard(body, ctx) {
826
+ const pins = Array.isArray(body.pins) ? body.pins : [];
827
+ const windows = Array.isArray(body.auditWindows) ? body.auditWindows : [];
828
+ const nodes = [
829
+ el("div", { class: "rollup" }, [
830
+ body.malformed === true
831
+ ? dot("bad", "retention.json will not parse — enforcement is refused, not guessed")
832
+ : dot(body.fromFile === true ? "ok" : "off", `${pins.length} pinned session(s)`),
833
+ body.sessionMaxAgeDays === null
834
+ ? null
835
+ : el("span", { class: "chip", text: `TTL ${String(body.sessionMaxAgeDays)}d` }),
836
+ ...windows.map((window) =>
837
+ window.active
838
+ ? dot("warn", `${String(window.frameworkId)}/${String(window.controlId)} defers deletion`)
839
+ : dot("off", `${String(window.frameworkId)}/${String(window.controlId)} expired`),
840
+ ),
841
+ ]),
842
+ el("p", { class: "muted", text: String(body.auditChainNote ?? "") }),
843
+ ];
844
+ if (body.fromFile !== true) nodes.push(m3Empty(body, "No retention.json"));
845
+ if (pins.length > 0) {
846
+ nodes.push(
847
+ el(
848
+ "div",
849
+ { class: "chip-group" },
850
+ pins.map((pin) => el("span", { class: "chip mono", text: String(pin) })),
851
+ ),
852
+ );
853
+ }
854
+ nodes.push(retentionRun("sweep", ctx), retentionRun("purge", ctx));
855
+ return nodes;
856
+ }
857
+
858
+ /**
859
+ * Dry-run FIRST, then a typed confirm. Two gestures, never one: the plan is
860
+ * the thing the operator is consenting to, and they cannot consent to a plan
861
+ * they have not seen.
862
+ */
863
+ function retentionRun(action, ctx) {
864
+ const state = el("div", { class: "control-state" });
865
+ const confirm = el("input", {
866
+ class: "input mono",
867
+ type: "text",
868
+ placeholder: "type the harness name",
869
+ "aria-label": `typed confirmation for ${action}`,
870
+ });
871
+ const real = el("button", {
872
+ class: "btn btn-danger",
873
+ type: "button",
874
+ text: `Run ${action} for real`,
875
+ disabled: true,
876
+ });
877
+ const plan = el("button", { class: "btn", type: "button", text: `Plan ${action} (--dry-run)` });
878
+
879
+ const call = (dryRun) =>
880
+ action === "sweep"
881
+ ? api.retentionSweep(
882
+ { id: ctx.id },
883
+ { dryRun, ...(dryRun ? {} : { confirmName: confirm.value }) },
884
+ )
885
+ : api.retentionPurge(
886
+ { id: ctx.id },
887
+ { dryRun, ...(dryRun ? {} : { confirmName: confirm.value }) },
888
+ );
889
+
890
+ plan.addEventListener("click", async () => {
891
+ plan.disabled = true;
892
+ const answer = await m3(call(true));
893
+ plan.disabled = false;
894
+ if (!answer.ok) {
895
+ state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
896
+ return;
897
+ }
898
+ // The real run unlocks only once the plan has been asked for.
899
+ real.disabled = false;
900
+ state.replaceChildren(
901
+ dot("off", `plan queued as job ${String(answer.body.job?.jobId ?? "?")}`),
902
+ el("div", { class: "muted reason", text: String(answer.body.note ?? "") }),
903
+ el("div", { class: "muted reason", text: String(answer.body.pinsNote ?? "") }),
904
+ );
905
+ });
906
+
907
+ real.addEventListener("click", async () => {
908
+ real.disabled = true;
909
+ const answer = await m3(call(false));
910
+ real.disabled = false;
911
+ if (!answer.ok) {
912
+ state.replaceChildren(dot("bad", String(answer.body.error ?? answer.status)));
913
+ return;
914
+ }
915
+ state.replaceChildren(
916
+ dot("warn", `${action} queued as job ${String(answer.body.job?.jobId ?? "?")}`),
917
+ el("div", { class: "muted reason", text: String(answer.body.note ?? "") }),
918
+ );
919
+ });
920
+
921
+ return el("div", { class: "control-action" }, [
922
+ el("div", { class: "control-row" }, [plan, confirm, real]),
923
+ state,
924
+ ]);
925
+ }