@shield-agent/kya 0.14.0 → 0.16.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 (60) hide show
  1. package/.mcp.json +1 -1
  2. package/README.md +6 -4
  3. package/amp/settings.example.json +1 -1
  4. package/claude/claude_desktop_config.example.json +1 -1
  5. package/cline/cline_mcp_settings.example.json +1 -1
  6. package/copilot/mcp-config.example.json +1 -1
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +4 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/commands/start.d.ts +44 -0
  11. package/dist/commands/start.d.ts.map +1 -1
  12. package/dist/commands/start.js +171 -2
  13. package/dist/commands/start.js.map +1 -1
  14. package/dist/gate/config.d.ts +2 -0
  15. package/dist/gate/config.d.ts.map +1 -1
  16. package/dist/gate/config.js +9 -0
  17. package/dist/gate/config.js.map +1 -1
  18. package/dist/gate/discover.d.ts +57 -0
  19. package/dist/gate/discover.d.ts.map +1 -0
  20. package/dist/gate/discover.js +380 -0
  21. package/dist/gate/discover.js.map +1 -0
  22. package/dist/receipt/enrich.d.ts +1 -0
  23. package/dist/receipt/enrich.d.ts.map +1 -1
  24. package/dist/receipt/enrich.js +1 -0
  25. package/dist/receipt/enrich.js.map +1 -1
  26. package/dist/receipt/gate-card.d.ts +4 -0
  27. package/dist/receipt/gate-card.d.ts.map +1 -1
  28. package/dist/receipt/gate-card.js +3 -1
  29. package/dist/receipt/gate-card.js.map +1 -1
  30. package/dist/receipt/gate-page.d.ts +47 -0
  31. package/dist/receipt/gate-page.d.ts.map +1 -0
  32. package/dist/receipt/gate-page.js +116 -0
  33. package/dist/receipt/gate-page.js.map +1 -0
  34. package/dist/receipt/live-server.d.ts +8 -0
  35. package/dist/receipt/live-server.d.ts.map +1 -1
  36. package/dist/receipt/live-server.js +134 -1
  37. package/dist/receipt/live-server.js.map +1 -1
  38. package/dist/receipt/receipt-css.d.ts +6 -5
  39. package/dist/receipt/receipt-css.d.ts.map +1 -1
  40. package/dist/receipt/receipt-css.js +255 -55
  41. package/dist/receipt/receipt-css.js.map +1 -1
  42. package/dist/receipt/render-receipt.d.ts +2 -2
  43. package/dist/receipt/render-receipt.d.ts.map +1 -1
  44. package/dist/receipt/render-receipt.js +420 -55
  45. package/dist/receipt/render-receipt.js.map +1 -1
  46. package/droid/README.md +1 -1
  47. package/droid/mcp.example.json +1 -1
  48. package/gemini/settings.example.json +1 -1
  49. package/grok/README.md +1 -1
  50. package/kilo/kilo.example.json +1 -1
  51. package/kimi/mcp.example.json +1 -1
  52. package/kiro/mcp.example.json +1 -1
  53. package/manifest.json +1 -1
  54. package/mastracode/mcp.example.json +1 -1
  55. package/mcp.json +1 -1
  56. package/openai/codex.config.example.toml +1 -1
  57. package/opencode/opencode.example.json +1 -1
  58. package/package.json +1 -1
  59. package/qwen/settings.example.json +1 -1
  60. package/server.json +2 -2
