@shield-agent/kya 0.34.0 → 0.35.3

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 (181) hide show
  1. package/.cursor-plugin/plugin.json +2 -2
  2. package/.mcp.json +1 -1
  3. package/LIMITATIONS.md +2 -1
  4. package/README.md +37 -655
  5. package/amp/settings.example.json +1 -1
  6. package/assets/report-blocked.png +0 -0
  7. package/assets/report-certify.png +0 -0
  8. package/assets/report-overview.png +0 -0
  9. package/assets/screenshot-features.json +1 -0
  10. package/claude/claude_desktop_config.example.json +1 -1
  11. package/cline/cline_mcp_settings.example.json +1 -1
  12. package/copilot/mcp-config.example.json +1 -1
  13. package/dist/alerts/triage-setup.d.ts +4 -0
  14. package/dist/alerts/triage-setup.d.ts.map +1 -1
  15. package/dist/alerts/triage-setup.js +11 -1
  16. package/dist/alerts/triage-setup.js.map +1 -1
  17. package/dist/card-png.d.ts +3 -0
  18. package/dist/card-png.d.ts.map +1 -0
  19. package/dist/card-png.js +54 -0
  20. package/dist/card-png.js.map +1 -0
  21. package/dist/card.d.ts +62 -0
  22. package/dist/card.d.ts.map +1 -0
  23. package/dist/card.js +154 -0
  24. package/dist/card.js.map +1 -0
  25. package/dist/certify/evaluate.d.ts +11 -1
  26. package/dist/certify/evaluate.d.ts.map +1 -1
  27. package/dist/certify/evaluate.js +8 -1
  28. package/dist/certify/evaluate.js.map +1 -1
  29. package/dist/certify/render.d.ts.map +1 -1
  30. package/dist/certify/render.js +10 -1
  31. package/dist/certify/render.js.map +1 -1
  32. package/dist/cli.d.ts +2 -0
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +223 -52
  35. package/dist/cli.js.map +1 -1
  36. package/dist/commands/connect.d.ts.map +1 -1
  37. package/dist/commands/connect.js +4 -3
  38. package/dist/commands/connect.js.map +1 -1
  39. package/dist/commands/doctor.d.ts.map +1 -1
  40. package/dist/commands/doctor.js +12 -1
  41. package/dist/commands/doctor.js.map +1 -1
  42. package/dist/commands/hook.d.ts.map +1 -1
  43. package/dist/commands/hook.js +94 -8
  44. package/dist/commands/hook.js.map +1 -1
  45. package/dist/commands/init.d.ts +2 -0
  46. package/dist/commands/init.d.ts.map +1 -1
  47. package/dist/commands/init.js +2 -0
  48. package/dist/commands/init.js.map +1 -1
  49. package/dist/commands/receipt.d.ts +19 -0
  50. package/dist/commands/receipt.d.ts.map +1 -1
  51. package/dist/commands/receipt.js +21 -1
  52. package/dist/commands/receipt.js.map +1 -1
  53. package/dist/commands/serve-mcp.d.ts +2 -0
  54. package/dist/commands/serve-mcp.d.ts.map +1 -1
  55. package/dist/commands/serve-mcp.js +2 -0
  56. package/dist/commands/serve-mcp.js.map +1 -1
  57. package/dist/commands/start.d.ts +50 -2
  58. package/dist/commands/start.d.ts.map +1 -1
  59. package/dist/commands/start.js +233 -27
  60. package/dist/commands/start.js.map +1 -1
  61. package/dist/commands/team.d.ts +19 -2
  62. package/dist/commands/team.d.ts.map +1 -1
  63. package/dist/commands/team.js +93 -13
  64. package/dist/commands/team.js.map +1 -1
  65. package/dist/commands/uninstall.d.ts +20 -0
  66. package/dist/commands/uninstall.d.ts.map +1 -0
  67. package/dist/commands/uninstall.js +378 -0
  68. package/dist/commands/uninstall.js.map +1 -0
  69. package/dist/commands/wire-hooks.d.ts +16 -2
  70. package/dist/commands/wire-hooks.d.ts.map +1 -1
  71. package/dist/commands/wire-hooks.js +86 -21
  72. package/dist/commands/wire-hooks.js.map +1 -1
  73. package/dist/cta.d.ts +3 -1
  74. package/dist/cta.d.ts.map +1 -1
  75. package/dist/cta.js +11 -6
  76. package/dist/cta.js.map +1 -1
  77. package/dist/fs-atomic.d.ts.map +1 -1
  78. package/dist/fs-atomic.js +9 -2
  79. package/dist/fs-atomic.js.map +1 -1
  80. package/dist/guard-demo.d.ts +23 -0
  81. package/dist/guard-demo.d.ts.map +1 -0
  82. package/dist/guard-demo.js +34 -0
  83. package/dist/guard-demo.js.map +1 -0
  84. package/dist/guard-entry.d.ts +2 -0
  85. package/dist/guard-entry.d.ts.map +1 -0
  86. package/dist/guard-entry.js +50 -0
  87. package/dist/guard-entry.js.map +1 -0
  88. package/dist/guard-lite.d.ts +27 -0
  89. package/dist/guard-lite.d.ts.map +1 -0
  90. package/dist/guard-lite.js +274 -0
  91. package/dist/guard-lite.js.map +1 -0
  92. package/dist/guard-pack.d.ts +7 -2
  93. package/dist/guard-pack.d.ts.map +1 -1
  94. package/dist/guard-pack.js +202 -4
  95. package/dist/guard-pack.js.map +1 -1
  96. package/dist/hook-health.d.ts +7 -0
  97. package/dist/hook-health.d.ts.map +1 -0
  98. package/dist/hook-health.js +105 -0
  99. package/dist/hook-health.js.map +1 -0
  100. package/dist/manifest.d.ts +106 -0
  101. package/dist/manifest.d.ts.map +1 -0
  102. package/dist/manifest.js +454 -0
  103. package/dist/manifest.js.map +1 -0
  104. package/dist/mcp/http.d.ts +1 -0
  105. package/dist/mcp/http.d.ts.map +1 -1
  106. package/dist/mcp/http.js +1 -0
  107. package/dist/mcp/http.js.map +1 -1
  108. package/dist/mcp/protocol.d.ts +2 -0
  109. package/dist/mcp/protocol.d.ts.map +1 -1
  110. package/dist/mcp/protocol.js +5 -1
  111. package/dist/mcp/protocol.js.map +1 -1
  112. package/dist/mcp/stdio.d.ts +1 -0
  113. package/dist/mcp/stdio.d.ts.map +1 -1
  114. package/dist/mcp/stdio.js +1 -0
  115. package/dist/mcp/stdio.js.map +1 -1
  116. package/dist/never-list-input.d.ts +2 -0
  117. package/dist/never-list-input.d.ts.map +1 -1
  118. package/dist/never-list-input.js +30 -30
  119. package/dist/never-list-input.js.map +1 -1
  120. package/dist/offline-evaluate.d.ts.map +1 -1
  121. package/dist/offline-evaluate.js +2 -1
  122. package/dist/offline-evaluate.js.map +1 -1
  123. package/dist/parse-args.js +1 -1
  124. package/dist/parse-args.js.map +1 -1
  125. package/dist/receipt/changes.d.ts +2 -1
  126. package/dist/receipt/changes.d.ts.map +1 -1
  127. package/dist/receipt/changes.js +9 -0
  128. package/dist/receipt/changes.js.map +1 -1
  129. package/dist/receipt/dashboard.d.ts +14 -1
  130. package/dist/receipt/dashboard.d.ts.map +1 -1
  131. package/dist/receipt/dashboard.js +20 -2
  132. package/dist/receipt/dashboard.js.map +1 -1
  133. package/dist/receipt/live-server.d.ts +15 -0
  134. package/dist/receipt/live-server.d.ts.map +1 -1
  135. package/dist/receipt/live-server.js +71 -0
  136. package/dist/receipt/live-server.js.map +1 -1
  137. package/dist/receipt/receipt-css.d.ts.map +1 -1
  138. package/dist/receipt/receipt-css.js +45 -0
  139. package/dist/receipt/receipt-css.js.map +1 -1
  140. package/dist/receipt/render-receipt.d.ts +14 -0
  141. package/dist/receipt/render-receipt.d.ts.map +1 -1
  142. package/dist/receipt/render-receipt.js +451 -79
  143. package/dist/receipt/render-receipt.js.map +1 -1
  144. package/dist/receipt/search.d.ts.map +1 -1
  145. package/dist/receipt/search.js +2 -0
  146. package/dist/receipt/search.js.map +1 -1
  147. package/dist/receipt/semantic-setup.d.ts +4 -0
  148. package/dist/receipt/semantic-setup.d.ts.map +1 -1
  149. package/dist/receipt/semantic-setup.js +10 -1
  150. package/dist/receipt/semantic-setup.js.map +1 -1
  151. package/dist/receipt/share.d.ts +15 -0
  152. package/dist/receipt/share.d.ts.map +1 -1
  153. package/dist/receipt/share.js +34 -1
  154. package/dist/receipt/share.js.map +1 -1
  155. package/dist/telemetry.d.ts +17 -2
  156. package/dist/telemetry.d.ts.map +1 -1
  157. package/dist/telemetry.js +67 -7
  158. package/dist/telemetry.js.map +1 -1
  159. package/dist/trail.d.ts +39 -0
  160. package/dist/trail.d.ts.map +1 -1
  161. package/dist/trail.js +27 -0
  162. package/dist/trail.js.map +1 -1
  163. package/dist/write-journal.d.ts +39 -0
  164. package/dist/write-journal.d.ts.map +1 -0
  165. package/dist/write-journal.js +107 -0
  166. package/dist/write-journal.js.map +1 -0
  167. package/droid/README.md +1 -1
  168. package/droid/mcp.example.json +1 -1
  169. package/gemini/settings.example.json +1 -1
  170. package/grok/README.md +1 -1
  171. package/kilo/kilo.example.json +1 -1
  172. package/kimi/mcp.example.json +1 -1
  173. package/kiro/mcp.example.json +1 -1
  174. package/manifest.json +2 -2
  175. package/mastracode/mcp.example.json +1 -1
  176. package/mcp.json +1 -1
  177. package/openai/codex.config.example.toml +1 -1
  178. package/opencode/opencode.example.json +1 -1
  179. package/package.json +9 -3
  180. package/qwen/settings.example.json +1 -1
  181. package/server.json +3 -3
