agentgate-runtime-control 2.13.8

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 (75) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/LICENSE +21 -0
  3. package/README.md +534 -0
  4. package/SECURITY.md +19 -0
  5. package/bin/agentgate.js +303 -0
  6. package/docs/case-study-technical-validation.md +25 -0
  7. package/docs/case-study-template.md +37 -0
  8. package/docs/data-protection.md +49 -0
  9. package/docs/design-partner-checklist.md +32 -0
  10. package/docs/design-partner-kit.md +51 -0
  11. package/docs/design-partner-rollout.md +35 -0
  12. package/docs/design-partner.md +67 -0
  13. package/docs/external-security-review-test-pack.md +132 -0
  14. package/docs/external-security-review.md +33 -0
  15. package/docs/incident-response.md +54 -0
  16. package/docs/integration-matrix.md +17 -0
  17. package/docs/managed-postgres-acceptance-test.md +138 -0
  18. package/docs/marketing-plan.md +33 -0
  19. package/docs/observability-alerting.md +44 -0
  20. package/docs/outreach.md +26 -0
  21. package/docs/partner-intake-template.md +26 -0
  22. package/docs/performance-baseline.md +23 -0
  23. package/docs/performance.md +27 -0
  24. package/docs/pricing.md +53 -0
  25. package/docs/production-deployment.md +70 -0
  26. package/docs/production-quickstart.md +58 -0
  27. package/docs/production-readiness.md +29 -0
  28. package/docs/quickstart.md +115 -0
  29. package/docs/release-checklist.md +33 -0
  30. package/docs/security-hardening-release-report.md +69 -0
  31. package/docs/threat-model.md +47 -0
  32. package/docs/website-copy.md +44 -0
  33. package/examples/basic.mjs +14 -0
  34. package/examples/control-plane.mjs +17 -0
  35. package/examples/design-partner-refund.mjs +33 -0
  36. package/examples/design-partner-shadow.mjs +27 -0
  37. package/examples/mcp-gateway.mjs +26 -0
  38. package/examples/policy-bundle.mjs +18 -0
  39. package/examples/refund-agent.mjs +20 -0
  40. package/examples/runtime.mjs +12 -0
  41. package/package.json +49 -0
  42. package/schema/postgres.sql +17 -0
  43. package/src/admin-rbac.js +3 -0
  44. package/src/agentgate.js +85 -0
  45. package/src/approval.js +30 -0
  46. package/src/attack-lab.js +94 -0
  47. package/src/auth.js +27 -0
  48. package/src/behavior.js +146 -0
  49. package/src/control-plane.js +215 -0
  50. package/src/egress-guard.js +132 -0
  51. package/src/event-bus.js +10 -0
  52. package/src/identity.js +109 -0
  53. package/src/index.js +44 -0
  54. package/src/local-experience.js +46 -0
  55. package/src/mcp-gateway.js +383 -0
  56. package/src/mcp-scanner.js +45 -0
  57. package/src/middleware.js +17 -0
  58. package/src/multi-tenant.js +29 -0
  59. package/src/observability.js +395 -0
  60. package/src/oidc.js +38 -0
  61. package/src/persistent-store.js +56 -0
  62. package/src/policy-builder.js +74 -0
  63. package/src/policy-bundles.js +17 -0
  64. package/src/policy-engine.js +67 -0
  65. package/src/policy-packs.js +115 -0
  66. package/src/policy-registry.js +58 -0
  67. package/src/postgres-adapter.js +76 -0
  68. package/src/runtime.js +138 -0
  69. package/src/saas.js +67 -0
  70. package/src/security-report.js +42 -0
  71. package/src/security-validation.js +92 -0
  72. package/src/shadow-mode.js +47 -0
  73. package/src/telemetry.js +28 -0
  74. package/src/webhook-delivery.js +70 -0
  75. package/standalone.html +86 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Deterministic shadow-mode analysis for design partners.