@@ -10,7 +10,7 @@ import { loadCertifyCard, loadIdentity, loadOrrCard, loadSandboxes, loadShowback
10
10
  import { SHOWBACK_DISCLAIMER, } from "../showback/cost-per-task.js";
11
11
  import { computeDashboard } from "./dashboard.js";
12
12
  import { buildChangesModel } from "./changes.js";
13
- import { loadGateCard } from "./gate-card.js";
13
+ import { GATE_NOT_SETUP_QUICKSTART, loadGatePage, LOOPBACK_RULE, toolNameFromToolId, toolServerPrefix, } from "./gate-page.js";
14
14
  import { receiptCss } from "./receipt-css.js";
15
15
  function esc(s) {
16
16
  return stripEscapes(s)
@@ -249,7 +249,7 @@ export function loadReceiptModel(input) {
249
249
  identity: loadIdentity(input.cwd),
250
250
  sandboxes: loadSandboxes(input.cwd),
251
251
  wiredHosts: loadWiredHosts(input.cwd),
252
- gate: loadGateCard(events),
252
+ gate: loadGatePage(events),
253
253
  orr: loadOrrCard(input.cwd),
254
254
  certify: loadCertifyCard(input.cwd),
255
255
  showback,
@@ -265,6 +265,25 @@ export function loadReceiptModel(input) {
265
265
  }
266
266
  return buildWindowReceiptModel(events, days, extras);
267
267
  }
268
+ /**
269
+ * Build the data-* filter facet attribute string shared by feed articles and
270
+ * change entries. `sessionId` is passed explicitly because change entries roll
271
+ * up under their session heading.
272
+ */
273
+ function filterDataAttrs(e, sessionId) {
274
+ const never = e.neverEvent || e.reasonCode === "NEVER_EVENT";
275
+ const project = e.project?.trim();
276
+ const server = parseMcpToolId(e.toolId);
277
+ return (`data-verdict="${esc(e.verdict.toUpperCase())}"` +
278
+ (never ? ` data-never="1"` : "") +
279
+ ` data-mode="${esc(e.mode)}"` +
280
+ ` data-plane="${esc(e.host?.trim() || "unknown")}"` +
281
+ ` data-product="${esc(e.product ?? "other")}"` +
282
+ ` data-tool="${esc(clip(e.toolId, 60))}"` +
283
+ ` data-session="${esc(sessionId)}"` +
284
+ (server ? ` data-server="${esc(mcpServerLabel(server.server))}"` : "") +
285
+ (project ? ` data-project="${esc(project)}"` : ""));
286
+ }
268
287
  function modeBadge(mode) {
269
288
  const letter = mode === "observe" ? "O" : mode === "hold" ? "H" : mode === "offline" ? "X" : "";
270
289
  if (!letter)
@@ -309,17 +328,7 @@ function renderFeed(events, nowMs, live) {
309
328
  // omitted when there is none. data-tool is clipped to 60 chars and must
310
329
  // match the tool filter values stamped on dashboard rows. data-server is
311
330
  // the recognized MCP server label, omitted for non-MCP or unknown servers.
312
- const project = e.project?.trim();
313
- const server = parseMcpToolId(e.toolId);
314
- const dataAttrs = `data-verdict="${esc(e.verdict.toUpperCase())}"` +
315
- (never ? ` data-never="1"` : "") +
316
- ` data-mode="${esc(e.mode)}"` +
317
- ` data-plane="${esc(e.host?.trim() || "unknown")}"` +
318
- ` data-product="${esc(e.product ?? "other")}"` +
319
- ` data-tool="${esc(clip(e.toolId, 60))}"` +
320
- ` data-session="${esc(e.sessionId || "unknown")}"` +
321
- (server ? ` data-server="${esc(mcpServerLabel(server.server))}"` : "") +
322
- (project ? ` data-project="${esc(project)}"` : "");
331
+ const dataAttrs = filterDataAttrs(e, e.sessionId || "unknown");
323
332
  parts.push(`<article class="ev ${t}${never ? " never" : ""}" ${dataAttrs}>
324
333
  <div class="rail" aria-hidden="true"><span class="tick"></span></div>
325
334
  <div class="main">
@@ -363,17 +372,7 @@ function changesPanel(changes, nowMs) {
363
372
  const entries = f.entries
364
373
  .map((e) => {
365
374
  const t = tone(e.verdict);
366
- const project = e.project?.trim();
367
- const server = parseMcpToolId(e.toolId);
368
- const dataAttrs = `data-verdict="${esc(e.verdict.toUpperCase())}"` +
369
- (e.never ? ` data-never="1"` : "") +
370
- ` data-mode="${esc(e.mode)}"` +
371
- ` data-plane="${esc(e.host?.trim() || "unknown")}"` +
372
- ` data-product="${esc(e.product ?? "other")}"` +
373
- ` data-tool="${esc(clip(e.toolId, 60))}"` +
374
- ` data-session="${esc(s.sessionId)}"` +
375
- (server ? ` data-server="${esc(mcpServerLabel(server.server))}"` : "") +
376
- (project ? ` data-project="${esc(project)}"` : "");
375
+ const dataAttrs = filterDataAttrs(e, s.sessionId);
377
376
  return ` <div class="chg-entry ${t}${e.never ? " never" : ""}" ${dataAttrs}>
378
377
  <div class="top">
379
378
  <span class="verdict">${esc(verdictWord(e.verdict))}</span>${modeBadge(e.mode)}
@@ -417,6 +416,14 @@ function identityLine(identity) {
417
416
  // baseUrl is inert text here — never an anchor.
418
417
  return `<div class="identity">${esc(clip(bits.join(" · "), 160))}</div>`;
419
418
  }
419
+ function navItem(href, label, count, runningDot) {
420
+ const countSpan = count !== undefined ? `<span class="nav-count">${count}</span>` : "";
421
+ const dot = runningDot ? '<span class="status-dot" aria-hidden="true"></span>' : "";
422
+ return `<a class="nav-link" id="tab-${href.slice(1)}" href="${href}">
423
+ <span class="nav-label">${dot}${esc(label)}</span>
424
+ ${countSpan}
425
+ </a>`;
426
+ }
420
427
  function statChips(agg) {
421
428
  // Chips are filter toggles: native buttons keep identical styling via .stat.
422
429
  const chip = (label, n, group, value, cls = "") => n > 0
@@ -607,11 +614,11 @@ function gatePanel(card, nowMs) {
607
614
  <div class="orrline">
608
615
  <span class="pill">not set up</span>
609
616
  </div>
610
- <p class="line">Gateway not set up — <code>kya gate init</code> to put a governed gateway in front of any MCP server: every tool call proxied, evaluated, and on this trail.</p>
617
+ <p class="line">Gateway not set up — <code>kya start</code> puts a governed gateway in front of any MCP server automatically: every tool call proxied, evaluated, and on this trail.</p>
611
618
  <ol class="steps">
612
- <li><code>kya gate init</code> — scaffold .kya/gateways.json with ready-to-move recipes</li>
613
- <li><code>kya gate setup</code> — install the pinned gateway binary</li>
614
- <li><code>kya gate run</code> — start the listener, then <code>kya connect &lt;host&gt; --gate</code></li>
619
+ <li>Add any MCP server to a host config (or <code>kya gate init</code> to pick from recipes)</li>
620
+ <li><code>kya start</code> — imports it, installs the gateway, and routes the host through it (originals backed up under .kya/backups)</li>
621
+ <li><code>kya gate doctor</code> — binary, config, listener health, loopback-only posture</li>
615
622
  </ol>
616
623
  </section>`;
617
624
  }
@@ -662,6 +669,207 @@ ${rows}
662
669
  <p class="mute small">${card.events} gateway event${card.events === 1 ? "" : "s"} in this window · <code>kya gate stop</code> stops everything</p>
663
670
  </section>`;
664
671
  }
672
+ /**
673
+ * Full Gateway dashboard for the #gateway zone. Extends the compact System-tab
674
+ * panel with stat cards, listener details, targets table, policy cards, a dry-run
675
+ * playground, quick actions, and a gateway-native events table. All user-controlled
676
+ * values (server ids, commands, urls, tool names) are escaped.
677
+ */
678
+ function gatePagePanel(page, nowMs) {
679
+ if (!page) {
680
+ return `<section class="zone" id="gateway" aria-labelledby="tab-gateway">
681
+ <p class="mute small zone-empty">Gateway details will appear here after setup.</p>
682
+ </section>`;
683
+ }
684
+ if (page.state === "not-set-up") {
685
+ return `<section class="zone gate-zone" id="gateway" aria-labelledby="tab-gateway">
686
+ <h2>Gateway</h2>
687
+ <p class="sub">Local MCP gate — every proxied tool call is evaluated and audited.</p>
688
+ <div class="card">
689
+ <span class="pill">not set up</span>
690
+ <p class="line">${esc(GATE_NOT_SETUP_QUICKSTART)}</p>
691
+ <div class="quick-actions">
692
+ <button type="button" class="stat" data-action="doctor" id="gate-action-doctor">Doctor</button>
693
+ </div>
694
+ <div id="gate-doctor-result"></div>
695
+ </div>
696
+ </section>`;
697
+ }
698
+ const denyCount = page.gateEvents.filter((e) => e.verdict.toUpperCase() === "DENY" || e.neverEvent || e.reasonCode === "NEVER_EVENT").length;
699
+ const listenerStatus = page.state === "running"
700
+ ? `<span class="pill ok">running</span>`
701
+ : `<span class="pill">stopped</span>`;
702
+ const bindBadge = page.bindScope.loopbackOnly
703
+ ? `<span class="pill ok" title="${esc(page.bindScope.detail)}">loopback-only</span>`
704
+ : `<span class="pill warn" title="${esc(page.bindScope.detail)}">not loopback-only</span>`;
705
+ const statCards = `<div class="stat-cards">
706
+ <div class="stat-card">
707
+ <span class="lab">Listener</span>
708
+ <span class="val">${listenerStatus} ${bindBadge}</span>
709
+ </div>
710
+ <div class="stat-card">
711
+ <span class="lab">Targets</span>
712
+ <span class="num">${page.servers.length}</span>
713
+ </div>
714
+ <div class="stat-card">
715
+ <span class="lab">Events</span>
716
+ <span class="num">${page.gateEvents.length}</span>
717
+ </div>
718
+ <div class="stat-card">
719
+ <span class="lab">Denies</span>
720
+ <span class="num${denyCount > 0 ? " bad" : ""}">${denyCount}</span>
721
+ </div>
722
+ </div>`;
723
+ const uptime = page.startedAt
724
+ ? `<p class="line">Up since <time datetime="${esc(page.startedAt)}">${esc(relativeTime(page.startedAt, nowMs))}</time></p>`
725
+ : "";
726
+ const binaryLine = page.binaryPresent
727
+ ? `<p class="line">Binary <code>${page.binaryVersion ? esc(clip(page.binaryVersion, 40)) : "installed"}</code> · <code>${esc(page.binaryPath)}</code></p>`
728
+ : `<p class="line">Binary missing — <code>kya gate setup</code></p>`;
729
+ const listenerUrl = page.url
730
+ ? `<p class="line">Listener <code>${esc(clip(page.url, 80))}</code> · port ${page.port ?? page.otlpPort}</p>`
731
+ : `<p class="line">Listener port ${page.port ?? 3930} (stopped)</p>`;
732
+ const listenerCard = `<div class="card">
733
+ <h3>Listener</h3>
734
+ ${listenerUrl}
735
+ ${uptime}
736
+ ${binaryLine}
737
+ <p class="line">OTLP receiver port ${page.otlpPort} · failure mode ${esc(page.failureMode)}</p>
738
+ </div>`;
739
+ const targetsTable = (() => {
740
+ if (page.servers.length === 0) {
741
+ return `<div class="card">
742
+ <h3>Targets</h3>
743
+ <p class="empty small">No servers configured — add one to .kya/gateways.json or run <code>kya gate init</code>.</p>
744
+ </div>`;
745
+ }
746
+ const rows = page.servers
747
+ .map((s) => {
748
+ const transport = s.transport === "stdio"
749
+ ? `<code title="${esc(s.cmd?.join(" ") ?? "")}">${esc(clip(s.cmd?.join(" ") ?? s.transport, 50))}</code>`
750
+ : `<code title="${esc(s.url ?? "")}">${esc(clip(s.url ?? s.transport, 50))}</code>`;
751
+ const provenance = s.importedFrom
752
+ .map((h) => `<span class="chip">${esc(h)}</span>`)
753
+ .join("");
754
+ const policy = `<span class="pill">${esc(s.policy.defaultTier ?? "unknown")}</span> · ${s.policy.denyPatterns.length} pattern${s.policy.denyPatterns.length === 1 ? "" : "s"}`;
755
+ const dot = s.worst === "none"
756
+ ? ""
757
+ : `<span class="wdot ${s.worst}" title="worst verdict: ${s.worst}"></span>`;
758
+ const denied = s.deniedTools.length === 0
759
+ ? '<span class="mute">—</span>'
760
+ : s.deniedTools.map((t) => `<span class="chip bad">${esc(t)}</span>`).join("");
761
+ return ` <tr data-server="${esc(s.serverFacet)}">
762
+ <td><span class="wdot ${s.worst === "none" ? "mute" : s.worst}"></span> <code>${esc(s.id)}</code></td>
763
+ <td>${transport}</td>
764
+ <td>${provenance}</td>
765
+ <td>${policy}</td>
766
+ <td>${dot} ${s.events}</td>
767
+ <td>${denied}</td>
768
+ </tr>`;
769
+ })
770
+ .join("\n");
771
+ return `<div class="card">
772
+ <h3>Targets</h3>
773
+ <table class="tbl targets-table">
774
+ <thead><tr><th>Server</th><th>Transport</th><th>Provenance</th><th>Policy</th><th>Events</th><th>Denied tools</th></tr></thead>
775
+ <tbody>
776
+ ${rows}
777
+ </tbody>
778
+ </table>
779
+ </div>`;
780
+ })();
781
+ const policiesCard = `<div class="card">
782
+ <h3>Policies</h3>
783
+ <p class="sub">Network rule</p>
784
+ <pre><code>${esc(LOOPBACK_RULE)}</code></pre>
785
+ <p class="sub">Per-server deny patterns</p>
786
+ ${page.servers.length === 0 ? '<p class="mute small">No servers configured.</p>' : page.servers
787
+ .map((s) => `<details><summary><code>${esc(s.id)}</code> · ${s.policy.denyPatterns.length} pattern${s.policy.denyPatterns.length === 1 ? "" : "s"}</summary><pre><code>${esc(s.policy.denyPatterns.join("\n"))}</code></pre></details>`)
788
+ .join("\n")}
789
+ <p class="mute small">READ and WRITE tools are allowed; WRITE calls are observed/audited. Only destructive/admin patterns are denied.</p>
790
+ </div>`;
791
+ const serverOptions = page.servers
792
+ .map((s) => `<option value="${esc(s.id)}">${esc(s.id)}</option>`)
793
+ .join("");
794
+ const playgroundCard = `<div class="card">
795
+ <h3>Playground</h3>
796
+ <p class="mute small">Dry-run — no tool is executed.</p>
797
+ <form class="playground-form" id="gate-play-form">
798
+ <label class="sr-only" for="gate-play-server">Server</label>
799
+ <select id="gate-play-server" name="server" required>
800
+ <option value="" disabled selected>Server</option>
801
+ ${serverOptions}
802
+ </select>
803
+ <label class="sr-only" for="gate-play-tool">Tool</label>
804
+ <input id="gate-play-tool" name="tool" type="text" placeholder="tool_name" required />
805
+ <button type="submit" class="stat">Evaluate</button>
806
+ </form>
807
+ <div id="gate-play-result"></div>
808
+ </div>`;
809
+ const quickActions = (() => {
810
+ if (page.state === "configured-stopped") {
811
+ return `<button type="button" class="stat ok" data-action="start" id="gate-action-start">Start</button>
812
+ <button type="button" class="stat" data-action="doctor" id="gate-action-doctor">Doctor</button>`;
813
+ }
814
+ return `<button type="button" class="stat bad" data-action="stop" id="gate-action-stop">Stop</button>
815
+ <button type="button" class="stat warn" data-action="restart" id="gate-action-restart">Restart</button>
816
+ <button type="button" class="stat" data-action="doctor" id="gate-action-doctor">Doctor</button>`;
817
+ })();
818
+ const eventsTable = (() => {
819
+ if (page.gateEvents.length === 0) {
820
+ return `<div class="card">
821
+ <h3>Gateway events</h3>
822
+ <p class="empty small">No gateway events in this window.</p>
823
+ </div>`;
824
+ }
825
+ const rows = [...page.gateEvents]
826
+ .sort((a, b) => b.ts.localeCompare(a.ts))
827
+ .slice(0, 25)
828
+ .map((e) => {
829
+ const server = toolServerPrefix(e.toolId) ?? "";
830
+ const tool = toolNameFromToolId(e.toolId) ?? e.toolId;
831
+ const t = tone(e.verdict);
832
+ const never = e.neverEvent || e.reasonCode === "NEVER_EVENT";
833
+ return ` <tr>
834
+ <td><time datetime="${esc(e.ts)}" title="${esc(e.ts)}">${esc(clock(e.ts))} <span class="rel">${esc(relativeTime(e.ts, nowMs))}</span></time></td>
835
+ <td><a href="?f=server:${esc(mcpServerLabel(server))}#activity">${esc(server)}</a></td>
836
+ <td><code>${esc(tool)}</code></td>
837
+ <td><span class="pill ${t}${never ? " orr-red" : ""}">${esc(verdictWord(e.verdict))}${never ? " · never" : ""}</span></td>
838
+ </tr>`;
839
+ })
840
+ .join("\n");
841
+ return `<div class="card">
842
+ <h3>Gateway events <span class="mute">latest 25</span></h3>
843
+ <table class="tbl">
844
+ <thead><tr><th>Time</th><th>Server</th><th>Tool</th><th>Verdict</th></tr></thead>
845
+ <tbody>
846
+ ${rows}
847
+ </tbody>
848
+ </table>
849
+ </div>`;
850
+ })();
851
+ return `<section class="zone gate-zone" id="gateway" aria-labelledby="tab-gateway">
852
+ <h2>Gateway</h2>
853
+ <p class="sub">Local MCP gate — every proxied tool call is evaluated and audited.</p>
854
+ ${statCards}
855
+ <div class="card-grid">
856
+ ${listenerCard}
857
+ ${policiesCard}
858
+ </div>
859
+ ${targetsTable}
860
+ <div class="card-grid">
861
+ ${playgroundCard}
862
+ <div class="card">
863
+ <h3>Quick actions</h3>
864
+ <div class="quick-actions">
865
+ ${quickActions}
866
+ </div>
867
+ <div id="gate-doctor-result"></div>
868
+ </div>
869
+ </div>
870
+ ${eventsTable}
871
+ </section>`;
872
+ }
665
873
  function sandboxesPanel(card, nowMs) {
666
874
  if (!card)
667
875
  return "";
@@ -936,7 +1144,7 @@ function gateHeroCard(card) {
936
1144
  ? `${card.url ? `<code>${esc(clip(card.url, 48))}</code> · ` : ""}${card.servers.length} server${card.servers.length === 1 ? "" : "s"} · ${card.events} event${card.events === 1 ? "" : "s"}`
937
1145
  : card.state === "configured-stopped"
938
1146
  ? `${card.servers.length} server${card.servers.length === 1 ? "" : "s"} configured — <code>kya gate run</code>`
939
- : `<code>kya gate init</code> to govern any MCP server`;
1147
+ : `add an MCP server to a host config — <code>kya start</code> governs it`;
940
1148
  const pill = card.state === "running"
941
1149
  ? `<span class="pill ok">running</span>`
942
1150
  : `<span class="pill">${card.state === "configured-stopped" ? "stopped" : "not set up"}</span>`;
@@ -990,7 +1198,7 @@ export function renderReceiptHtml(model) {
990
1198
  </footer>`;
991
1199
  // liveToken is minted base64url by the live server — safe in a JS string
992
1200
  // literal; static renders omit it. No post-hoc html.replace (spoofable).
993
- const eventsUrl = model.liveToken ? `/events?t=${model.liveToken}` : "/events";
1201
+ const eventsUrl = model.liveToken ? `/events?t=${encodeURIComponent(model.liveToken)}` : "/events";
994
1202
  const liveScript = model.live
995
1203
  ? `<script>
996
1204
  (function(){
@@ -1131,6 +1339,148 @@ export function renderReceiptHtml(model) {
1131
1339
  })();
1132
1340
  </script>`;
1133
1341
  const livePill = model.live ? `<span class="live" title="Watching trail.jsonl">Live</span>` : "";
1342
+ const themeScript = `<script>
1343
+ (function(){
1344
+ var root = document.documentElement;
1345
+ var btn = document.getElementById('theme-toggle');
1346
+ function apply(theme){ root.setAttribute('data-theme', theme); }
1347
+ var explicit = root.getAttribute('data-theme');
1348
+ var stored = null;
1349
+ try { stored = localStorage.getItem('kya-theme'); } catch(e){}
1350
+ if (explicit) {
1351
+ // honor an explicit theme set on the static artifact (e.g. screenshots)
1352
+ } else if (stored) apply(stored);
1353
+ else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) apply('light');
1354
+ else apply('dark');
1355
+ if (btn) btn.addEventListener('click', function(){
1356
+ apply(root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark');
1357
+ try { localStorage.setItem('kya-theme', root.getAttribute('data-theme')); } catch(e){}
1358
+ });
1359
+ })();
1360
+ </script>`;
1361
+ // Gateway interactive controls: playground evaluator, quick actions, and the
1362
+ // sidebar stop-report link. Only wired when the page is served by the live
1363
+ // loopback server (token present). Token is read from the query string so it
1364
+ // matches the same origin as the SSE endpoint; all user-controlled output is
1365
+ // escaped before DOM insertion.
1366
+ const gateScript = model.liveToken
1367
+ ? `<script>
1368
+ (function(){
1369
+ var T = '';
1370
+ var s = location.search;
1371
+ if (s.indexOf('?') === 0) {
1372
+ var params = s.slice(1).split('&');
1373
+ for (var i = 0; i < params.length; i++) {
1374
+ if (params[i].indexOf('t=') === 0) {
1375
+ try { T = decodeURIComponent(params[i].slice(2)); } catch(e){ T = params[i].slice(2); }
1376
+ break;
1377
+ }
1378
+ }
1379
+ }
1380
+ if (!T) return;
1381
+ function esc(str){
1382
+ return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1383
+ }
1384
+ function setResult(el, html, isError){
1385
+ if (!el) return;
1386
+ el.innerHTML = '<div class="gate-result' + (isError ? ' error' : '') + '">' + html + '</div>';
1387
+ }
1388
+ function post(path, body){
1389
+ return fetch(path + '?t=' + encodeURIComponent(T), {
1390
+ method: 'POST',
1391
+ headers: { 'Content-Type': 'application/json' },
1392
+ body: JSON.stringify(body)
1393
+ });
1394
+ }
1395
+ function parseJson(res){ return res.json(); }
1396
+
1397
+ var form = document.getElementById('gate-play-form');
1398
+ if (form) {
1399
+ form.addEventListener('submit', function(e){
1400
+ e.preventDefault();
1401
+ var server = form.elements.server.value.trim();
1402
+ var tool = form.elements.tool.value.trim();
1403
+ var resultEl = document.getElementById('gate-play-result');
1404
+ var btn = form.querySelector('button[type="submit"]');
1405
+ if (btn) btn.disabled = true;
1406
+ setResult(resultEl, 'Evaluating...', false);
1407
+ post('/gate/playground', { server: server, tool: tool })
1408
+ .then(parseJson)
1409
+ .then(function(data){
1410
+ if (data && data.ok) {
1411
+ var pill = '<span class="pill ' + (data.verdict === 'allow' ? 'ok' : 'bad') + '">' + esc(data.verdict.toUpperCase()) + '</span>';
1412
+ setResult(resultEl, pill + '<p>' + esc(data.reason) + '</p>', false);
1413
+ } else {
1414
+ setResult(resultEl, '<p>Error: ' + esc(data && data.error ? data.error : 'unknown') + '</p>', true);
1415
+ }
1416
+ })
1417
+ .catch(function(err){
1418
+ setResult(resultEl, '<p>Error: ' + esc(err.message) + '</p>', true);
1419
+ })
1420
+ .finally(function(){
1421
+ if (btn) btn.disabled = false;
1422
+ });
1423
+ });
1424
+ }
1425
+
1426
+ var gateway = document.getElementById('gateway');
1427
+ if (gateway) {
1428
+ gateway.querySelectorAll('[data-action]').forEach(function(btn){
1429
+ btn.addEventListener('click', function(){
1430
+ var action = btn.getAttribute('data-action');
1431
+ var resultEl = document.getElementById('gate-doctor-result');
1432
+ btn.disabled = true;
1433
+ if (action === 'doctor') setResult(resultEl, 'Running doctor...', false);
1434
+ post('/gate/action', { action: action })
1435
+ .then(parseJson)
1436
+ .then(function(data){
1437
+ if (data && data.ok) {
1438
+ if (action === 'doctor') {
1439
+ setResult(resultEl, '<pre>' + esc(JSON.stringify(data.report, null, 2)) + '</pre>', false);
1440
+ btn.disabled = false;
1441
+ } else {
1442
+ setTimeout(function(){ location.reload(); }, 800);
1443
+ }
1444
+ } else {
1445
+ setResult(resultEl, '<p>Error: ' + esc(data && data.error ? data.error : 'unknown') + '</p>', true);
1446
+ btn.disabled = false;
1447
+ }
1448
+ })
1449
+ .catch(function(err){
1450
+ setResult(resultEl, '<p>Error: ' + esc(err.message) + '</p>', true);
1451
+ btn.disabled = false;
1452
+ });
1453
+ });
1454
+ });
1455
+ }
1456
+
1457
+ var stopLink = document.getElementById('stop-report');
1458
+ if (stopLink) {
1459
+ stopLink.addEventListener('click', function(e){
1460
+ e.preventDefault();
1461
+ stopLink.textContent = 'Stopping...';
1462
+ post('/stop', {})
1463
+ .then(parseJson)
1464
+ .then(function(data){
1465
+ if (data && data.ok) {
1466
+ stopLink.textContent = 'Report stopped';
1467
+ stopLink.setAttribute('aria-disabled', 'true');
1468
+ stopLink.style.pointerEvents = 'none';
1469
+ stopLink.style.opacity = '0.6';
1470
+ } else {
1471
+ stopLink.textContent = 'Stop failed';
1472
+ window.alert('Failed to stop report: ' + esc(data && data.error ? data.error : 'unknown'));
1473
+ }
1474
+ })
1475
+ .catch(function(err){
1476
+ stopLink.textContent = 'Stop failed';
1477
+ window.alert('Failed to stop report: ' + esc(err.message));
1478
+ });
1479
+ });
1480
+ }
1481
+ })();
1482
+ </script>`
1483
+ : "";
1134
1484
  // Zones never render as bare whitespace: with no panels they get a hint.
1135
1485
  const zoneBody = (parts, empty) => parts.some((p) => p.length > 0)
1136
1486
  ? parts.filter(Boolean).join("\n ")
@@ -1153,23 +1503,35 @@ export function renderReceiptHtml(model) {
1153
1503
  <style>${receiptCss()}</style>
1154
1504
  </head>
1155
1505
  <body>
1506
+ <aside class="sidebar">
1507
+ <header class="sidebar-header">
1508
+ <div class="brand">
1509
+ <span class="mark" aria-hidden="true">k</span>
1510
+ <span class="wordmark">kya</span>
1511
+ </div>
1512
+ </header>
1513
+ <nav class="sidebar-nav" aria-label="Report sections">
1514
+ ${navItem("#overview", "Overview", events.length)}
1515
+ ${navItem("#activity", "Activity", events.length)}
1516
+ ${navItem("#changes", "Changes", changes.fileCount)}
1517
+ ${navItem("#certify", "Certify", model.certify?.gap ?? 0)}
1518
+ ${navItem("#gateway", "Gateway", model.gate?.servers.length ?? 0, model.gate?.state === "running")}
1519
+ ${navItem("#system", "System", undefined)}
1520
+ </nav>
1521
+ <footer class="sidebar-footer">
1522
+ <button type="button" class="theme-toggle" id="theme-toggle" aria-label="Toggle theme">Theme</button>
1523
+ <!-- Stop-report action is wired by the live-server POST handler. -->
1524
+ <a href="#" id="stop-report">Stop report</a>
1525
+ </footer>
1526
+ </aside>
1156
1527
  <main>
1157
- <div class="topstick">
1158
- <header class="bar">
1159
- <div class="title-row">
1160
- <h1>${esc(model.title)}</h1>
1161
- <div class="range">${esc(model.rangeLabel)}${livePill}</div>
1162
- </div>
1163
- ${identityLine(model.identity)}
1164
- </header>
1165
- <nav class="tabs" aria-label="Report sections">
1166
- <a class="tab" id="tab-overview" href="#overview">Overview</a>
1167
- <a class="tab" id="tab-changes" href="#changes">Changes</a>
1168
- <a class="tab" id="tab-certify" href="#certify">Certify</a>
1169
- <a class="tab" id="tab-activity" href="#activity">Activity</a>
1170
- <a class="tab" id="tab-system" href="#system">System</a>
1171
- </nav>
1172
- </div>
1528
+ <header class="page-header">
1529
+ <div class="title-row">
1530
+ <h1>${esc(model.title)}</h1>
1531
+ <div class="range">${esc(model.rangeLabel)}${livePill}</div>
1532
+ </div>
1533
+ ${identityLine(model.identity)}
1534
+ </header>
1173
1535
  ${blockedBanner}
1174
1536
  <div class="hero">
1175
1537
  ${heroCertifyPanel(model.certify)}
@@ -1181,14 +1543,6 @@ export function renderReceiptHtml(model) {
1181
1543
  <section class="zone" id="overview" aria-labelledby="tab-overview">
1182
1544
  ${overviewBody}
1183
1545
  </section>
1184
- <section class="zone" id="changes" aria-labelledby="tab-changes">
1185
- <section class="feed chg" id="changes-list" aria-label="Changes by session and file">
1186
- ${changesPanel(changes, nowMs)}
1187
- </section>
1188
- </section>
1189
- <section class="zone" id="certify" aria-labelledby="tab-certify">
1190
- ${certifyBody}
1191
- </section>
1192
1546
  <section class="zone" id="activity" aria-labelledby="tab-activity">
1193
1547
  <div class="feed-head">
1194
1548
  <div class="stats" aria-label="Counts">
@@ -1207,11 +1561,22 @@ ${changesPanel(changes, nowMs)}
1207
1561
  ${renderFeed(events, nowMs, model.live)}
1208
1562
  </section>
1209
1563
  </section>
1564
+ <section class="zone" id="changes" aria-labelledby="tab-changes">
1565
+ <section class="feed chg" id="changes-list" aria-label="Changes by session and file">
1566
+ ${changesPanel(changes, nowMs)}
1567
+ </section>
1568
+ </section>
1569
+ <section class="zone" id="certify" aria-labelledby="tab-certify">
1570
+ ${certifyBody}
1571
+ </section>
1572
+ ${gatePagePanel(model.gate, nowMs)}
1210
1573
  <section class="zone" id="system" aria-labelledby="tab-system">
1211
1574
  ${systemBody}
1212
1575
  </section>
1213
1576
  </main>
1214
1577
  ${filterScript}
1578
+ ${themeScript}
1579
+ ${gateScript}
1215
1580
  ${liveScript}
1216
1581
  </body>
1217
1582
  </html>`;
@@ -1346,7 +1711,7 @@ export function renderReceiptMarkdown(model) {
1346
1711
  const g = model.gate;
1347
1712
  lines.push("## Gateway");
1348
1713
  if (g.state === "not-set-up") {
1349
- lines.push("not set up — `kya gate init` to put a governed gateway in front of any MCP server, then `kya gate setup` + `kya gate run`");
1714
+ lines.push(`not set up — ${GATE_NOT_SETUP_QUICKSTART}`);
1350
1715
  }
1351
1716
  else {
1352
1717
  const binary = g.binaryPresent