@@ -6,7 +6,9 @@ import { semanticInstallCommand, semanticRuntimeInstalled } from "./semantic-sta
6
6
  import { assertNoSecrets, clip, stripEscapes } from "../dash/render.js";
7
7
  import { clipMultiline, DIFF_MAX_TOTAL_CHARS } from "../diff-preview.js";
8
8
  import { DOMAIN_LABELS, domainOrder } from "../certify/render.js";
9
- import { productLabel, eventsSince, readTrail } from "../trail.js";
9
+ import { productLabel, eventsSince, readTrail, countOutcomes, outcomeOf, wouldHoldInStrict } from "../trail.js";
10
+ import { eventIdOf, isNearMiss } from "../card.js";
11
+ import { readCredentials } from "../login/credentials.js";
10
12
  import { mcpServerLabel, parseMcpToolId } from "../mcp-servers.js";
11
13
  import { loadInvestigateLastRun, } from "../investigate/last-run.js";
12
14
  import { readNotifyLog, summarizeNotifyLog, } from "../notify/log.js";
@@ -19,6 +21,7 @@ import { computeDashboard } from "./dashboard.js";
19
21
  import { buildChangesModel } from "./changes.js";
20
22
  import { GATE_NOT_SETUP_QUICKSTART, loadGatePage, toolNameFromToolId, toolServerPrefix, } from "./gate-page.js";
21
23
  import { receiptCss } from "./receipt-css.js";
24
+ import { DEMO_CALLS, PROMISE } from "../guard-demo.js";
22
25
  import { applyChipFilters, rankEvents } from "./search.js";
23
26
  import { emptyOpenshellCard, loadOpenshellCard } from "./openshell-card.js";
24
27
  import { readIocResult } from "../ioc/result.js";