3
+ * Compares AgentGate's proposed decision with the action that actually occurred.
4
+ */
5
+ export function recordShadowEvent({ decision, executed, request = {}, runId = null, actualError = null } = {}) {
6
+ const proposed = String(decision || 'UNKNOWN').toUpperCase();
7
+ const actual = executed ? 'EXECUTED' : (actualError ? 'ERROR' : 'NOT_EXECUTED');
8
+ const mismatch = (proposed === 'BLOCK' && executed) || (proposed === 'ALLOW' && !executed && !actualError);
9
+ return {
10
+ runId,
11
+ timestamp: new Date().toISOString(),
12
+ proposedDecision: proposed,
13
+ actualOutcome: actual,
14
+ mismatch,
15
+ request,
16
+ actualError: actualError ? String(actualError.message || actualError) : null
17
+ };
18
+ }
19
+
20
+ export function analyzeShadowEvents(events = []) {
21
+ const list = Array.isArray(events) ? events : [];
22
+ const counts = { ALLOW: 0, ASK: 0, BLOCK: 0, UNKNOWN: 0 };
23
+ let wouldBlockExecuted = 0;
24
+ let wouldAskExecuted = 0;
25
+ let wouldAllowNotExecuted = 0;
26
+ let errors = 0;
27
+ for (const event of list) {
28
+ const decision = counts[event.proposedDecision] !== undefined ? event.proposedDecision : 'UNKNOWN';
29
+ counts[decision] += 1;
30
+ if (event.proposedDecision === 'BLOCK' && event.actualOutcome === 'EXECUTED') wouldBlockExecuted += 1;
31
+ if (event.proposedDecision === 'ASK' && event.actualOutcome === 'EXECUTED') wouldAskExecuted += 1;
32
+ if (event.proposedDecision === 'ALLOW' && event.actualOutcome === 'NOT_EXECUTED') wouldAllowNotExecuted += 1;
33
+ if (event.actualOutcome === 'ERROR') errors += 1;
34
+ }
35
+ const total = list.length;
36
+ return {
37
+ total,
38
+ decisions: counts,
39
+ wouldBlockExecuted,
40
+ wouldAskExecuted,
41
+ wouldAllowNotExecuted,
42
+ errors,
43
+ mismatchCount: wouldBlockExecuted + wouldAskExecuted + wouldAllowNotExecuted,
44
+ mismatchRate: total ? (wouldBlockExecuted + wouldAskExecuted + wouldAllowNotExecuted) / total : 0,
45
+ safeForEnforcement: wouldBlockExecuted === 0 && wouldAskExecuted === 0
46
+ };
47
+ }
@@ -0,0 +1,28 @@
1
+ export class RuntimeTelemetry {
2
+ constructor() {
3
+ this.startedAt = Date.now();
4
+ this.counters = new Map();
5
+ this.latency = new Map();
6
+ }
7
+ increment(name, value = 1) { this.counters.set(name, (this.counters.get(name) || 0) + value); }
8
+ observe(name, milliseconds) { const item = this.latency.get(name) || { count: 0, totalMs: 0, maxMs: 0 }; item.count += 1; item.totalMs += Number(milliseconds) || 0; item.maxMs = Math.max(item.maxMs, Number(milliseconds) || 0); this.latency.set(name, item); }
9
+ snapshot() {
10
+ const counters = Object.fromEntries(this.counters);
11
+ const latency = Object.fromEntries([...this.latency].map(([k,v]) => [k, {...v, avgMs: v.count ? Number((v.totalMs / v.count).toFixed(3)) : 0}]));
12
+ return { uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), counters, latency, timestamp: new Date().toISOString() };
13
+ }
14
+ }
15
+ export const createTelemetry = () => new RuntimeTelemetry();
16
+
17
+ export class SlidingWindowLimiter {
18
+ constructor({ limit = 120, windowMs = 60_000 } = {}) { this.limit = limit; this.windowMs = windowMs; this.buckets = new Map(); }
19
+ check(key = 'global') {
20
+ const now = Date.now();
21
+ const bucket = (this.buckets.get(key) || []).filter(t => now - t < this.windowMs);
22
+ const allowed = bucket.length < this.limit;
23
+ if (allowed) bucket.push(now);
24
+ this.buckets.set(key, bucket);
25
+ return { allowed, limit: this.limit, remaining: Math.max(0, this.limit - bucket.length), resetAt: new Date((bucket[0] || now) + this.windowMs).toISOString() };
26
+ }
27
+ clear(key) { if (key) this.buckets.delete(key); else this.buckets.clear(); }
28
+ }
@@ -0,0 +1,70 @@
1
+ import crypto from 'node:crypto';
2
+ import dns from 'node:dns/promises';
3
+ import net from 'node:net';
4
+
5
+ export function signWebhook(secret, body) {
6
+ return `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
7
+ }
8
+
9
+ const BLOCKED_IPV4 = [
10
+ [/^127\./, 'loopback'], [/^10\./, 'private'], [/^192\.168\./, 'private'],
11
+ [/^169\.254\./, 'link-local'], [/^0\./, 'unspecified'], [/^224\./, 'multicast'],
12
+ [/^172\.(1[6-9]|2\d|3[0-1])\./, 'private']
13
+ ];
14
+ const BLOCKED_IPV6 = [/^::1$/, /^fc/i, /^fd/i, /^fe80:/i, /^ff/i, /^::$/];
15
+
16
+ export function isBlockedAddress(address) {
17
+ if (net.isIPv4(address)) return BLOCKED_IPV4.find(([re]) => re.test(address))?.[1] || null;
18
+ if (net.isIPv6(address)) return BLOCKED_IPV6.find(re => re.test(address)) ? 'private-or-special' : null;
19
+ return null;
20
+ }
21
+
22
+ export async function validateWebhookTarget(rawUrl, options = {}) {
23
+ let parsed;
24
+ try { parsed = new URL(String(rawUrl)); } catch { throw new Error('Webhook URL is invalid'); }
25
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Webhook URL must use http or https');
26
+ if (parsed.username || parsed.password) throw new Error('Webhook URL credentials are not allowed');
27
+ if (options.allowPrivateNetworks === true) return parsed;
28
+ const host = parsed.hostname.replace(/^\[|\]$/g, '');
29
+ const literalReason = isBlockedAddress(host);
30
+ if (literalReason) throw new Error(`Webhook target blocked: ${literalReason}`);
31
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host === 'metadata.google.internal') {
32
+ throw new Error('Webhook target blocked: local or metadata hostname');
33
+ }
34
+ const lookup = options.lookup || dns.lookup;
35
+ const addresses = await lookup(host, { all: true, verbatim: true });
36
+ if (!addresses.length) throw new Error('Webhook hostname did not resolve');
37
+ for (const entry of addresses) {
38
+ const reason = isBlockedAddress(entry.address);
39
+ if (reason) throw new Error(`Webhook target blocked: ${reason}`);
40
+ }
41
+ return parsed;
42
+ }
43
+
44
+ export function createWebhookDispatcher(options = {}) {
45
+ const fetchImpl = options.fetch || globalThis.fetch;
46
+ if (typeof fetchImpl !== 'function') throw new Error('Webhook delivery requires fetch');
47
+ const maxAttempts = Math.max(1, Number(options.maxAttempts || 3));
48
+ const timeoutMs = Math.max(250, Number(options.timeoutMs || 8000));
49
+ const lookup = options.lookup || dns.lookup;
50
+ return async function deliver(webhook, delivery) {
51
+ await validateWebhookTarget(webhook.url, { ...options, lookup });
52
+ const body = JSON.stringify({ id: delivery.id, event: delivery.event, tenantId: delivery.tenantId, payload: delivery.payload, createdAt: delivery.createdAt });
53
+ let lastError = null;
54
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
55
+ const controller = new AbortController();
56
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
57
+ try {
58
+ const response = await fetchImpl(webhook.url, {
59
+ method: 'POST', redirect: 'error',
60
+ headers: { 'content-type': 'application/json', 'user-agent': 'AgentGate-Webhook/2.4', 'x-agentgate-event': delivery.event, 'x-agentgate-signature': signWebhook(webhook.secret, body) },
61
+ body, signal: controller.signal
62
+ });
63
+ clearTimeout(timer);
64
+ if (response.ok) return { status: 'delivered', attempts: attempt, statusCode: response.status };
65
+ lastError = new Error(`Webhook returned HTTP ${response.status}`);
66
+ } catch (error) { clearTimeout(timer); lastError = error; }
67
+ }
68
+ return { status: 'failed', attempts: maxAttempts, error: lastError?.message || 'Webhook delivery failed' };
69
+ };
70
+ }
@@ -0,0 +1,86 @@
1
+ <!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AgentGate — Runtime Control Plane</title><style>
2
+ *{box-sizing:border-box}body{margin:0;background:#070a12;color:#eaf0ff;font-family:Inter,Arial,sans-serif}button{font:inherit;color:inherit;cursor:pointer}.app{display:flex;min-height:100vh}.side{width:250px;border-right:1px solid #1b2334;padding:25px 16px;display:flex;flex-direction:column;background:#080b13}.brand{font-size:21px;font-weight:800;padding:0 10px 28px}.brand b{color:#9b8dff}.mark{display:inline-grid;place-items:center;width:35px;height:35px;border-radius:10px;background:linear-gradient(135deg,#8a7cff,#4b43a5);margin-right:10px;vertical-align:middle}.workspace,.card,.stat,.attack,.policy{border:1px solid #1b2334;background:#0d121e;border-radius:12px}.workspace{padding:11px;display:flex;gap:10px;align-items:center;font-size:12px}.avatar{width:31px;height:31px;border-radius:8px;background:#191f31;display:grid;place-items:center;color:#aaa1ff}.workspace small{display:block;color:#8993a8;margin-top:3px}.nav{margin-top:24px;display:grid;gap:5px}.nav button{background:none;border:0;text-align:left;padding:11px 12px;border-radius:9px;color:#8e99ad}.nav button.active,.nav button:hover{background:#141a28;color:white}.sideBottom{margin-top:auto;color:#687388;font-size:10px}.secure{color:#9aa5b9;padding:10px}.dot{display:inline-block;width:7px;height:7px;background:#3fe29a;border-radius:50%;margin-right:7px}main{flex:1;min-width:0}header{height:84px;border-bottom:1px solid #1b2334;padding:0 34px;display:flex;align-items:center;justify-content:space-between;background:#080c15}.eyebrow{font-size:9px;letter-spacing:2px;color:#777f94}h1,h2,h3{margin:0}h1{font-size:21px}h2{font-size:39px;line-height:1.05;letter-spacing:-1.5px;margin-top:14px}.headerRight{display:flex;gap:10px}.search,.test,.ghost,.primary{border-radius:9px;padding:9px 13px}.search,.ghost{border:1px solid #232c40;background:#111827;color:#778397}.test,.primary{border:0;background:linear-gradient(135deg,#9b8cff,#7568eb);font-weight:700}.content{padding:31px 34px;max-width:1300px;margin:auto}.hero,.labHero{border:1px solid #25213f;border-radius:20px;padding:38px 40px;background:radial-gradient(circle at 85% 35%,#2b1f5e55,transparent 34%),linear-gradient(135deg,#111322,#0b101a);display:flex;justify-content:space-between;align-items:center}.pill{display:inline-block;color:#8eeac0;background:#0b261c;border:1px solid #1b5c43;border-radius:99px;padding:5px 9px;font-size:10px}.hero p,.labHero p,.sectionTitle p{color:#929caf;max-width:650px;line-height:1.6;font-size:14px}.heroActions{display:flex;gap:10px;margin-top:25px}.score{text-align:center;margin:0 30px}.ring{width:155px;height:155px;border-radius:50%;display:grid;place-content:center;background:conic-gradient(#8b7cff 91%,#202538 0);position:relative}.ring:after{content:'';position:absolute;inset:9px;background:#0c111d;border-radius:50%}.ring strong,.ring span{position:relative;z-index:1}.ring strong{font-size:39px}.ring span{font-size:8px;letter-spacing:1.5px}.score small{color:#55dba3}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:14px 0}.stat{padding:18px}.stat span,.stat small{display:block;color:#7f899d;font-size:10px}.stat strong{display:block;font-size:25px;margin:8px 0 4px}.stat small{color:#4fcd98}.grid{display:grid;grid-template-columns:1.7fr 1fr;gap:14px}.card{padding:20px}.cardHead{display:flex;justify-content:space-between;margin-bottom:15px}.cardHead p,.quick>p{color:#717c90;font-size:11px;margin-top:5px}.live{font-size:9px;color:#55dba3}.tr{display:grid;grid-template-columns:1.8fr .8fr .8fr .6fr;padding:12px 4px;border-top:1px solid #171e2c;font-size:11px}.th{color:#687388;font-size:9px;border:0}.muted{color:#788397}.decision{display:inline-block;border-radius:99px;padding:4px 7px;font-size:9px;font-weight:700}.allow{color:#4ee0a2;background:#0d2b20}.ask{color:#f5c66e;background:#2b2415}.block{color:#ff6f81;background:#311821}.control{display:flex;align-items:center;gap:10px;padding:13px 0;border-top:1px solid #171e2c}.control .grow{flex:1}.control small{display:block;color:#687388;font-size:9px;margin-top:3px}.on{font-size:8px;color:#4ee0a2;border:1px solid #275e4a;padding:3px 6px;border-radius:5px}.sectionTitle,.findingHead{display:flex;justify-content:space-between;align-items:end;margin:30px 0 15px}.attacks,.policyList{display:grid;gap:8px}.attack,.policy{padding:17px;display:flex;align-items:center;gap:15px}.num{color:#505b6e}.aBody,.policy .grow{flex:1}.aBody b,.policy b{font-size:12px}.aBody p,.policy p{font-size:10px;color:#747f93;margin-top:5px}.risk{font-size:7px;padding:3px 5px;border-radius:4px;margin-left:7px}.critical{color:#ff7183;background:#321a22}.high{color:#e8b866;background:#2e2717}.policy code{font-size:9px;color:#69758a;margin-left:10px}.replay{padding:35px}.trace{max-width:760px;margin:auto}.traceStep{margin:14px 0}.traceStep span{color:#7e72f5;font-size:10px}.traceStep b{display:block;font-size:12px;margin:4px 0}.traceStep p{font-size:11px;color:#7b869a}.line{height:25px;border-left:1px dashed #394158;margin-left:5px}.toast{position:fixed;right:25px;bottom:25px;background:#e9e6ff;color:#17142c;padding:12px 16px;border-radius:10px;font-size:11px;font-weight:700}@media(max-width:900px){.side{display:none}.content{padding:20px 16px}.hero,.labHero{display:block;padding:27px}.score{margin:25px 0}.grid{grid-template-columns:1fr}.stats{grid-template-columns:1fr 1fr}header{padding:0 18px}.search{display:none}}@media(max-width:520px){h2{font-size:31px}.heroActions{flex-direction:column}.headerRight .test{font-size:0}.headerRight .test:after{content:'⚡';font-size:14px}.tr{grid-template-columns:1.5fr .8fr .8fr}.tr span:last-child{display:none}.attack .decision{display:none}}
3
+ </style></head><body><div class="app"><aside class="side"><div class="brand"><span class="mark">◈</span>Agent<b>Gate</b></div><div class="workspace"><div class="avatar">AG</div><div><strong>Acme Agent</strong><small>Development</small></div></div><nav class="nav"><button class="active" data-tab="Overview">◉ &nbsp; Overview</button><button data-tab="Attack Lab">⚗ &nbsp; Attack Lab <small>NEW</small></button><button data-tab="Policies">▣ &nbsp; Policies</button><button data-tab="Replay">↻ &nbsp; Replay</button><button data-tab="Behavior">◌ &nbsp; Behavior</button><button data-tab="Blast Radius">◈ &nbsp; Blast Radius</button><button data-tab="Monitor">◉ &nbsp; Runtime Monitor</button><button data-tab="Observability">◒ &nbsp; Observability</button><button data-tab="Cost Control">$ &nbsp; Cost Control</button></nav><div class="sideBottom"><div class="secure"><span class="dot"></span>Protection active</div>AgentGate v2.13.8</div></aside><main><header><div><div class="eyebrow">RUNTIME CONTROL PLANE</div><h1 id="title">Overview</h1></div><div class="headerRight"><div class="search">⌕ &nbsp; Search</div><button class="test" id="test">⚡ Test agent</button></div></header><div id="content"></div></main></div><script>
4
+ const content=document.getElementById('content'),title=document.getElementById('title');let toastTimer;
5
+ const decision=t=>`<span class="decision ${t.toLowerCase()}">${t}</span>`;
6
+ function overview(){content.innerHTML=`<section class="hero"><div><span class="pill">● Agent protected</span><h2>Control what your AI agents can actually do.</h2><p>AgentGate sits between your agent and its tools: ALLOW, ASK, or BLOCK — with replayable evidence of what happened.</p><div class="heroActions"><button class="primary" id="attackBtn">⚗ Run Attack Lab</button><button class="ghost" onclick="notify('SDK command copied')">▣ npm install agentgate-runtime-control</button></div></div><div class="score"><div class="ring"><strong>91</strong><span>SECURITY<br>SCORE</span></div><small>+6 this week</small></div></section><div class="stats"><div class="stat"><span>Actions evaluated</span><strong>12,482</strong><small>+18.4%</small></div><div class="stat"><span>Allowed</span><strong>10,921</strong><small>87.5%</small></div><div class="stat"><span>Blocked</span><strong>1,104</strong><small>8.8%</small></div><div class="stat"><span>Needs approval</span><strong>457</strong><small>3.7%</small></div></div><section class="card" style="margin-top:14px"><div class="cardHead"><div><h3>First 10 minutes</h3><p>Connect one agent and protect one sensitive tool.</p></div></div><div class="control"><span>01</span><div class="grow"><b>Install</b><small>npm install agentgate-runtime-control</small></div></div><div class="control"><span>02</span><div class="grow"><b>Observe</b><small>Run in Shadow Mode and inspect decisions.</small></div></div><div class="control"><span>03</span><div class="grow"><b>Attack</b><small>Run the live Attack Lab against the gateway.</small></div></div><div class="control"><span>04</span><div class="grow"><b>Enforce</b><small>Enable BLOCK / ASK on one sensitive tool.</small></div></div><div class="control"><span>05</span><div class="grow"><b>Replay</b><small>Export the audit evidence for review.</small></div></div></section><section class="grid"><div class="card"><div class="cardHead"><div><h3>Live activity</h3><p>Real-time decisions from your agent</p></div><span class="live">● DEMO DATA</span></div><div class="tr th"><span>Action</span><span>Tool</span><span>Decision</span><span>When</span></div>${[['refund_customer','refund','ALLOW','2m ago'],['delete_workspace','delete','BLOCK','8m ago'],['lookup_customer','read','ALLOW','12m ago'],['export_records','export','ASK','19m ago']].map(r=>`<div class="tr"><span><b>${r[0]}</b></span><span class="muted">${r[1]}</span><span>${decision(r[2])}</span><span class="muted">${r[3]}</span></div>`).join('')}</div><div class="card quick"><h3>Protection</h3><p>Your runtime controls</p><div class="control"><span>✓</span><div class="grow"><b>Policy engine</b><small>4 active policies</small></div><span class="on">ON</span></div><div class="control"><span>⚗</span><div class="grow"><b>Attack Lab</b><small>12 scenarios ready</small></div><button class="ghost" id="attackBtn2">Preview</button></div><div class="control"><span>↻</span><div class="grow"><b>Replay</b><small>Last 30 days</small></div>›</div></div></section>`;document.getElementById('attackBtn').onclick=()=>show('Attack Lab');document.getElementById('attackBtn2').onclick=()=>show('Attack Lab')}
7
+ function attack(){content.innerHTML=`<section class="labHero"><div><span class="pill">⚗ ATTACK LAB · LIVE</span><h2>Test the runtime boundary.</h2><p>Runs the built-in Attack Lab through the authenticated Control Plane. No scenario is reported as completed until the gateway returns a real decision.</p></div><button class="primary" id="runAttack">RUN ATTACK LAB ⚡</button></section><div class="findingHead"><div><h3 id="attackStatus">Ready</h3><p id="attackSummary">Five built-in scenarios will be executed through AgentGate.</p></div></div><div id="attackResults" class="attacks"><p class="muted">No live test run yet.</p></div>`;document.getElementById('runAttack').onclick=runLiveAttack}
8
+ async function runLiveAttack(){const btn=document.getElementById('runAttack');const status=document.getElementById('attackStatus');btn.disabled=true;status.textContent='Running…';try{const r=await fetch('/api/attack-lab',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({})});const d=await r.json();if(!r.ok)throw new Error(d.error||'Attack Lab request failed');document.getElementById('attackSummary').textContent=`${d.summary.passed}/${d.summary.total} protected · ${d.summary.blocked} BLOCK · ${d.summary.approvalRequired} ASK${d.summary.skipped?` · ${d.summary.skipped} SKIPPED`:''}`;document.getElementById('attackResults').innerHTML=d.results.map((x,i)=>`<div class="attack"><div class="num">${String(i+1).padStart(2,'0')}</div><div class="aBody"><b>${esc(x.name)}</b><span class="risk ${(x.risk>=90?'critical':x.risk>=70?'high':'medium')}">${x.risk ?? '—'}</span><p>${esc(x.action)} · run ${esc(x.runId||'not recorded')}${x.skipped?' · tool not registered':''}</p></div>${x.skipped?'<span class="decision">SKIPPED</span>':decision(x.decision)}</div>`).join('');status.textContent=d.summary.status==='PROTECTED'?'Protected — live gateway results':d.summary.status==='PARTIAL'?'Partial — some attack tools are not registered':'Findings require review';notify('Live Attack Lab completed')}catch(e){status.textContent='Attack Lab failed';document.getElementById('attackSummary').textContent=e.message;notify(e.message)}finally{btn.disabled=false}}
9
+ function policies(){content.innerHTML=`<section class="sectionTitle"><div><span class="pill">▣ POLICY ENGINE · LIVE</span><h2>Deterministic controls.</h2><p>Create a draft, test explicit cases, review the result, then activate. Activation is refused until tests pass.</p></div><button class="primary" id="newPolicy">+ New policy</button></section><div class="card" style="margin-bottom:14px"><div class="cardHead"><div><h3>Policy lifecycle</h3><p>Create Draft → Add Test Cases → Run Tests → Review Results → Activate</p></div><span class="live">● LIVE API</span></div><div class="control"><span>01</span><div class="grow"><b>Create Draft</b><small>Define the deterministic policy JSON.</small></div></div><div class="control"><span>02</span><div class="grow"><b>Test the boundary</b><small>Every case declares an expected ALLOW / ASK / BLOCK decision.</small></div></div><div class="control"><span>03</span><div class="grow"><b>Review</b><small>Activation remains locked if any case fails.</small></div></div><div class="control"><span>04</span><div class="grow"><b>Activate</b><small>Only a tested policy can become active.</small></div></div></div><div id="policyList" class="policyList"><p class="muted">Loading policies…</p></div>`;document.getElementById('newPolicy').onclick=showPolicyForm;loadPolicies()}
10
+ async function loadPolicies(){try{const r=await fetch('/api/policies',{cache:'no-store'});const d=await r.json();if(!r.ok)throw new Error(d.error||'Unable to load policies');const items=d.policies||[];document.getElementById('policyList').innerHTML=items.length?items.map(p=>`<div class="policy"><div class="grow"><b>${esc(p.name)}</b><code>v${p.version}</code><p>${esc(p.state)} · ${esc(p.hash||'')}</p>${p.lastTest?`<small>Last test: ${p.lastTest.passed?'PASS':'FAIL'} · ${p.lastTest.results?.length||0} cases</small>`:''}</div><div style="display:flex;gap:7px;align-items:center"><span class="on">${esc(p.state).toUpperCase()}</span>${p.state==='draft'||p.state==='tested'?`<button class="ghost" onclick="testPolicy('${esc(p.name)}',${p.version})">Test</button>`:''}${p.state==='tested'||p.state==='active'?`<button class="ghost" onclick="activatePolicy('${esc(p.name)}',${p.version})">${p.state==='active'?'Re-activate':'Activate'}</button>`:''}</div></div>`).join(''):'<p class="muted">No policies yet. Create the first one above.</p>'}catch(e){document.getElementById('policyList').innerHTML=`<p class="muted">Policy API unavailable: ${esc(e.message)}</p>`}}
11
+ async function showPolicyForm(){const name=prompt('Policy name (example: refund-safety)');if(!name)return;const raw=prompt('Policy JSON',JSON.stringify({refund:{max:5000}},null,2));if(!raw)return;let policy;try{policy=JSON.parse(raw)}catch{notify('Invalid JSON policy');return}try{const r=await fetch('/api/policies/create',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,policy})});const d=await r.json();if(!r.ok)throw new Error(d.error||'Policy creation failed');notify(`Draft ${name} v${d.id?.split('_v').pop()||''} created`);loadPolicies()}catch(e){notify(`Create failed: ${e.message}`)}}
12
+ async function testPolicy(name,version){const raw=prompt('Test cases JSON',JSON.stringify([{name:'safe',input:{action:'refund',amount:100},expected:'ALLOW'},{name:'high',input:{action:'refund',amount:6000},expected:'BLOCK'}],null,2));if(!raw)return;let cases;try{cases=JSON.parse(raw);if(!Array.isArray(cases))throw new Error('Cases must be an array')}catch(e){notify(`Invalid test cases: ${e.message}`);return}try{const r=await fetch('/api/policies/test',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,version,cases})});const d=await r.json();if(!r.ok)throw new Error(d.error||'Policy test failed');const t=d.test;notify(`Policy test ${t.passed?'PASSED':'FAILED'} · ${t.results?.filter(x=>x.passed).length||0}/${t.results?.length||0}`);loadPolicies()}catch(e){notify(`Test failed: ${e.message}`)}}
13
+ async function activatePolicy(name,version){if(!confirm(`Activate ${name} v${version}?`))return;try{const r=await fetch('/api/policies/activate',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name,version})});const d=await r.json();if(!r.ok)throw new Error(d.error||'Activation failed');notify(`Policy ${name} v${version} activated`);loadPolicies()}catch(e){notify(`Activation blocked: ${e.message}`)}}
14
+
15
+ async function exportAudit(){try{const r=await fetch('/api/audit/export',{cache:'no-store'});const d=await r.json();if(!r.ok)throw new Error(d.error||'Export failed');const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`agentgate-audit-${new Date().toISOString().slice(0,10)}.json`;a.click();URL.revokeObjectURL(a.href);notify('Audit JSON exported')}catch(e){notify(e.message)}}
16
+ async function behavior(){
17
+ content.innerHTML='<section class="sectionTitle"><div><span class="pill">◌ BEHAVIOR DETECTION</span><h2>Detect patterns, not just events.</h2><p>AgentGate analyzes recent tool-call sequences for suspicious chaining, repeated control attempts, and high-impact behavior.</p></div></section><div class="card" id="behaviorCard"><p class="muted">Analyzing runtime...</p></div>';
18
+ try{const r=await fetch('/api/behavior',{cache:'no-store'});const d=await r.json();document.getElementById('behaviorCard').innerHTML='<div class="stats"><div class="stat"><span>Status</span><strong>'+esc(d.status)+'</strong><small>'+d.findingCount+' findings</small></div><div class="stat"><span>Max risk</span><strong>'+d.maxRisk+'</strong><small>'+d.runsAnalyzed+' runs analyzed</small></div><div class="stat"><span>High</span><strong>'+d.high+'</strong><small>high severity</small></div><div class="stat"><span>Critical</span><strong>'+d.critical+'</strong><small>critical severity</small></div></div><div class="attacks">'+(d.findings.map(f=>'<div class="attack"><div class="num">!</div><div class="aBody"><b>'+esc(f.title)+'</b><span class="risk '+esc(f.severity)+'">'+esc(f.severity)+'</span><p>'+esc(f.description)+'</p><small class="muted">'+esc(f.action)+' · '+esc(f.agent)+'</small></div><span class="decision block">RISK '+f.risk+'</span></div>').join('')||'<p class="muted">No suspicious behavior detected.</p>')+'</div>'}catch(e){document.getElementById('behaviorCard').innerHTML='<p class="muted">Behavior API unavailable.</p>'}
19
+ }
20
+ async function blast(){
21
+ content.innerHTML='<section class="sectionTitle"><div><span class="pill">◈ BLAST RADIUS</span><h2>Understand potential impact.</h2><p>Estimate the scope of an action before it becomes a real incident.</p></div></section><div class="card" id="blastCard"><p class="muted">Calculating...</p></div>';
22
+ try{const r=await fetch('/api/blast-radius',{cache:'no-store'});const d=await r.json();document.getElementById('blastCard').innerHTML='<div class="stats"><div class="stat"><span>Status</span><strong>'+esc(d.status)+'</strong><small>overall estimate</small></div><div class="stat"><span>Max score</span><strong>'+d.maxScore+'</strong><small>0–100</small></div><div class="stat"><span>Runs</span><strong>'+d.runCount+'</strong><small>assessed</small></div></div><div class="attacks">'+(d.assessments.slice(0,20).map(a=>'<div class="attack"><div class="num">'+a.score+'</div><div class="aBody"><b>'+esc(a.action||'unknown')+'</b><span class="risk '+esc(a.level)+'">'+esc(a.level)+'</span><p>'+esc(a.environment)+' · scope '+esc(a.scope)+' · targets '+esc(a.estimatedTargets)+'</p><small class="muted">'+esc(a.factors.join(' · '))+'</small></div><span class="decision block">'+a.score+'</span></div>').join('')||'<p class="muted">No runs to assess.</p>')+'</div><p class="muted" style="margin-top:15px">Deterministic estimate based on request metadata; not a guarantee of actual impact.</p>'}catch(e){document.getElementById('blastCard').innerHTML='<p class="muted">Blast Radius API unavailable.</p>'}
23
+ }
24
+ async function replay(){content.innerHTML=`<section class="sectionTitle"><div><span class="pill">↻ REPLAY</span><h2>See exactly what happened.</h2><p>Replay uses a real run ID and explains the winning policy rule, approval state, execution, egress and cost.</p></div></section><div id="replayCard" class="card replay"><p class="muted">Loading latest run…</p></div>`;
25
+ try{const rr=await fetch('/api/runs',{cache:'no-store'});if(!rr.ok)throw new Error('unavailable');const data=await rr.json();const run=(data.runs||[])[0];if(!run){document.getElementById('replayCard').innerHTML='<p class="muted">No runs yet. Click Test agent or connect your agent.</p>';return;}const t=run.ruleTrace||run.policy?.ruleTrace||[];const winning=run.winningRule||run.policy?.winningRule||t.find(x=>x.matched)?.id||'not recorded';document.getElementById('replayCard').innerHTML=`<div class="trace"><div class="traceStep"><span>RUN</span><b>${esc(run.id)}</b><p>${esc(run.request?.action||'unknown action')} · ${esc(run.request?.tool||'unknown tool')}</p></div><div class="line"></div><div class="traceStep"><span>01</span><b>Policy decision</b><p>${decision(run.decision)} ${esc(run.reason||'')} · risk ${run.risk}</p><p>Winning rule: <strong>${esc(winning)}</strong></p></div><div class="line"></div><div class="traceStep"><span>02</span><b>Rule trace</b><p>${t.map(x=>`${esc(x.id)} → ${x.matched?'matched':'not matched'}${x.matchedRule?` (${esc(x.matchedRule)})`:''}`).join(' · ')||'No rule trace recorded'}</p></div><div class="line"></div><div class="traceStep"><span>03</span><b>Execution</b><p>${run.executed===true?'Tool handler executed.':run.decision==='BLOCK'?'Tool handler NOT executed — blocked before execution.':run.decision==='ASK'?'Execution paused — approval required.':'Execution state not recorded.'}</p></div><div class="line"></div><div class="traceStep"><span>04</span><b>Cost / Egress</b><p>Cost: $${run.cost?.total??run.cost?.estimated??0} · Egress: ${esc(run.egress?.decision||'not applicable')}</p></div></div>`}catch(e){document.getElementById('replayCard').innerHTML='<p class="muted">Replay API unavailable.</p>'}}
26
+
27
+
28
+ function esc(v){return String(v??'').replace(/[&<>\"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[c]))}
29
+ async function monitor(){
30
+ content.innerHTML=`<section class="sectionTitle"><div><span class="pill">◉ RUNTIME MONITOR</span><h2>Live agent activity.</h2><p>These events come from the same AgentGate Gateway runtime. Approvals are resolved here and reflected in the audit trail.</p></div><span class="live" id="monitorLive">● CONNECTING</span></section><div class="stats" id="monitorStats"></div><section class="grid"><div class="card"><div class="cardHead"><div><h3>Live decisions</h3><p>Tool calls captured by the gateway</p></div><span class="live">● LIVE</span></div><div id="runs"></div></div><div class="card"><div class="cardHead"><div><h3>Pending approvals</h3><p>Human decisions before side effects</p></div><span class="on" id="pendingCount">0 PENDING</span></div><div id="approvals"></div></div></section>`;
31
+ await refreshMonitor();
32
+ clearInterval(window._monitorTimer); window._monitorTimer=setInterval(refreshMonitor,1500);
33
+ }
34
+ async function refreshMonitor(){
35
+ try{
36
+ const [rr,aa]=await Promise.all([fetch('/api/runs',{cache:'no-store'}),fetch('/api/approvals?status=pending',{cache:'no-store'})]);
37
+ if(!rr.ok||!aa.ok) throw new Error('Control plane unavailable');
38
+ const runs=(await rr.json()).runs||[], approvals=(await aa.json()).approvals||[];
39
+ document.getElementById('monitorLive').textContent='● CONNECTED';
40
+ document.getElementById('pendingCount').textContent=`${approvals.length} PENDING`;
41
+ const counts={ALLOW:0,ASK:0,BLOCK:0}; runs.forEach(r=>counts[r.decision]=(counts[r.decision]||0)+1);
42
+ document.getElementById('monitorStats').innerHTML=[['Events',runs.length,'live gateway events'],['Allowed',counts.ALLOW,'executed'],['Blocked',counts.BLOCK,'stopped'],['Approval',counts.ASK,'review required']].map(x=>`<div class="stat"><span>${x[0]}</span><strong>${x[1]}</strong><small>${x[2]}</small></div>`).join('');
43
+ document.getElementById('runs').innerHTML=runs.slice(0,10).map(r=>`<div class="tr" style="grid-template-columns:1.6fr .8fr .7fr .7fr"><span><b>${esc(r.request?.action||r.request?.tool)}</b><small class="muted" style="display:block">${esc(r.request?.agent||'unknown')}</small></span><span class="muted">${esc(r.request?.tool)}</span><span>${decision(r.decision)}</span><span class="muted">${r.status||'recorded'}</span></div>`).join('')||'<p class="muted">No runtime events yet.</p>';
44
+ document.getElementById('approvals').innerHTML=approvals.map(a=>`<div class="control" style="align-items:flex-start"><div class="grow"><b>${esc(a.request?.action||a.metadata?.tool)}</b><small>Risk ${a.risk} · ${esc(a.reason)}</small><code style="display:block;margin-top:7px;color:#69758a;font-size:9px">${esc(JSON.stringify(a.request||{}))}</code><div style="display:flex;gap:7px;margin-top:9px"><button class="primary" onclick="resolveApproval('${a.id}','approve')">Approve</button><button class="ghost" onclick="resolveApproval('${a.id}','deny')">Deny</button></div></div></div>`).join('')||'<p class="muted">No pending approvals.</p>';
45
+ }catch(e){const el=document.getElementById('monitorLive'); if(el) el.textContent='● OFFLINE';}
46
+ }
47
+ async function resolveApproval(id,action){
48
+ const body={approvalId:id}; if(action==='deny') body.reason='Denied from AgentGate dashboard';
49
+ const r=await fetch('/api/approvals/'+action,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
50
+ const result=await r.json(); notify(result.status==='executed'?'Approval approved — tool executed':result.status==='denied'?'Approval denied':'Approval updated'); refreshMonitor();
51
+ }
52
+
53
+
54
+ async function observability(){
55
+ content.innerHTML=`<section class="sectionTitle"><div><span class="pill">◒ OBSERVABILITY + SECURITY</span><h2>See what happened, why, and whether it was safe.</h2><p>AgentGate joins runtime performance, behavior, security decisions and cost in the same operational view.</p></div><span class="live">● LIVE DATA</span></section><div class="stats" id="obsStats"></div><section class="grid"><div class="card"><div class="cardHead"><div><h3>Runtime performance</h3><p>P50 / P95 / P99 execution latency</p></div></div><div id="obsLatency"></div></div><div class="card"><div class="cardHead"><div><h3>Security-aware telemetry</h3><p>Security outcomes inside the runtime trace</p></div></div><div id="obsSecurity"></div></div></section><section class="grid" style="margin-top:14px"><div class="card"><div class="cardHead"><div><h3>Agent Efficiency</h3><p>Deterministic score — no LLM judgment</p></div></div><div id="obsEfficiency"></div></div><div class="card"><div class="cardHead"><div><h3>Behavior × Cost × Security</h3><p>Why runtime behavior changes cost or risk</p></div></div><div id="obsCorrelation"></div></div></section><section class="card" style="margin-top:14px"><div class="cardHead"><div><h3>Unified security traces</h3><p>LLM → Tool → Policy → Approval → Execution → Egress → Behavior → Cost</p></div></div><div id="obsTraces"></div></section><section class="card" style="margin-top:14px"><div class="cardHead"><div><h3>Tool performance</h3><p>Which tools are slow, failing, costly or blocked?</p></div></div><div id="obsTools"></div></section>`;
56
+ try{
57
+ const [or,er,br]=await Promise.all([fetch('/api/observability',{cache:'no-store'}),fetch('/api/efficiency',{cache:'no-store'}),fetch('/api/behavior/correlation',{cache:'no-store'})]);
58
+ if(!or.ok||!er.ok||!br.ok) throw new Error('unavailable');
59
+ const d=await or.json(), e=await er.json(), c=await br.json();
60
+ document.getElementById('obsStats').innerHTML=[['Runs',d.runs,'runtime events'],['Success',d.successRate+'%','successful executions'],['Blocked',d.security.blocked,'policy/runtime blocks'],['Approvals',d.security.approvalRequired,'human review'],['Risk events',d.security.highRisk,'risk ≥ 70'],['Cost','$'+d.cost.total,'known runtime cost']].map(x=>`<div class="stat"><span>${x[0]}</span><strong>${x[1]}</strong><small>${x[2]}</small></div>`).join('');
61
+ document.getElementById('obsLatency').innerHTML=`<div class="stats"><div class="stat"><span>P50</span><strong>${d.latency.p50}ms</strong><small>median</small></div><div class="stat"><span>P95</span><strong>${d.latency.p95}ms</strong><small>tail latency</small></div><div class="stat"><span>P99</span><strong>${d.latency.p99}ms</strong><small>extreme tail</small></div></div>`;
62
+ document.getElementById('obsSecurity').innerHTML=`<div class="control"><div class="grow"><b>Blocked actions</b><small>Policy / runtime blocks</small></div><span class="decision block">${d.security.blocked}</span></div><div class="control"><div class="grow"><b>Approval required</b><small>Human review gates</small></div><span class="decision ask">${d.security.approvalRequired}</span></div><div class="control"><div class="grow"><b>Egress blocked</b><small>Sensitive output prevented</small></div><span class="decision block">${d.security.egressBlocked}</span></div><div class="control"><div class="grow"><b>Behavior anomalies</b><small>Deterministic behavior findings</small></div><span class="decision block">${d.behavior.findingCount}</span></div>`;
63
+ const eff=Object.entries(e.byAgent||{}); document.getElementById('obsEfficiency').innerHTML=eff.map(([name,x])=>`<div class="control"><div class="grow"><b>${esc(name)}</b><small>${x.metrics.runs} runs · ${x.metrics.successRate}% success · ${x.metrics.avgToolCalls} calls/task · $${x.metrics.costPerSuccessfulTask}/successful task</small></div><strong>${x.score}/100</strong></div>`).join('')||'<p class="muted">No agent data yet.</p>';
64
+ document.getElementById('obsCorrelation').innerHTML=`<div class="control"><div class="grow"><b>${c.anomalyRuns} anomalous runs</b><small>${c.securityCorrelation.status}</small></div><span>${c.costDeltaPercent===null?'—':(c.costDeltaPercent>=0?'+':'')+c.costDeltaPercent+'% cost'}</span></div><div class="control"><div class="grow"><b>Blocked anomaly runs</b><small>Security-controlled behavior</small></div><span class="decision block">${c.blockedAnomalyRuns}</span></div><div class="control"><div class="grow"><b>Approval anomaly runs</b><small>Human-control friction</small></div><span class="decision ask">${c.approvalAnomalyRuns}</span></div>`;
65
+ const traces=(d.traces||[]).slice(0,8); document.getElementById('obsTraces').innerHTML=traces.map(t=>`<div class="control" style="align-items:flex-start"><div class="grow"><b>${esc(t.agent)} · ${esc(t.model||'unknown')}</b><small>${esc(t.runId||'')} · ${t.latencyMs??'—'}ms · $${t.cost?.total||0} · ${esc(t.decision)}</small><div style="margin-top:7px;color:#788397;font-size:10px">${t.events.map(ev=>`${esc(ev.stage)}:${esc(ev.status)}`).join(' → ')}</div></div><span class="risk ${t.risk>=90?'critical':t.risk>=70?'high':''}">RISK ${t.risk}</span></div>`).join('')||'<p class="muted">No traces yet.</p>';
66
+ const tools=Object.entries(d.tools||{}).sort((a,b)=>b[1].runs-a[1].runs); document.getElementById('obsTools').innerHTML='<div class="tr th"><span>TOOL</span><span>RUNS</span><span>LATENCY</span><span>COST</span></div>'+(tools.map(([name,x])=>`<div class="tr"><span><b>${esc(name)}</b></span><span>${x.runs}</span><span class="muted">${x.latency.p95}ms P95</span><span>$${x.cost}</span></div>`).join('')||'<p class="muted">No runtime data yet.</p>');
67
+ }catch(e){document.getElementById('obsStats').innerHTML='<div class="card"><p class="muted">Observability API unavailable.</p></div>'}
68
+ }
69
+ async function costControl(){
70
+ content.innerHTML=`<section class="sectionTitle"><div><span class="pill">$ COST CONTROL</span><h2>Know what your agents cost — then control what they can spend.</h2><p>Cost is connected to agents, models, actions, tenants and security outcomes. Budget thresholds can become runtime ASK/BLOCK decisions.</p></div></section><div class="stats" id="costStats"></div><section class="grid"><div class="card"><div class="cardHead"><div><h3>Budget + forecast</h3><p>Current spend and deterministic month-end projection</p></div></div><div id="budgetStatus"></div></div><div class="card"><div class="cardHead"><div><h3>Security cost</h3><p>What controlled actions consumed in runtime cost</p></div></div><div id="securityCost"></div></div></section><section class="grid" style="margin-top:14px"><div class="card"><div class="cardHead"><div><h3>Cost by agent</h3><p>Where spend is actually going</p></div></div><div id="costAgents"></div></div><div class="card"><div class="cardHead"><div><h3>Cost by model</h3><p>Provider/model cost concentration</p></div></div><div id="costModels"></div></div></section><section class="card" style="margin-top:14px"><div class="cardHead"><div><h3>Cost by action</h3><p>Expensive actions and their security outcomes</p></div></div><div id="costActions"></div></section><section class="card" style="margin-top:14px"><div class="cardHead"><div><h3>Pricing registry</h3><p>Explicit model pricing — no stale hard-coded assumptions</p></div></div><div id="pricingStatus"></div></section>`;
71
+ try{
72
+ const [cr,pr,ar,fr]=await Promise.all([fetch('/api/cost',{cache:'no-store'}),fetch('/api/cost/pricing',{cache:'no-store'}),fetch('/api/cost/analytics',{cache:'no-store'}),fetch('/api/cost/forecast',{cache:'no-store'})]);
73
+ if(!cr.ok||!pr.ok||!ar.ok||!fr.ok) throw new Error('unavailable'); const c=await cr.json(), p=await pr.json(), a=await ar.json(), f=await fr.json();
74
+ document.getElementById('costStats').innerHTML=[['Today','$'+c.daily,'daily spend'],['This month','$'+c.monthly,'monthly spend'],['Forecast','$'+f.forecast,'month-end run-rate'],['Successful task','$'+a.successfulTaskCost,'average'],['Tokens',a.totalTokens,'tracked tokens'],['Monthly remaining',c.remaining.monthly===null?'∞':'$'+c.remaining.monthly,'hard budget']].map(x=>`<div class="stat"><span>${x[0]}</span><strong>${x[1]}</strong><small>${x[2]}</small></div>`).join('');
75
+ const b=c.budgets||{}; document.getElementById('budgetStatus').innerHTML=`<div class="control"><div class="grow"><b>Forecast</b><small>${f.method}</small></div><strong>$${f.forecast}</strong></div><div class="control"><div class="grow"><b>Daily ASK / BLOCK</b><small>Runtime thresholds</small></div><span>$${b.daily?.ask||0} / $${b.daily?.hard||0}</span></div><div class="control"><div class="grow"><b>Monthly ASK / BLOCK</b><small>Runtime thresholds</small></div><span>$${b.monthly?.ask||0} / $${b.monthly?.hard||0}</span></div><div class="control"><div class="grow"><b>Forecast vs budget</b><small>Positive means projected overage</small></div><span>${f.varianceVsBudget===null?'—':'$'+f.varianceVsBudget}</span></div>`;
76
+ document.getElementById('securityCost').innerHTML=Object.entries(a.securityCost||{}).map(([k,v])=>`<div class="control"><div class="grow"><b>${esc(k)}</b><small>Cost associated with controlled/security activity</small></div><span>$${v}</span></div>`).join('');
77
+ const renderGroup=(obj)=>Object.entries(obj||{}).sort((a,b)=>b[1].cost-a[1].cost).slice(0,8).map(([k,x])=>`<div class="control"><div class="grow"><b>${esc(k)}</b><small>${x.runs} runs · ${x.blocked} blocked · ${x.approvals} approvals</small></div><span>$${x.cost}</span></div>`).join('')||'<p class="muted">No cost data yet.</p>';
78
+ document.getElementById('costAgents').innerHTML=renderGroup(a.byAgent); document.getElementById('costModels').innerHTML=renderGroup(a.byModel); document.getElementById('costActions').innerHTML=renderGroup(a.byAction);
79
+ const pricing=Object.entries(p.pricing||{}); document.getElementById('pricingStatus').innerHTML=pricing.map(([m,x])=>`<div class="control"><div class="grow"><b>${esc(m)}</b><small>Input $${x.inputPer1M}/1M · Output $${x.outputPer1M}/1M · Cached $${x.cachedInputPer1M}/1M</small></div><span class="on">${esc(x.currency||'USD')}</span></div>`).join('')||'<p class="muted">No model pricing configured. Add pricing through the API before reporting monetary cost.</p>';
80
+ }catch(e){document.getElementById('costStats').innerHTML='<div class="card"><p class="muted">Cost API unavailable.</p></div>'}
81
+ }
82
+
83
+ function show(tab){title.textContent=tab;document.querySelectorAll('.nav button').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));({Overview:overview,'Attack Lab':attack,Policies:policies,Replay:replay,Behavior:behavior,'Blast Radius':blast,Monitor:monitor,Observability:observability,'Cost Control':costControl}[tab])()}
84
+ function notify(t){clearTimeout(toastTimer);let x=document.querySelector('.toast');if(!x){x=document.createElement('div');x.className='toast';document.body.appendChild(x)}x.textContent='✓ '+t;toastTimer=setTimeout(()=>x.remove(),2200)}
85
+ document.querySelectorAll('.nav button').forEach(b=>b.onclick=()=>show(b.dataset.tab));document.getElementById('test').onclick=()=>show('Attack Lab');overview();
86
+ </script></body></html>