@@ -63,6 +66,11 @@ function mdFence(content) {
63
66
  function mdInline(s) {
64
67
  return stripEscapes(s).replace(/`/g, "'");
65
68
  }
69
+ /**
70
+ * Counts for the report headline. A row with an outcome is counted by what happened to the call (`outcomes`); only a
71
+ * row without one (written before outcomes existed) is counted by its stored verdict (allow, deny, require).
72
+ * `never` is a policy overlay and counts every row.
73
+ */
66
74
  function countVerdicts(events) {
67
75
  let allow = 0;
68
76
  let deny = 0;
@@ -72,6 +80,8 @@ function countVerdicts(events) {
72
80
  const v = e.verdict.toUpperCase();
73
81
  if (e.neverEvent || e.reasonCode === "NEVER_EVENT")
74
82
  never++;
83
+ if (outcomeOf(e))
84
+ continue;
75
85
  if (v === "ALLOW")
76
86
  allow++;
77
87
  else if (v === "DENY")
@@ -79,7 +89,55 @@ function countVerdicts(events) {
79
89
  else if (v === "REQUIRE_APPROVE")
80
90
  require++;
81
91
  }
82
- return { allow, deny, require, never };
92
+ return { allow, deny, require, never, outcomes: countOutcomes(events) };
93
+ }
94
+ const STRICT_NOTE = "would hold in strict mode";
95
+ /** One line under the headline: how to turn strict mode on. Shown only when a call ran that strict mode would hold (STRICT_NOTE is the row note). */
96
+ function strictHowTo(wouldHold, code) {
97
+ return `${wouldHold} ${wouldHold === 1 ? "call that ran" : "calls that ran"} would hold in strict mode. To turn strict mode on, add ${code("--strict")} to the ${code("kya hook")} command in your tool's hook settings.`;
98
+ }
99
+ /** Headline text for the Markdown report: "Ran 11, Blocked 5, Held 0". Error is listed only when it is not 0. */
100
+ function outcomeHeadline(o) {
101
+ return `Ran ${o.ran}, Blocked ${o.blocked}, Held ${o.held}${o.error > 0 ? `, Error ${o.error}` : ""}`;
102
+ }
103
+ const OUTCOME_WORD = { ran: "RAN", blocked: "BLOCKED", held: "HELD", error: "ERROR" };
104
+ /** A short note for a call that ran although policy said otherwise (REQUIRE_APPROVE, or DENY in shadow mode). */
105
+ function ranNote(e) {
106
+ if (wouldHoldInStrict(e))
107
+ return STRICT_NOTE;
108
+ if (outcomeOf(e) === "ran" && e.verdict.toUpperCase() === "DENY")
109
+ return "would block (shadow mode)";
110
+ return undefined;
111
+ }
112
+ /** Feed order: blocked rows (newest first) above everything else (newest first). Rows without an outcome sort as before. */
113
+ function compareFeed(a, b) {
114
+ const rank = (e) => (outcomeOf(e) === "blocked" ? 0 : 1);
115
+ return rank(a) - rank(b) || b.ts.localeCompare(a.ts);
116
+ }
117
+ /** Key for rows that may collapse. Only rows with an outcome collapse; a blocked row, or one with a change preview, never does. */
118
+ function collapseKey(e) {
119
+ const o = outcomeOf(e);
120
+ if (!o || o === "blocked" || e.diffPreview?.trim())
121
+ return undefined;
122
+ return JSON.stringify([e.toolId, o, e.verdict, e.reasonCode, e.summary ?? "", e.targetPath ?? "", e.argsHash ?? "", e.sessionId, e.product ?? "", e.project ?? "", e.mode]);
123
+ }
124
+ /** Merge identical consecutive rows ("shell echo, 10 times"). Input order is kept. */
125
+ export function collapseFeedRows(ordered) {
126
+ const out = [];
127
+ for (const e of ordered) {
128
+ const key = collapseKey(e);
129
+ const last = out[out.length - 1];
130
+ if (last && key !== undefined && last.key === key)
131
+ last.count++;
132
+ else
133
+ out.push({ event: e, count: 1, key });
134
+ }
135
+ return out.map(({ event, count }) => ({ event, count }));
136
+ }
137
+ /** "shell echo (+1 args), 3 times" or just "3 times" when the row has no summary. */
138
+ function repeatText(e, count) {
139
+ const base = e.summary?.trim();
140
+ return `${base ? `${base}, ` : ""}${count} times`;
83
141
  }
84
142
  function relativeTime(ts, nowMs) {
85
143
  const t = Date.parse(ts);
@@ -142,6 +200,21 @@ function verdictWord(verdict) {
142
200
  return "REVIEW";
143
201
  return v;
144
202
  }
203
+ /** Row tone: by outcome when the row has one (a call that ran is ok, even if policy said review), else by verdict. */
204
+ function rowTone(e) {
205
+ switch (outcomeOf(e)) {
206
+ case "ran": return "ok";
207
+ case "blocked": return "bad";
208
+ case "held": return "warn";
209
+ case "error": return "mute";
210
+ default: return tone(e.verdict);
211
+ }
212
+ }
213
+ /** Row label: RAN, BLOCKED, HELD or ERROR when the row has an outcome, else the verdict word as before. */
214
+ function rowWord(e) {
215
+ const o = outcomeOf(e);
216
+ return o ? OUTCOME_WORD[o] : verdictWord(e.verdict);
217
+ }
145
218
  /** Pure rollup of trail events for the report's stat chips and sections. */
146
219
  export function aggregateEvents(events) {
147
220
  const modes = { observe: 0, hold: 0, offline: 0 };
@@ -182,8 +255,15 @@ export function aggregateEvents(events) {
182
255
  s.events++;
183
256
  if (e.neverEvent || e.reasonCode === "NEVER_EVENT")
184
257
  s.never++;
258
+ const o = outcomeOf(e);
185
259
  const v = e.verdict.toUpperCase();
186
- if (v === "DENY")
260
+ if (o) {
261
+ if (o === "blocked")
262
+ s.deny++;
263
+ else if (o === "held")
264
+ s.hold++;
265
+ }
266
+ else if (v === "DENY")
187
267
  s.deny++;
188
268
  else if (v === "REQUIRE_APPROVE")
189
269
  s.hold++;
@@ -275,6 +355,7 @@ export function buildReceiptModel(sessionId, events, extras) {
275
355
  liveToken: extras?.liveToken,
276
356
  searchQuery: extras?.searchQuery,
277
357
  identity: extras?.identity,
358
+ planeConfigured: extras?.planeConfigured,
278
359
  sandboxes: extras?.sandboxes,
279
360
  openshell: extras?.openshell,
280
361
  wiredHosts: extras?.wiredHosts,
@@ -306,6 +387,7 @@ export function buildWindowReceiptModel(events, days, extras) {
306
387
  liveToken: extras?.liveToken,
307
388
  searchQuery: extras?.searchQuery,
308
389
  identity: extras?.identity,
390
+ planeConfigured: extras?.planeConfigured,
309
391
  sandboxes: extras?.sandboxes,
310
392
  openshell: extras?.openshell,
311
393
  wiredHosts: extras?.wiredHosts,
@@ -342,6 +424,7 @@ export function loadReceiptModel(input) {
342
424
  liveToken: input.liveToken,
343
425
  searchQuery: input.searchQuery,
344
426
  identity: loadIdentity(input.cwd),
427
+ planeConfigured: isPlaneConfigured(loadIdentity(input.cwd), env),
345
428
  sandboxes: loadSandboxes(input.cwd),
346
429
  openshell: loadOpenshellCard({ cwd: input.cwd, events: importEvents, env, certify }),
347
430
  wiredHosts: loadWiredHosts(input.cwd),
@@ -379,6 +462,7 @@ function filterDataAttrs(e, sessionId) {
379
462
  const project = e.project?.trim();
380
463
  const server = parseMcpToolId(e.toolId);
381
464
  return (`data-verdict="${esc(e.verdict.toUpperCase())}"` +
465
+ (outcomeOf(e) ? ` data-outcome="${outcomeOf(e)}"` : "") +
382
466
  (never ? ` data-never="1"` : "") +
383
467
  ` data-mode="${esc(e.mode)}"` +
384
468
  ` data-plane="${esc(e.host?.trim() || "unknown")}"` +
@@ -414,6 +498,18 @@ function noMatchHint(query, env = process.env) {
414
498
  return (`${base} Plain-language search is off, so only exact words match. ` +
415
499
  `Turn it on once with <code>${esc(semanticInstallCommand(env))}</code>, then search again.`);
416
500
  }
501
+ /**
502
+ * "Make card" next to a stopped row (G5). Only a denied call gets it. The live report gets a button that calls the local
503
+ * /action/card; a static file has no server, so it shows the CLI command. Never in the deny text the agent reads.
504
+ */
505
+ function cardControl(e, live) {
506
+ if (!isNearMiss(e))
507
+ return "";
508
+ const id = esc(eventIdOf(e));
509
+ return live
510
+ ? `<span class="card-ctl"><button type="button" class="stat" data-card-id="${id}">Make card</button><span class="mute small card-result" role="status" aria-live="polite"></span></span>`
511
+ : `<span class="card-ctl mute small">Make card: <code>kya receipt --card --id ${id}</code></span>`;
512
+ }
417
513
  function renderFeed(events, nowMs, live, preserveOrder, noMatch) {
418
514
  if (events.length === 0) {
419
515
  if (noMatch)
@@ -423,19 +519,34 @@ function renderFeed(events, nowMs, live, preserveOrder, noMatch) {
423
519
  : "No events yet - evaluate a tool call with <code>kya wrap</code>, then regenerate this receipt.";
424
520
  return `<p class="empty">${hint}</p>`;
425
521
  }
426
- const ordered = preserveOrder ? [...events] : [...events].sort((a, b) => b.ts.localeCompare(a.ts));
522
+ const ordered = preserveOrder ? [...events] : [...events].sort(compareFeed);
523
+ // Search ranking keeps its order and never collapses; the plain feed merges identical consecutive rows.
524
+ const rows = preserveOrder ? ordered.map((event) => ({ event, count: 1 })) : collapseFeedRows(ordered);
427
525
  const parts = [];
428
526
  let lastDay = "";
429
- for (const e of ordered) {
430
- const key = localDayKey(e.ts);
431
- if (key !== lastDay) {
432
- lastDay = key;
433
- parts.push(`<div class="day" role="presentation">${esc(dayLabel(key, nowMs))}</div>`);
527
+ let pinnedHeading = false;
528
+ for (const { event: e, count } of rows) {
529
+ // Blocked rows sit above everything else, under one heading, so they never hide below repeated rows.
530
+ const pinned = !preserveOrder && outcomeOf(e) === "blocked";
531
+ if (pinned) {
532
+ if (!pinnedHeading) {
533
+ pinnedHeading = true;
534
+ parts.push(`<div class="day" role="presentation">Blocked</div>`);
535
+ }
434
536
  }
435
- const t = tone(e.verdict);
537
+ else {
538
+ const key = localDayKey(e.ts);
539
+ if (key !== lastDay) {
540
+ lastDay = key;
541
+ parts.push(`<div class="day" role="presentation">${esc(dayLabel(key, nowMs))}</div>`);
542
+ }
543
+ }
544
+ const t = rowTone(e);
436
545
  const never = e.neverEvent || e.reasonCode === "NEVER_EVENT";
437
- const summary = e.summary?.trim()
438
- ? `<div class="summary">${esc(e.summary.trim())}</div>`
546
+ const note = ranNote(e);
547
+ const summaryText = count > 1 ? repeatText(e, count) : e.summary?.trim();
548
+ const summary = summaryText
549
+ ? `<div class="summary">${esc(summaryText)}</div>`
439
550
  : "";
440
551
  const preview = e.diffPreview?.trim()
441
552
  ? `<details class="diff"><summary>Change preview</summary><pre>${esc(e.diffPreview.trim())}</pre></details>`
@@ -450,9 +561,10 @@ function renderFeed(events, nowMs, live, preserveOrder, noMatch) {
450
561
  <div class="rail" aria-hidden="true"><span class="tick"></span></div>
451
562
  <div class="main">
452
563
  <div class="top">
453
- <span class="verdict">${esc(verdictWord(e.verdict))}</span>${modeBadge(e.mode)}
564
+ <span class="verdict">${esc(rowWord(e))}</span>${note ? `<span class="strict-note">${esc(note)}</span>` : ""}${modeBadge(e.mode)}
454
565
  <code class="tool">${esc(e.toolId)}</code>
455
566
  <time class="when" datetime="${esc(e.ts)}" title="${esc(e.ts)}">${esc(clock(e.ts))} <span class="rel">${esc(relativeTime(e.ts, nowMs))}</span></time>
567
+ ${cardControl(e, live)}
456
568
  </div>
457
569
  ${summary}
458
570
  ${preview}
@@ -488,11 +600,11 @@ function changesPanel(changes, nowMs) {
488
600
  .map((f) => {
489
601
  const entries = f.entries
490
602
  .map((e) => {
491
- const t = tone(e.verdict);
603
+ const t = rowTone(e);
492
604
  const dataAttrs = filterDataAttrs(e, s.sessionId);
493
605
  return ` <div class="chg-entry ${t}${e.never ? " never" : ""}" ${dataAttrs}>
494
606
  <div class="top">
495
- <span class="verdict">${esc(verdictWord(e.verdict))}</span>${modeBadge(e.mode)}
607
+ <span class="verdict">${esc(rowWord(e))}</span>${modeBadge(e.mode)}
496
608
  <code class="tool">${esc(e.toolId)}</code>
497
609
  <time class="when" datetime="${esc(e.ts)}" title="${esc(e.ts)}">${esc(clock(e.ts))} <span class="rel">${esc(relativeTime(e.ts, nowMs))}</span></time>
498
610
  </div>
@@ -521,13 +633,41 @@ ${files}
521
633
  })
522
634
  .join("\n");
523
635
  }
524
- function identityLine(identity) {
636
+ /** Default base URL `kya init` writes. It is a placeholder, not a plane that runs. */
637
+ const DEFAULT_PLANE_URL = "http://127.0.0.1:8090";
638
+ /** A control plane counts as configured with an API key, saved hosted login, or a base URL other than the init default. */
639
+ export function isPlaneConfigured(identity, env = process.env) {
640
+ if (env.KYA_API_KEY?.trim() || env.SHIELD_API_KEY?.trim() || env.KYA_BASE_URL?.trim())
641
+ return true;
642
+ if (readCredentials(env))
643
+ return true;
644
+ const base = identity?.baseUrl?.trim().replace(/\/+$/, "");
645
+ return Boolean(base && base !== DEFAULT_PLANE_URL);
646
+ }
647
+ /** Who the report is for. Host and plane address show only when a plane is configured. */
648
+ function identityBits(identity, planeConfigured) {
525
649
  if (!identity)
526
- return "";
650
+ return [];
651
+ // Hidden fields are still checked: a credential in the saved base URL must stop the render, shown or not.
652
+ assertNoSecrets([identity.agentName, identity.agentId, identity.host, identity.baseUrl].filter(Boolean).join(" · "));
527
653
  const nameId = identity.agentName && identity.agentId
528
654
  ? `${identity.agentName}/${identity.agentId}`
529
655
  : (identity.agentName ?? identity.agentId);
530
- const bits = [nameId, identity.host, identity.baseUrl].filter((b) => typeof b === "string" && b.trim().length > 0);
656
+ return [nameId, ...(planeConfigured ? [identity.host, identity.baseUrl] : [])].filter((b) => typeof b === "string" && b.trim().length > 0);
657
+ }
658
+ /**
659
+ * Header block of the live report (F4): the "Share this report" button and the place for its result. The button
660
+ * calls POST /action/share (the same redacted payload as `kya receipt --share`). Static files do not get it:
661
+ * there is no server to answer.
662
+ */
663
+ function shareHeaderBlock() {
664
+ return `<div class="share-row" id="share-report" style="margin-top:0.75rem">
665
+ <button type="button" class="stat" id="share-report-btn">Share this report</button>
666
+ <div id="share-report-result" role="status" aria-live="polite"></div>
667
+ </div>`;
668
+ }
669
+ function identityLine(identity, planeConfigured) {
670
+ const bits = identityBits(identity, planeConfigured);
531
671
  if (bits.length === 0)
532
672
  return "";
533
673
  // baseUrl is inert text here - never an anchor.
@@ -645,7 +785,20 @@ function dashboardPanel(db) {
645
785
  </button>`;
646
786
  // Zero-count rows are hidden, consistent with the header chips.
647
787
  const mixRow = (label, group, value, count, fill) => count > 0 ? barRow(label, group, value, pct(count), `${pct(count)}%`, fill) : "";
648
- const mixCard = `<div class="db-card">
788
+ const om = db.outcomeMix;
789
+ const mixCard = om.recorded > 0
790
+ ? `<div class="db-card">
791
+ <h3>Outcome mix</h3>
792
+ ${mixRow("Ran", "outcome", "ran", om.ran, "ok")}
793
+ ${mixRow("Blocked", "outcome", "blocked", om.blocked, "bad")}
794
+ ${mixRow("Held", "outcome", "held", om.held, "warn")}
795
+ ${mixRow("Error", "outcome", "error", om.error, "warn")}
796
+ ${mixRow("Earlier: Allow", "verdict", "ALLOW", om.legacyMix.allow, "ok")}
797
+ ${mixRow("Earlier: Review", "verdict", "REQUIRE_APPROVE", om.legacyMix.review, "warn")}
798
+ ${mixRow("Earlier: Deny", "verdict", "DENY", om.legacyMix.deny, "bad")}
799
+ ${mixRow("Never", "never", "1", db.verdictMix.never, "bad")}
800
+ </div>`
801
+ : `<div class="db-card">
649
802
  <h3>Verdict mix</h3>
650
803
  ${mixRow("Allow", "verdict", "ALLOW", db.verdictMix.allow, "ok")}
651
804
  ${mixRow("Review", "verdict", "REQUIRE_APPROVE", db.verdictMix.review, "warn")}
@@ -1378,6 +1531,46 @@ function kpiTile(label, value, cls = "") {
1378
1531
  const toned = typeof value === "number" && value > 0 && cls ? ` ${cls}` : "";
1379
1532
  return `<div class="kpi"><span class="kpi-num${toned}">${v}</span><span class="kpi-lab">${label}</span></div>`;
1380
1533
  }
1534
+ /** Rows the hook stopped (outcome "blocked", F3), newest first. Rows with no outcome are not counted. */
1535
+ export function blockedCalls(events) {
1536
+ return events.filter((e) => outcomeOf(e) === "blocked").sort((a, b) => b.ts.localeCompare(a.ts));
1537
+ }
1538
+ const BLOCKED_ROWS_SHOWN = 5;
1539
+ /**
1540
+ * First panel of the report (guard first): how many calls the guard stopped and the last five.
1541
+ * With none blocked it shows the three built-in demo calls, labelled as a demo, never as activity.
1542
+ */
1543
+ function heroBlockedPanel(events, nowMs, windowLabel, live) {
1544
+ const blocked = blockedCalls(events);
1545
+ if (blocked.length === 0) {
1546
+ const demo = DEMO_CALLS.map((c) => ` <li><code>${esc(c.label)}</code><span class="blk-what">${esc(c.what)}</span><span class="pill">demo</span></li>`).join("\n");
1547
+ return `<section class="panel hero-blocked demo" id="frag-hero-blocked" aria-label="Blocked">
1548
+ <h2>Blocked <span class="pill">demo</span></h2>
1549
+ <p class="blk-lead"><span class="blk-num">0</span> calls blocked ${esc(windowLabel)}. Demo: three calls your agent could make, and the guard stops all three.</p>
1550
+ <ul class="blk-rows">
1551
+ ${demo}
1552
+ </ul>
1553
+ <p class="mute small">These rows are samples, not your activity. Real blocked calls replace them here.</p>
1554
+ </section>`;
1555
+ }
1556
+ const rows = blocked
1557
+ .slice(0, BLOCKED_ROWS_SHOWN)
1558
+ .map((e) => {
1559
+ const what = e.summary?.trim() ? esc(clip(e.summary.trim(), 140)) : "";
1560
+ const rule = e.neverRule ? `<span class="mute small">rule <code>${esc(e.neverRule)}</code></span>` : "";
1561
+ return ` <li><code>${esc(e.toolId)}</code><span class="blk-what">${what}</span>${rule}<span class="mute small">${esc(relativeTime(e.ts, nowMs))}</span>${cardControl(e, live)}</li>`;
1562
+ })
1563
+ .join("\n");
1564
+ const more = blocked.length > BLOCKED_ROWS_SHOWN ? `<p class="mute small">Showing the last ${BLOCKED_ROWS_SHOWN} of ${blocked.length}. The Activity tab lists every call.</p>` : "";
1565
+ return `<section class="panel hero-blocked" id="frag-hero-blocked" aria-label="Blocked">
1566
+ <h2>Blocked</h2>
1567
+ <p class="blk-lead"><span class="blk-num bad">${blocked.length}</span> ${blocked.length === 1 ? "call" : "calls"} blocked ${esc(windowLabel)}.</p>
1568
+ <ul class="blk-rows">
1569
+ ${rows}
1570
+ </ul>
1571
+ ${more}
1572
+ </section>`;
1573
+ }
1381
1574
  /**
1382
1575
  * Hero Certify panel: purpose-built rich card for the live Agent Trust
1383
1576
  * Baseline. The section carries the result state (`hero-certify pass|gap|
@@ -1540,6 +1733,23 @@ function verdictsHeroCard(c, total) {
1540
1733
  // raw event count keeps every bar inside the base the Overview card uses.
1541
1734
  const pct = (n) => (total > 0 ? Math.round((n / total) * 100) : 0);
1542
1735
  const mixRow = (label, n, fill) => `<div class="mix-row"><span class="mix-lab">${label}</span><span class="mix-bar"><span class="mix-fill ${fill}" style="width:${pct(n)}%"></span></span><span class="mix-num">${n}</span></div>`;
1736
+ const o = c.outcomes;
1737
+ if (o.recorded > 0) {
1738
+ const row = (label, n, fill) => (n > 0 ? mixRow(label, n, fill) : "");
1739
+ return `<section class="panel" aria-label="Verdicts">
1740
+ <h2>Decisions</h2>
1741
+ <div class="mix">
1742
+ ${mixRow("Ran", o.ran, "ok")}
1743
+ ${mixRow("Blocked", o.blocked, "bad")}
1744
+ ${mixRow("Held", o.held, "warn")}
1745
+ ${row("Error", o.error, "warn")}
1746
+ ${row("Earlier: Allow", c.allow, "ok")}
1747
+ ${row("Earlier: Deny", c.deny, "bad")}
1748
+ ${row("Earlier: Review", c.require, "warn")}
1749
+ ${mixRow("Never", c.never, "bad")}
1750
+ </div>
1751
+ </section>`;
1752
+ }
1543
1753
  return `<section class="panel" aria-label="Verdicts">
1544
1754
  <h2>Decisions</h2>
1545
1755
  <div class="mix">
@@ -1904,7 +2114,7 @@ export function renderFeedPage(events, filters, page, nowMs, live, query, embedd
1904
2114
  total = ranked.length;
1905
2115
  }
1906
2116
  else {
1907
- const sorted = [...events].sort((a, b) => b.ts.localeCompare(a.ts));
2117
+ const sorted = [...events].sort(compareFeed);
1908
2118
  ordered = applyChipFilters(sorted, filters);
1909
2119
  total = ordered.length;
1910
2120
  }
@@ -1948,6 +2158,21 @@ const zoneBody = (parts, empty) => parts.some((p) => p.length > 0)
1948
2158
  : `<p class="mute small zone-empty">${empty}</p>`;
1949
2159
  /** Inner markup of the Activity counts bar (verdict buttons + mode/plane chips). */
1950
2160
  function statCountsInner(c, chips) {
2161
+ const o = c.outcomes;
2162
+ if (o.recorded > 0) {
2163
+ const btn = (label, n, group, value, cls, title) => `<button type="button" class="stat${cls ? ` ${cls}` : ""}" data-fgroup="${group}" data-fvalue="${value}" aria-pressed="false" title="Filter: ${title}">${label}<b>${n}</b></button>`;
2164
+ // Rows from before outcomes existed keep their verdict counts, labelled as earlier rows.
2165
+ const earlier = (label, n, value, cls) => n > 0 ? btn(`Earlier: ${label}`, n, "verdict", value, cls, `${label} (rows with no outcome recorded)`) : "";
2166
+ const howTo = o.wouldHold > 0 ? `\n <span class="strict-howto">${strictHowTo(o.wouldHold, (t) => `<code>${t}</code>`)}</span>` : "";
2167
+ return `${btn("Ran", o.ran, "outcome", "ran", "ok", "Ran")}
2168
+ ${btn("Blocked", o.blocked, "outcome", "blocked", "bad", "Blocked")}
2169
+ ${btn("Held", o.held, "outcome", "held", "warn", "Held")}
2170
+ ${o.error > 0 ? btn("Error", o.error, "outcome", "error", "", "Error") : ""}
2171
+ ${earlier("Allow", c.allow, "ALLOW", "ok")}${earlier("Deny", c.deny, "DENY", "bad")}${earlier("Review", c.require, "REQUIRE_APPROVE", "warn")}
2172
+ <button type="button" class="stat" data-fgroup="never" data-fvalue="1" aria-pressed="false" title="Filter: Never">Never<b>${c.never}</b></button>
2173
+ ${chips.modes}${chips.planes}${howTo}
2174
+ <button type="button" class="stat clear-filters" id="clear-filters" hidden>Clear filters ×</button>`;
2175
+ }
1951
2176
  return `<button type="button" class="stat ok" data-fgroup="verdict" data-fvalue="ALLOW" aria-pressed="false" title="Filter: Allow">Allow<b>${c.allow}</b></button>
1952
2177
  <button type="button" class="stat bad" data-fgroup="verdict" data-fvalue="DENY" aria-pressed="false" title="Filter: Deny">Deny<b>${c.deny}</b></button>
1953
2178
  <button type="button" class="stat warn" data-fgroup="verdict" data-fvalue="REQUIRE_APPROVE" aria-pressed="false" title="Filter: Review">Review<b>${c.require}</b></button>
@@ -1975,7 +2200,7 @@ export function buildLiveFragments(model, livePaged) {
1975
2200
  chipProducts: chips.products,
1976
2201
  chipProjects: chips.projects,
1977
2202
  chipServers: chips.servers,
1978
- heroCertify: heroCertifyPanel(model.certify),
2203
+ heroBlocked: heroBlockedPanel(events, nowMs, `in the ${model.rangeLabel}`, model.live),
1979
2204
  heroTiles: [
1980
2205
  verdictsHeroCard(c, events.length),
1981
2206
  activityHeroCard(agg, dashboard, events.length),
@@ -1983,18 +2208,24 @@ export function buildLiveFragments(model, livePaged) {
1983
2208
  importHeroCard(model.imports, nowMs, livePaged),
1984
2209
  discoveryHeroCard(model.discovery, nowMs, livePaged),
1985
2210
  investigateHeroCard(model.investigate, nowMs, livePaged),
1986
- showbackHeroCard(model.showback, events),
1987
2211
  ].join("\n "),
2212
+ showbackPanel: showbackHeroCard(model.showback, events),
2213
+ connectedBody: zoneBody([wiredHostsPanel(model.wiredHosts)], "No coding agent is connected yet. Run kya connect &lt;host&gt; to add one."),
1988
2214
  overviewPanels: zoneBody([incidentAlertsPanel(model.incidentAlerts, nowMs), dashboardPanel(dashboard), sessionsPanel(agg, nowMs, changes), reasonsPanel(agg)], "No activity in this window yet - analytics appear once events land."),
1989
- certifyBody: zoneBody([model.certify ? certifyDetailPanel(model.certify.requirements) : ""], "No check yet - use your coding tools with kya connected, then run kya certify."),
2215
+ certifyBody: zoneBody([
2216
+ heroCertifyPanel(model.certify),
2217
+ model.certify
2218
+ ? certifyDetailPanel(model.certify.requirements)
2219
+ : `<p class="mute small zone-empty">The full rule table appears here after the first check.</p>`,
2220
+ ], "No check yet - use your coding tools with kya connected, then run kya certify."),
1990
2221
  orrPanel: orrPanel(model.orr, nowMs, livePaged),
1991
2222
  changesList: changesPanel(changes, nowMs),
1992
2223
  },
1993
2224
  nav: {
1994
- overview: events.length,
2225
+ overview: blockedCalls(events).length,
1995
2226
  activity: events.length,
1996
2227
  changes: changes.fileCount,
1997
- certify: model.certify?.gap ?? null,
2228
+ certify: null,
1998
2229
  orr: model.orr ? model.orr.scorecards.fail : null,
1999
2230
  },
2000
2231
  };
@@ -2398,8 +2629,10 @@ export function renderReceiptHtml(model) {
2398
2629
  chipProducts: 'chip-products',
2399
2630
  chipProjects: 'chip-projects',
2400
2631
  chipServers: 'chip-servers',
2401
- heroCertify: 'frag-hero-certify',
2632
+ heroBlocked: 'frag-hero-blocked',
2402
2633
  heroTiles: 'frag-hero-tiles',
2634
+ showbackPanel: 'frag-showback',
2635
+ connectedBody: 'frag-connected',
2403
2636
  overviewPanels: 'frag-overview-panels',
2404
2637
  certifyBody: 'frag-certify',
2405
2638
  orrPanel: 'frag-orr',
@@ -2616,6 +2849,25 @@ export function renderReceiptHtml(model) {
2616
2849
  try { localStorage.setItem('kya-theme', root.getAttribute('data-theme')); } catch(e){}
2617
2850
  });
2618
2851
  })();
2852
+ </script>`;
2853
+ // The "More" menu: closed by default, remembered per browser, and open when the page was reached through
2854
+ // a link to a tab inside it (a hash such as #certify). Without JS or localStorage the page still works.
2855
+ const moreScript = `<script>
2856
+ (function(){
2857
+ var more = document.getElementById('nav-more');
2858
+ if (!more) return;
2859
+ var inside = false;
2860
+ try { inside = !!(location.hash && more.querySelector('a[href="' + location.hash + '"]')); } catch(e){}
2861
+ var stored = null;
2862
+ try { stored = localStorage.getItem('kya-nav-more'); } catch(e){}
2863
+ if (inside || stored === 'open') more.open = true;
2864
+ more.addEventListener('toggle', function(){
2865
+ try { localStorage.setItem('kya-nav-more', more.open ? 'open' : 'closed'); } catch(e){}
2866
+ });
2867
+ window.addEventListener('hashchange', function(){
2868
+ try { if (location.hash && more.querySelector('a[href="' + location.hash + '"]')) more.open = true; } catch(e){}
2869
+ });
2870
+ })();
2619
2871
  </script>`;
2620
2872
  // Gateway interactive controls: playground evaluator, quick actions, and the
2621
2873
  // sidebar stop-report link. Only wired when the page is served by the live
@@ -2775,6 +3027,68 @@ export function renderReceiptHtml(model) {
2775
3027
  });
2776
3028
  });
2777
3029
 
3030
+ // The card control next to a stopped row. Delegated: the Blocked panel is replaced on every live refresh.
3031
+ document.addEventListener('click', function(ev){
3032
+ var b = ev.target && ev.target.closest ? ev.target.closest('[data-card-id]') : null;
3033
+ if (!b) return;
3034
+ var out = b.parentNode.querySelector('.card-result');
3035
+ b.disabled = true;
3036
+ out.textContent = 'Making the card...';
3037
+ post('/action/card', { id: b.getAttribute('data-card-id') }).then(parseJson).then(function(d){
3038
+ out.textContent = d && d.ok ? 'Saved ' + (d.png || d.svg) + '. Caption: ' + d.caption.replace(/\\n/g, ' ') : 'No card: ' + (d && d.error ? d.error : 'unknown error');
3039
+ b.disabled = false;
3040
+ }).catch(function(err){ out.textContent = 'No card: ' + err.message; b.disabled = false; });
3041
+ });
3042
+
3043
+ var shareBtn = document.getElementById('share-report-btn');
3044
+ if (shareBtn) {
3045
+ var shareOut = document.getElementById('share-report-result');
3046
+ var shareFail = function(msg){
3047
+ setResult(shareOut, '<p>Could not share the report: ' + esc(msg) + '. The report still works. Try again when you are online.</p>', true);
3048
+ shareBtn.disabled = false;
3049
+ };
3050
+ var copyText = function(text, field, btn){
3051
+ var done = function(){ btn.textContent = 'Copied'; };
3052
+ if (navigator.clipboard && navigator.clipboard.writeText) {
3053
+ navigator.clipboard.writeText(text).then(done, function(){ field.select(); btn.textContent = 'Press Ctrl+C to copy'; });
3054
+ } else { field.select(); btn.textContent = 'Press Ctrl+C to copy'; }
3055
+ };
3056
+ shareBtn.addEventListener('click', function(){
3057
+ shareBtn.disabled = true;
3058
+ shareOut.textContent = 'Sharing...';
3059
+ post('/action/share', {}).then(parseJson).then(function(d){
3060
+ if (!d || !d.ok) { shareFail(d && d.error ? d.error : 'unknown error'); return; }
3061
+ // Show and open only an http(s) link. share.ts already checked it is on the server that was asked.
3062
+ if (!/^https?:/.test(String(d.url))) { shareFail('the server sent a link that is not a web address'); return; }
3063
+ shareOut.textContent = '';
3064
+ var label = document.createElement('p');
3065
+ label.textContent = 'Public link. Send it to a colleague:';
3066
+ var field = document.createElement('input');
3067
+ field.type = 'text'; field.readOnly = true; field.id = 'share-url'; field.value = d.url; field.size = 44;
3068
+ var copy = document.createElement('button');
3069
+ copy.type = 'button'; copy.className = 'stat'; copy.id = 'share-copy'; copy.textContent = 'Copy link';
3070
+ copy.addEventListener('click', function(){ copyText(d.url, field, copy); });
3071
+ shareOut.appendChild(label); shareOut.appendChild(field); shareOut.appendChild(copy);
3072
+ var claim = document.createElement('p');
3073
+ var a = document.createElement('a');
3074
+ a.id = 'share-claim'; a.href = d.url; a.target = '_blank'; a.rel = 'noopener';
3075
+ a.textContent = 'Open the page to claim it, keep it and get a weekly summary';
3076
+ claim.appendChild(a);
3077
+ shareOut.appendChild(claim);
3078
+ var connect = document.createElement('p');
3079
+ connect.id = 'share-connect';
3080
+ connect.textContent = 'To connect this computer to your account, run kya login.';
3081
+ shareOut.appendChild(connect);
3082
+ if (d.expiresAt) {
3083
+ var exp = document.createElement('p');
3084
+ exp.textContent = 'If nobody claims the report, it is deleted on ' + String(d.expiresAt).slice(0, 10) + '.';
3085
+ shareOut.appendChild(exp);
3086
+ }
3087
+ shareBtn.disabled = false;
3088
+ }).catch(function(err){ shareFail(err.message); });
3089
+ });
3090
+ }
3091
+
2778
3092
  var importForm = document.getElementById('import-form');
2779
3093
  if (importForm) {
2780
3094
  importForm.addEventListener('submit', function(e){
@@ -2840,7 +3154,6 @@ export function renderReceiptHtml(model) {
2840
3154
  postureSectionHtml(model.posture, !!model.liveToken),
2841
3155
  capabilitiesPanel(model),
2842
3156
  gatePanel(model.gate, nowMs),
2843
- wiredHostsPanel(model.wiredHosts),
2844
3157
  sandboxesPanel(model.sandboxes, nowMs),
2845
3158
  openshellPanel(model.openshell, nowMs),
2846
3159
  alertsPanel(model.alerts, nowMs, livePaged),
@@ -2852,7 +3165,7 @@ export function renderReceiptHtml(model) {
2852
3165
  <head>
2853
3166
  <meta charset="utf-8"/>
2854
3167
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
2855
- <title>${esc(model.title)} - ${esc(model.rangeLabel)}</title>
3168
+ <title>${esc(PROMISE)} - ${esc(model.rangeLabel)}</title>
2856
3169
  <style>${receiptCss()}${model.liveToken ? MAP_CSS + MAP_LIVE_ZONE_CSS : ""}</style>
2857
3170
  </head>
2858
3171
  <body>
@@ -2864,20 +3177,25 @@ export function renderReceiptHtml(model) {
2864
3177
  </div>
2865
3178
  </header>
2866
3179
  <nav class="sidebar-nav" aria-label="Report sections">
2867
- ${navItem("#overview", "Overview", events.length)}
3180
+ ${navItem("#overview", "Blocked", blockedCalls(events).length)}
2868
3181
  ${navItem("#activity", "Activity", events.length)}
2869
- ${navItem("#changes", "Changes", changes.fileCount)}
2870
- ${navItem("#certify", "Certify", model.certify?.gap ?? 0)}
2871
- ${navItem("#orr", "ORR", model.orr ? model.orr.scorecards.fail : undefined)}
2872
- ${model.liveToken ? navItem("#map", "Map", undefined) : ""}
2873
- ${navGroup("Gateway", model.gate?.state === "running")}
2874
- ${navSubItem("#gateway-home", "Home", model.gate?.servers.length ?? 0)}
2875
- ${navSubItem("#gateway-listeners", "Listeners", model.gate?.listeners?.length ?? 0)}
2876
- ${navSubItem("#gateway-routes", "Routes", model.gate?.routes?.length ?? 0)}
2877
- ${navSubItem("#gateway-backends", "Backends", model.gate?.servers.length ?? 0)}
2878
- ${navSubItem("#gateway-policies", "Policies", model.gate?.policySummary?.totalPolicies ?? 0)}
2879
- ${navSubItem("#gateway-playground", "Playground", model.gate?.playgroundSamples?.length ?? 0)}
2880
- ${navItem("#system", "System", undefined)}
3182
+ ${navItem("#connected", "Connected tools", undefined)}
3183
+ <details class="nav-more" id="nav-more">
3184
+ <summary class="nav-more-summary">More</summary>
3185
+ ${navItem("#changes", "Changes", changes.fileCount)}
3186
+ ${navItem("#certify", "Certify", undefined)}
3187
+ ${navItem("#orr", "ORR", model.orr ? model.orr.scorecards.fail : undefined)}
3188
+ ${model.liveToken ? navItem("#map", "Map", undefined) : ""}
3189
+ ${model.showback ? navItem("#showback", "Showback", undefined) : ""}
3190
+ ${navGroup("Gateway", model.gate?.state === "running")}
3191
+ ${navSubItem("#gateway-home", "Home", model.gate?.servers.length ?? 0)}
3192
+ ${navSubItem("#gateway-listeners", "Listeners", model.gate?.listeners?.length ?? 0)}
3193
+ ${navSubItem("#gateway-routes", "Routes", model.gate?.routes?.length ?? 0)}
3194
+ ${navSubItem("#gateway-backends", "Backends", model.gate?.servers.length ?? 0)}
3195
+ ${navSubItem("#gateway-policies", "Policies", model.gate?.policySummary?.totalPolicies ?? 0)}
3196
+ ${navSubItem("#gateway-playground", "Playground", model.gate?.playgroundSamples?.length ?? 0)}
3197
+ ${navItem("#system", "System", undefined)}
3198
+ </details>
2881
3199
  </nav>
2882
3200
  <footer class="sidebar-footer">
2883
3201
  <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">Theme</button>
@@ -2889,16 +3207,17 @@ export function renderReceiptHtml(model) {
2889
3207
  ${model.liveToken ? '<main data-live-token="1">' : '<main>'}
2890
3208
  <header class="page-header">
2891
3209
  <div class="title-row">
2892
- <h1>${esc(model.title)}</h1>
3210
+ <h1>${esc(PROMISE)}</h1>
2893
3211
  <div class="range">${esc(model.rangeLabel)}${livePill}</div>
2894
3212
  </div>
2895
- ${identityLine(model.identity)}
3213
+ ${identityLine(model.identity, model.planeConfigured)}
3214
+ ${model.liveToken ? shareHeaderBlock() : ""}
2896
3215
  </header>
2897
3216
  ${blockedBanner}
2898
3217
  ${iocBanner}
2899
3218
  <section class="zone" id="overview" aria-labelledby="tab-overview">
2900
3219
  <div class="hero">
2901
- ${fr.fragments.heroCertify}
3220
+ ${fr.fragments.heroBlocked}
2902
3221
  <div class="hero-tiles" id="frag-hero-tiles">
2903
3222
  ${fr.fragments.heroTiles}
2904
3223
  </div>
@@ -2924,6 +3243,9 @@ ${initialFeed.feedHtml}
2924
3243
  ${initialFeed.paginationHtml}
2925
3244
  </div>
2926
3245
  </section>
3246
+ <section class="zone" id="connected" aria-labelledby="tab-connected">
3247
+ <div id="frag-connected">${fr.fragments.connectedBody}</div>
3248
+ </section>
2927
3249
  <section class="zone" id="changes" aria-labelledby="tab-changes">
2928
3250
  <section class="feed chg" id="changes-list" aria-label="Changes by session and file">
2929
3251
  ${fr.fragments.changesList}
@@ -2936,6 +3258,7 @@ ${fr.fragments.changesList}
2936
3258
  <div id="frag-orr">${fr.fragments.orrPanel}</div>
2937
3259
  </section>
2938
3260
  ${model.liveToken ? '<section class="zone" id="map" aria-labelledby="tab-map"><div id="frag-map"><p class="mute">Loading the map.</p></div></section>' : ""}
3261
+ ${model.showback ? `<section class="zone" id="showback" aria-labelledby="tab-showback"><div id="frag-showback">${fr.fragments.showbackPanel}</div></section>` : ""}
2939
3262
  ${gatePagePanel(model.gate, nowMs)}
2940
3263
  <section class="zone" id="system" aria-labelledby="tab-system">
2941
3264
  ${systemBody}
@@ -2945,6 +3268,7 @@ ${filterScript}
2945
3268
  ${model.liveToken ? liveMapScripts() : ""}
2946
3269
  ${staticSearchScript}
2947
3270
  ${themeScript}
3271
+ ${moreScript}
2948
3272
  ${gateScript}
2949
3273
  </body>
2950
3274
  </html>`;
@@ -2952,28 +3276,83 @@ ${gateScript}
2952
3276
  assertNoSecrets(html);
2953
3277
  return html;
2954
3278
  }
3279
+ /** "## Blocked" section: the count and the last five blocked calls, or the demo when none was blocked. */
3280
+ function blockedMarkdown(events, nowMs) {
3281
+ const blocked = blockedCalls(events);
3282
+ if (blocked.length === 0) {
3283
+ return [
3284
+ "## Blocked (demo)",
3285
+ "",
3286
+ "0 calls blocked in this window. Demo: three calls your agent could make, and the guard stops all three. These rows are samples, not your activity.",
3287
+ "",
3288
+ ...DEMO_CALLS.map((c) => `- \`${mdInline(c.label)}\` - ${mdText(c.what)} (demo)`),
3289
+ "",
3290
+ ];
3291
+ }
3292
+ return [
3293
+ "## Blocked",
3294
+ "",
3295
+ `${blocked.length} ${blocked.length === 1 ? "call" : "calls"} blocked in this window. Last ${Math.min(5, blocked.length)}:`,
3296
+ "",
3297
+ ...blocked.slice(0, 5).map((e) => {
3298
+ const sum = e.summary?.trim() ? ` - ${mdText(clip(e.summary.trim(), 140))}` : "";
3299
+ return `- \`${mdInline(e.toolId)}\`${sum} - ${mdText(relativeTime(e.ts, nowMs))}`;
3300
+ }),
3301
+ "",
3302
+ ];
3303
+ }
2955
3304
  export function renderReceiptMarkdown(model) {
2956
3305
  const c = countVerdicts(model.events);
2957
3306
  const agg = aggregateEvents(model.events);
2958
3307
  const nowMs = Date.now();
3308
+ const o = c.outcomes;
2959
3309
  const lines = [
2960
- `# ${mdText(model.title)}`,
3310
+ `# ${mdText(PROMISE)}`,
2961
3311
  "",
2962
3312
  mdText(model.rangeLabel),
2963
3313
  "",
2964
- `Allow ${c.allow} | Deny ${c.deny} | Review ${c.require} | Never ${c.never}`,
3314
+ ...blockedMarkdown(model.events, nowMs),
3315
+ ];
3316
+ if (o.recorded > 0) {
3317
+ // Counted by what happened to each call. Rows from before outcomes existed keep their verdict counts, on a second line.
3318
+ lines.push(`${outcomeHeadline(o)} | Never ${c.never}`, "");
3319
+ if (o.wouldHold > 0)
3320
+ lines.push(strictHowTo(o.wouldHold, (t) => `\`${t}\``), "");
3321
+ if (o.legacy > 0) {
3322
+ lines.push(`Earlier calls (no outcome recorded): Allow ${c.allow} | Deny ${c.deny} | Review ${c.require}`, "");
3323
+ }
3324
+ }
3325
+ else {
3326
+ lines.push(`Allow ${c.allow} | Deny ${c.deny} | Review ${c.require} | Never ${c.never}`, "");
3327
+ }
3328
+ // Feed rows. With outcomes recorded the feed leads (blocked rows first); without, it stays where it was.
3329
+ const feedLines = [
3330
+ "## Feed",
3331
+ ...collapseFeedRows(o.recorded > 0 ? [...model.events].sort(compareFeed) : [...model.events].sort((a, b) => b.ts.localeCompare(a.ts)))
3332
+ .flatMap(({ event: e, count }) => {
3333
+ const text = count > 1 ? repeatText(e, count) : e.summary?.trim();
3334
+ const sum = text ? ` - ${mdText(text)}` : "";
3335
+ const note = ranNote(e);
3336
+ const head = `- **${mdText(rowWord(e))}**${note ? ` (${note})` : ""} \`${mdInline(e.toolId)}\`${sum} - ${productLabel(e.product)} - ${mdText(e.reasonCode)}`;
3337
+ if (!e.diffPreview?.trim())
3338
+ return [head];
3339
+ // Fenced content is verbatim (stripEscapes only - backslash escapes
3340
+ // are literal in code fences); the dynamically sized tilde fence
3341
+ // prevents breakout and backticks cannot close a tilde fence.
3342
+ const diff = stripEscapes(e.diffPreview.trim());
3343
+ const fence = mdFence(diff);
3344
+ return [head, "", fence, diff, fence, ""];
3345
+ }),
2965
3346
  "",
2966
3347
  ];
3348
+ if (o.recorded > 0)
3349
+ lines.push(...feedLines);
2967
3350
  if (model.ioc) {
2968
3351
  const k = model.ioc.findings.length;
2969
3352
  lines.push(mdText(`IoC check (advisory): ${k === 0 ? "no findings" : `${k} finding${k === 1 ? "" : "s"}`}, list ${model.ioc.listVersion}, checked ${model.ioc.checkedAt}.`), "");
2970
3353
  }
2971
3354
  if (model.identity) {
2972
- const id = model.identity;
2973
- const nameId = id.agentName && id.agentId
2974
- ? `${id.agentName}/${id.agentId}`
2975
- : (id.agentName ?? id.agentId);
2976
- const bits = [nameId, id.host, id.baseUrl].filter((b) => typeof b === "string" && b.trim().length > 0);
3355
+ const bits = identityBits(model.identity, model.planeConfigured);
2977
3356
  if (bits.length > 0) {
2978
3357
  lines.push(mdText(bits.join(" · ")), "");
2979
3358
  }
@@ -2988,7 +3367,7 @@ export function renderReceiptMarkdown(model) {
2988
3367
  agg.planes.runtime > 0 ? `Runtime ${agg.planes.runtime}` : "",
2989
3368
  ].filter(Boolean);
2990
3369
  if (modeBits.length > 0 || planeBits.length > 0) {
2991
- lines.push(`Modes: ${modeBits.join(" | ") || "-"} · Planes: ${planeBits.join(" | ") || "-"}`, "");
3370
+ lines.push(`Modes: ${modeBits.join(" | ") || "-"} · Source: ${planeBits.join(" | ") || "-"}`, "");
2992
3371
  }
2993
3372
  if (agg.products.length >= 2) {
2994
3373
  lines.push("## Products", ...agg.products.map((p) => `- ${mdText(p.label)} × ${p.count}`), "");
@@ -2996,23 +3375,16 @@ export function renderReceiptMarkdown(model) {
2996
3375
  if (agg.projects.length >= 2) {
2997
3376
  lines.push("## Projects", ...agg.projects.map((p) => `- ${mdText(p.label)} × ${p.count}`), "");
2998
3377
  }
2999
- if (model.certify) {
3000
- const c = model.certify;
3001
- lines.push("## Certify", `result: ${c.result} · counts: ${c.pass} pass · ${c.gap} gap · ${c.insufficientEvidence} insufficient · ${c.attested} attested`);
3002
- if (c.topGaps.length > 0) {
3003
- lines.push("Top gaps:");
3004
- for (const g of c.topGaps) {
3005
- lines.push(`- \`${mdInline(g.id)}\` - ${mdText(g.title)} (${mdText(g.severity)})`);
3006
- }
3007
- }
3008
- lines.push(`checked live - last ${c.windowDays} days, ${c.trailEvents} recorded events · the Certify tab lists every rule · kya certify writes the gap report and signed proof file`, "");
3009
- }
3010
3378
  const db = computeDashboard(model.events);
3011
3379
  if (db.verdictMix.total > 0) {
3012
3380
  const pct = (n) => Math.round((n / db.verdictMix.total) * 100);
3013
3381
  // never is a reason overlay: the Never share is computed from neverEvent
3014
3382
  // rows on top of the same 100% = total events base as the verdict buckets.
3015
- lines.push("## Analytics", "", `Verdict mix: Allow ${pct(db.verdictMix.allow)}% · Review ${pct(db.verdictMix.review)}% · Deny ${pct(db.verdictMix.deny)}% · Never ${pct(db.verdictMix.never)}%`, "");
3383
+ const om = db.outcomeMix;
3384
+ const mixLine = om.recorded > 0
3385
+ ? `Outcome mix: Ran ${pct(om.ran)}% · Blocked ${pct(om.blocked)}% · Held ${pct(om.held)}%${om.error > 0 ? ` · Error ${pct(om.error)}%` : ""}${om.legacy > 0 ? ` · Earlier (no outcome): Allow ${pct(om.legacyMix.allow)}% · Review ${pct(om.legacyMix.review)}% · Deny ${pct(om.legacyMix.deny)}%` : ""} · Never ${pct(db.verdictMix.never)}%`
3386
+ : `Verdict mix: Allow ${pct(db.verdictMix.allow)}% · Review ${pct(db.verdictMix.review)}% · Deny ${pct(db.verdictMix.deny)}% · Never ${pct(db.verdictMix.never)}%`;
3387
+ lines.push("## Analytics", "", mixLine, "");
3016
3388
  if (db.activity.buckets.length > 0) {
3017
3389
  const max = Math.max(1, ...db.activity.buckets.map((b) => b.count));
3018
3390
  lines.push(`Activity (${db.activity.granularity === "hour" ? "hourly" : "daily"}):`);
@@ -3040,20 +3412,8 @@ export function renderReceiptMarkdown(model) {
3040
3412
  lines.push("");
3041
3413
  }
3042
3414
  }
3043
- lines.push("## Feed", ...[...model.events]
3044
- .sort((a, b) => b.ts.localeCompare(a.ts))
3045
- .flatMap((e) => {
3046
- const sum = e.summary?.trim() ? ` - ${mdText(e.summary.trim())}` : "";
3047
- const head = `- **${mdText(verdictWord(e.verdict))}** \`${mdInline(e.toolId)}\`${sum} - ${productLabel(e.product)} - ${mdText(e.reasonCode)}`;
3048
- if (!e.diffPreview?.trim())
3049
- return [head];
3050
- // Fenced content is verbatim (stripEscapes only - backslash escapes
3051
- // are literal in code fences); the dynamically sized tilde fence
3052
- // prevents breakout and backticks cannot close a tilde fence.
3053
- const diff = stripEscapes(e.diffPreview.trim());
3054
- const fence = mdFence(diff);
3055
- return [head, "", fence, diff, fence, ""];
3056
- }), "");
3415
+ if (o.recorded === 0)
3416
+ lines.push(...feedLines);
3057
3417
  // Same normalization as the HTML path: targetPath flows into single-line
3058
3418
  // `#### ` code-span headers, so embedded newlines must be flattened first.
3059
3419
  const changes = buildChangesModel(normalizeTrailEvents(model.events));
@@ -3068,7 +3428,7 @@ export function renderReceiptMarkdown(model) {
3068
3428
  // fence one longer than the longest run inside.
3069
3429
  const diff = stripEscapes(e.preview);
3070
3430
  const fence = mdFence(diff);
3071
- lines.push(`- **${mdText(verdictWord(e.verdict))}** \`${mdInline(e.toolId)}\` - ${mdText(e.ts)}`, "", fence, diff, fence, "");
3431
+ lines.push(`- **${mdText(rowWord(e))}** \`${mdInline(e.toolId)}\` - ${mdText(e.ts)}`, "", fence, diff, fence, "");
3072
3432
  }
3073
3433
  }
3074
3434
  }
@@ -3174,6 +3534,18 @@ export function renderReceiptMarkdown(model) {
3174
3534
  const ot = model.otel;
3175
3535
  lines.push("## Export to your monitoring (OTel)", `${ot.endpoint ? `\`${mdInline(ot.endpoint)}\` · ` : ""}${ot.spansSent} sent · ${ot.spansFailed} failed${ot.lastExportAt ? ` · last export ${relativeTime(ot.lastExportAt, nowMs)}` : ""}`, "");
3176
3536
  }
3537
+ // Certify last: no gap count reaches the top of the report.
3538
+ if (model.certify) {
3539
+ const c = model.certify;
3540
+ lines.push("## Certify", `result: ${c.result} · counts: ${c.pass} pass · ${c.gap} gap · ${c.insufficientEvidence} insufficient · ${c.attested} attested`);
3541
+ if (c.topGaps.length > 0) {
3542
+ lines.push("Top gaps:");
3543
+ for (const g of c.topGaps) {
3544
+ lines.push(`- \`${mdInline(g.id)}\` - ${mdText(g.title)} (${mdText(g.severity)})`);
3545
+ }
3546
+ }
3547
+ lines.push(`checked live - last ${c.windowDays} days, ${c.trailEvents} recorded events · the Certify tab lists every rule · kya certify writes the gap report and signed proof file`, "");
3548
+ }
3177
3549
  const md = `${lines.join("\n")}\n`;
3178
3550
  assertNoSecrets(md);
3179
3551
  return md;