@clampd/mcp-proxy 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,602 @@
1
+ /**
2
+ * Live dashboard — serves an HTML page at GET / with a real-time event log
3
+ * of all tool calls flowing through the proxy.
4
+ *
5
+ * Features:
6
+ * - Enriched event data (classification, session flags, intent labels, encodings)
7
+ * - Expandable detail rows with full gateway response data
8
+ * - Real-time risk trend SVG sparkline chart
9
+ * - Session summary panel with aggregated stats
10
+ * - Export (Copy Report / Download JSON)
11
+ * - Attack Demo panel with pre-built payloads
12
+ * - Status filter tabs (All / Allowed / Blocked / Flagged / Errors)
13
+ */
14
+
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import type { ProxyOptions } from "./proxy.js";
17
+
18
+ // ── Types ─────────────────────────────────────────────────────────────
19
+
20
+ export interface ProxyEvent {
21
+ timestamp: string;
22
+ tool: string;
23
+ params: string;
24
+ status: "allowed" | "blocked" | "flagged" | "error";
25
+ risk_score: number;
26
+ latency_ms: number;
27
+ reason?: string;
28
+ matched_rules?: string[];
29
+ // Enriched fields from gateway response
30
+ classification?: string;
31
+ session_flags?: string[];
32
+ intent_labels?: string[];
33
+ encodings_detected?: string[];
34
+ scope_granted?: string;
35
+ action?: string;
36
+ reasoning?: string;
37
+ degraded_stages?: string[];
38
+ scan_details?: {
39
+ pii_found?: Array<{ pii_type: string; count: number }>;
40
+ secrets_found?: Array<{ secret_type: string; count: number }>;
41
+ input_risk?: number;
42
+ output_risk?: number;
43
+ };
44
+ descriptor_hash?: string;
45
+ scope_token?: string;
46
+ }
47
+
48
+ export interface SessionStats {
49
+ toolCallCount: number;
50
+ uniqueTools: string[];
51
+ blockedCount: number;
52
+ flaggedCount: number;
53
+ totalRisk: number;
54
+ firstCallAt: string;
55
+ lastCallAt: string;
56
+ rulesTriggered: Record<string, number>;
57
+ piiDetected: boolean;
58
+ secretsDetected: boolean;
59
+ }
60
+
61
+ // ── Dashboard HTML ────────────────────────────────────────────────────
62
+
63
+ function renderDashboard(events: ProxyEvent[], opts: ProxyOptions & { demoPanel?: boolean; sessionStats?: SessionStats }): string {
64
+ const modeLabel = opts.dryRun ? "DRY-RUN" : "LIVE";
65
+ const blocked = events.filter((e) => e.status === "blocked").length;
66
+ const flagged = events.filter((e) => e.status === "flagged").length;
67
+ const allowed = events.filter((e) => e.status === "allowed").length;
68
+ const errors = events.filter((e) => e.status === "error").length;
69
+ const total = events.length;
70
+ const threatRate = total > 0 ? (((blocked + flagged) / total) * 100).toFixed(1) : "—";
71
+ const avgLatency = total > 0 ? Math.round(events.reduce((s, e) => s + e.latency_ms, 0) / total) : 0;
72
+ const totalRulesFired = events.reduce((s, e) => s + (e.matched_rules?.length ?? 0), 0);
73
+
74
+ const last50 = events.slice(-50).reverse();
75
+
76
+ // Build risk sparkline SVG
77
+ const sparkline = renderSparkline(last50);
78
+
79
+ // Build session summary
80
+ const sessionHtml = opts.sessionStats ? renderSessionSummary(opts.sessionStats) : "";
81
+
82
+ // Build event rows with expandable detail
83
+ const rows = last50.map((e, i) => renderEventRow(e, i)).join("");
84
+
85
+ // Build demo panel
86
+ const demoHtml = opts.demoPanel ? renderDemoPanel(opts) : "";
87
+
88
+ return `<!DOCTYPE html>
89
+ <html lang="en">
90
+ <head>
91
+ <meta charset="utf-8">
92
+ <meta name="viewport" content="width=device-width, initial-scale=1">
93
+ <title>Clampd MCP Proxy</title>
94
+ <style>
95
+ * { margin: 0; padding: 0; box-sizing: border-box; }
96
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #e0e0e0; }
97
+ .header { padding: 20px 32px; border-bottom: 1px solid #222; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
98
+ .header h1 { font-size: 20px; color: #fff; }
99
+ .badge { padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
100
+ .badge-live { background: #1a472a; color: #4ade80; }
101
+ .badge-dryrun { background: #422006; color: #fbbf24; }
102
+ .stats { display: flex; gap: 24px; padding: 14px 32px; border-bottom: 1px solid #222; flex-wrap: wrap; align-items: flex-end; }
103
+ .stat { display: flex; flex-direction: column; }
104
+ .stat-label { font-size: 10px; text-transform: uppercase; color: #555; letter-spacing: 0.5px; }
105
+ .stat-value { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
106
+ .stat-allowed { color: #22c55e; }
107
+ .stat-blocked { color: #ef4444; }
108
+ .stat-flagged { color: #f59e0b; }
109
+ .stat-error { color: #6b7280; }
110
+ .stat-sep { width: 1px; height: 32px; background: #222; margin: 0 4px; }
111
+ .info { padding: 10px 32px; font-size: 12px; color: #555; border-bottom: 1px solid #222; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
112
+ .info span { color: #888; }
113
+ .filters { display: flex; gap: 4px; padding: 12px 32px; border-bottom: 1px solid #222; }
114
+ .filter-btn { padding: 5px 14px; border: 1px solid #333; background: transparent; color: #888; border-radius: 4px; cursor: pointer; font-size: 12px; font-family: inherit; }
115
+ .filter-btn:hover { border-color: #555; color: #ccc; }
116
+ .filter-btn.active { background: #1a1a2e; border-color: #6366f1; color: #a5b4fc; }
117
+ .sparkline-container { padding: 8px 32px; border-bottom: 1px solid #222; }
118
+ .session-panel { padding: 14px 32px; border-bottom: 1px solid #222; display: flex; gap: 24px; flex-wrap: wrap; align-items: flex-start; }
119
+ .session-block { display: flex; flex-direction: column; gap: 4px; }
120
+ .session-title { font-size: 10px; text-transform: uppercase; color: #555; letter-spacing: 0.5px; }
121
+ .session-val { font-size: 13px; color: #ccc; font-family: 'SF Mono', 'Fira Code', monospace; }
122
+ .badge-sm { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 500; margin: 1px 2px; }
123
+ .badge-rule { background: #2d1b69; color: #c4b5fd; }
124
+ .badge-label { background: #1e3a5f; color: #7dd3fc; }
125
+ .badge-flag { background: #422006; color: #fbbf24; }
126
+ .badge-encoding { background: #431407; color: #fb923c; }
127
+ .badge-scope { background: #042f2e; color: #5eead4; }
128
+ .badge-degraded { background: #450a0a; color: #fca5a5; }
129
+ .badge-pii { background: #4a1d96; color: #d8b4fe; }
130
+ .badge-secret { background: #7f1d1d; color: #fca5a5; }
131
+ .table-wrap { max-height: 500px; overflow-y: auto; }
132
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
133
+ th { text-align: left; padding: 7px 12px; background: #111; color: #666; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; position: sticky; top: 0; z-index: 1; }
134
+ td { padding: 7px 12px; border-bottom: 1px solid #151515; vertical-align: top; }
135
+ tr.event-row { cursor: pointer; transition: background 0.1s; }
136
+ tr.event-row:hover { background: #111; }
137
+ .mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; }
138
+ .params-cell { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #666; }
139
+ .status-allowed { color: #22c55e; font-weight: 600; }
140
+ .status-blocked { color: #ef4444; font-weight: 600; }
141
+ .status-flagged { color: #f59e0b; font-weight: 600; }
142
+ .status-error { color: #6b7280; font-weight: 600; }
143
+ .detail-row { display: none; }
144
+ .detail-row.open { display: table-row; }
145
+ .detail-cell { padding: 12px 16px; background: #0d0d14; border-bottom: 1px solid #1a1a2e; }
146
+ .detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; }
147
+ .detail-section { display: flex; flex-direction: column; gap: 3px; }
148
+ .detail-label { font-size: 10px; text-transform: uppercase; color: #444; letter-spacing: 0.3px; }
149
+ .detail-val { font-size: 12px; color: #bbb; }
150
+ .detail-params { max-height: 180px; overflow: auto; background: #080810; border: 1px solid #1a1a2e; border-radius: 4px; padding: 8px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; color: #888; white-space: pre-wrap; word-break: break-all; margin-top: 6px; }
151
+ .empty { padding: 48px; text-align: center; color: #333; }
152
+ .actions { margin-left: auto; display: flex; gap: 8px; }
153
+ .btn { padding: 5px 12px; border: 1px solid #333; background: #111; color: #aaa; border-radius: 4px; cursor: pointer; font-size: 11px; font-family: inherit; transition: all 0.15s; }
154
+ .btn:hover { background: #1a1a2e; border-color: #6366f1; color: #c4b5fd; }
155
+ .btn-copy.copied { background: #1a472a; border-color: #22c55e; color: #4ade80; }
156
+ .demo-panel { padding: 16px 32px; border-bottom: 1px solid #222; }
157
+ .demo-title { font-size: 13px; font-weight: 600; color: #a5b4fc; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
158
+ .demo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
159
+ .demo-card { padding: 8px 12px; border: 1px solid #222; border-radius: 6px; cursor: pointer; transition: all 0.15s; }
160
+ .demo-card:hover { border-color: #6366f1; background: #0d0d14; }
161
+ .demo-card.running { opacity: 0.5; pointer-events: none; }
162
+ .demo-card-name { font-size: 12px; font-weight: 600; color: #ccc; }
163
+ .demo-card-desc { font-size: 10px; color: #555; margin-top: 2px; }
164
+ .demo-card-result { font-size: 10px; margin-top: 4px; font-family: 'SF Mono', monospace; }
165
+ .class-malicious { color: #ef4444; }
166
+ .class-suspicious { color: #f59e0b; }
167
+ .class-benign { color: #22c55e; }
168
+ @media (max-width: 768px) {
169
+ .stats { gap: 12px; padding: 10px 16px; }
170
+ .stat-value { font-size: 18px; }
171
+ .info, .filters, .session-panel, .demo-panel, .sparkline-container { padding-left: 16px; padding-right: 16px; }
172
+ .header { padding: 16px; }
173
+ .detail-grid { grid-template-columns: 1fr; }
174
+ .demo-grid { grid-template-columns: 1fr; }
175
+ }
176
+ </style>
177
+ </head>
178
+ <body>
179
+ <div class="header">
180
+ <h1>Clampd MCP Proxy</h1>
181
+ <span class="badge ${modeLabel === "LIVE" ? "badge-live" : "badge-dryrun"}">${modeLabel}</span>
182
+ <div class="actions">
183
+ <button class="btn" onclick="location.reload()" id="refreshBtn">Refresh</button>
184
+ <button class="btn btn-copy" onclick="copyReport()" id="copyBtn">Copy Report</button>
185
+ <button class="btn" onclick="downloadJSON()">Download JSON</button>
186
+ </div>
187
+ </div>
188
+ <div class="stats">
189
+ <div class="stat">
190
+ <span class="stat-label">Allowed</span>
191
+ <span class="stat-value stat-allowed">${allowed}</span>
192
+ </div>
193
+ <div class="stat">
194
+ <span class="stat-label">Blocked</span>
195
+ <span class="stat-value stat-blocked">${blocked}</span>
196
+ </div>
197
+ <div class="stat">
198
+ <span class="stat-label">Flagged</span>
199
+ <span class="stat-value stat-flagged">${flagged}</span>
200
+ </div>
201
+ <div class="stat">
202
+ <span class="stat-label">Errors</span>
203
+ <span class="stat-value stat-error">${errors}</span>
204
+ </div>
205
+ <div class="stat-sep"></div>
206
+ <div class="stat">
207
+ <span class="stat-label">Total</span>
208
+ <span class="stat-value">${total}</span>
209
+ </div>
210
+ <div class="stat">
211
+ <span class="stat-label">Threat Rate</span>
212
+ <span class="stat-value stat-blocked">${threatRate}${total > 0 ? "%" : ""}</span>
213
+ </div>
214
+ <div class="stat">
215
+ <span class="stat-label">Rules Fired</span>
216
+ <span class="stat-value" style="color:#c4b5fd">${totalRulesFired}</span>
217
+ </div>
218
+ <div class="stat">
219
+ <span class="stat-label">Avg Latency</span>
220
+ <span class="stat-value" style="color:#888">${avgLatency}ms</span>
221
+ </div>
222
+ </div>
223
+ <div class="info">
224
+ Gateway: <span>${escapeHtml(opts.gatewayUrl)}</span> &nbsp;|&nbsp;
225
+ Agent: <span>${escapeHtml(opts.agentId)}</span> &nbsp;|&nbsp;
226
+ Port: <span>${opts.port}</span>
227
+ </div>
228
+ ${sessionHtml}
229
+ ${sparkline}
230
+ <div class="filters">
231
+ <button class="filter-btn active" onclick="filterEvents('all')">All (${total})</button>
232
+ <button class="filter-btn" onclick="filterEvents('allowed')">Allowed (${allowed})</button>
233
+ <button class="filter-btn" onclick="filterEvents('blocked')">Blocked (${blocked})</button>
234
+ <button class="filter-btn" onclick="filterEvents('flagged')">Flagged (${flagged})</button>
235
+ <button class="filter-btn" onclick="filterEvents('error')">Errors (${errors})</button>
236
+ </div>
237
+ ${demoHtml}
238
+ <div class="table-wrap">
239
+ <table>
240
+ <thead>
241
+ <tr>
242
+ <th>Time</th>
243
+ <th>Tool</th>
244
+ <th>Status</th>
245
+ <th>Risk</th>
246
+ <th>Rules</th>
247
+ <th>Latency</th>
248
+ <th>Reason</th>
249
+ </tr>
250
+ </thead>
251
+ <tbody id="eventBody">
252
+ ${rows || '<tr><td colspan="7" class="empty">No tool calls yet. Connect Claude Desktop to http://localhost:' + opts.port + '/sse</td></tr>'}
253
+ </tbody>
254
+ </table>
255
+ </div>
256
+ <script>
257
+ // SSE — track new event count, show badge on refresh button
258
+ const evtSource = new EventSource('/events');
259
+ let newCount = 0;
260
+ evtSource.onmessage = function() {
261
+ newCount++;
262
+ const btn = document.getElementById('refreshBtn');
263
+ if (btn) btn.textContent = 'Refresh (' + newCount + ' new)';
264
+ };
265
+
266
+ // Filter events by status
267
+ function filterEvents(status) {
268
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
269
+ event.target.classList.add('active');
270
+ document.querySelectorAll('.event-row').forEach(row => {
271
+ const detail = row.nextElementSibling;
272
+ if (status === 'all' || row.dataset.status === status) {
273
+ row.style.display = '';
274
+ // keep detail state
275
+ } else {
276
+ row.style.display = 'none';
277
+ if (detail && detail.classList.contains('detail-row')) detail.style.display = 'none';
278
+ }
279
+ });
280
+ }
281
+
282
+ // Toggle detail row
283
+ function toggleDetail(idx) {
284
+ const detail = document.getElementById('detail-' + idx);
285
+ if (detail) detail.classList.toggle('open');
286
+ }
287
+
288
+ // Copy markdown report
289
+ function copyReport() {
290
+ const report = document.getElementById('reportData').textContent;
291
+ navigator.clipboard.writeText(report).then(() => {
292
+ const btn = document.getElementById('copyBtn');
293
+ btn.textContent = 'Copied!';
294
+ btn.classList.add('copied');
295
+ setTimeout(() => { btn.textContent = 'Copy Report'; btn.classList.remove('copied'); }, 2000);
296
+ });
297
+ }
298
+
299
+ // Download JSON
300
+ function downloadJSON() {
301
+ const data = document.getElementById('jsonData').textContent;
302
+ const blob = new Blob([data], { type: 'application/json' });
303
+ const url = URL.createObjectURL(blob);
304
+ const a = document.createElement('a');
305
+ a.href = url;
306
+ a.download = 'clampd-proxy-events-' + new Date().toISOString().slice(0,19).replace(/:/g,'-') + '.json';
307
+ a.click();
308
+ URL.revokeObjectURL(url);
309
+ }
310
+
311
+ // Demo attack buttons
312
+ function runDemo(attackId) {
313
+ const card = document.getElementById('demo-' + attackId);
314
+ if (!card) return;
315
+ card.classList.add('running');
316
+ fetch('/demo/attack', {
317
+ method: 'POST',
318
+ headers: { 'Content-Type': 'application/json' },
319
+ body: JSON.stringify({ attack_id: attackId }),
320
+ })
321
+ .then(r => r.json())
322
+ .then(result => {
323
+ card.classList.remove('running');
324
+ const resultEl = card.querySelector('.demo-card-result');
325
+ if (resultEl) {
326
+ const color = result.status === 'blocked' ? '#ef4444' : result.status === 'allowed' ? '#22c55e' : '#6b7280';
327
+ resultEl.innerHTML = '<span style="color:' + color + '">' + result.status.toUpperCase() + '</span> risk=' + (result.risk_score || 0).toFixed(2) + (result.matched_rules?.length ? ' [' + result.matched_rules.join(', ') + ']' : '');
328
+ }
329
+ // Update refresh button badge
330
+ newCount++;
331
+ const rbtn = document.getElementById('refreshBtn');
332
+ if (rbtn) rbtn.textContent = 'Refresh (' + newCount + ' new)';
333
+ })
334
+ .catch(() => { card.classList.remove('running'); });
335
+ }
336
+ </script>
337
+ <div style="display:none">
338
+ <pre id="reportData">${escapeHtml(generateReport(events, opts))}</pre>
339
+ <pre id="jsonData">${escapeHtml(JSON.stringify(events, null, 2))}</pre>
340
+ </div>
341
+ </body>
342
+ </html>`;
343
+ }
344
+
345
+ // ── Event Row (expandable) ───────────────────────────────────────────
346
+
347
+ function renderEventRow(e: ProxyEvent, idx: number): string {
348
+ const statusClass = e.status === "blocked" ? "status-blocked" : e.status === "flagged" ? "status-flagged" : e.status === "error" ? "status-error" : "status-allowed";
349
+ const rules = e.matched_rules?.map((r) => `<span class="badge-sm badge-rule">${escapeHtml(r)}</span>`).join("") ?? "-";
350
+ const time = e.timestamp.split("T")[1]?.slice(0, 12) ?? e.timestamp;
351
+
352
+ // Detail panel content
353
+ const classColor = e.classification === "Malicious" ? "class-malicious" : e.classification === "Suspicious" ? "class-suspicious" : "class-benign";
354
+
355
+ const detailSections: string[] = [];
356
+
357
+ if (e.classification) {
358
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Classification</span><span class="detail-val ${classColor}">${escapeHtml(e.classification)}</span></div>`);
359
+ }
360
+ if (e.action) {
361
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Action</span><span class="detail-val">${escapeHtml(e.action)}</span></div>`);
362
+ }
363
+ if (e.reasoning) {
364
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Reasoning</span><span class="detail-val">${escapeHtml(e.reasoning)}</span></div>`);
365
+ }
366
+ if (e.intent_labels?.length) {
367
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Intent Labels</span><span class="detail-val">${e.intent_labels.map((l) => `<span class="badge-sm badge-label">${escapeHtml(l)}</span>`).join("")}</span></div>`);
368
+ }
369
+ if (e.session_flags?.length) {
370
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Session Flags</span><span class="detail-val">${e.session_flags.map((f) => `<span class="badge-sm badge-flag">${escapeHtml(f)}</span>`).join("")}</span></div>`);
371
+ }
372
+ if (e.encodings_detected?.length) {
373
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Encodings Detected</span><span class="detail-val">${e.encodings_detected.map((enc) => `<span class="badge-sm badge-encoding">${escapeHtml(enc)}</span>`).join("")}</span></div>`);
374
+ }
375
+ if (e.scope_granted) {
376
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Scope Granted</span><span class="detail-val"><span class="badge-sm badge-scope">${escapeHtml(e.scope_granted)}</span></span></div>`);
377
+ }
378
+ if (e.degraded_stages?.length) {
379
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Degraded Stages</span><span class="detail-val">${e.degraded_stages.map((s) => `<span class="badge-sm badge-degraded">${escapeHtml(s)}</span>`).join("")}</span></div>`);
380
+ }
381
+ if (e.scan_details?.pii_found?.length) {
382
+ detailSections.push(`<div class="detail-section"><span class="detail-label">PII Found</span><span class="detail-val">${e.scan_details.pii_found.map((p) => `<span class="badge-sm badge-pii">${escapeHtml(p.pii_type)} (${p.count})</span>`).join("")}</span></div>`);
383
+ }
384
+ if (e.scan_details?.secrets_found?.length) {
385
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Secrets Found</span><span class="detail-val">${e.scan_details.secrets_found.map((s) => `<span class="badge-sm badge-secret">${escapeHtml(s.secret_type)} (${s.count})</span>`).join("")}</span></div>`);
386
+ }
387
+ if (e.descriptor_hash) {
388
+ detailSections.push(`<div class="detail-section"><span class="detail-label">Descriptor Hash</span><span class="detail-val mono">${escapeHtml(e.descriptor_hash.slice(0, 16))}...</span></div>`);
389
+ }
390
+
391
+ const reasonText = e.reason ? escapeHtml(e.reason.length > 60 ? e.reason.slice(0, 60) + "..." : e.reason) : "-";
392
+
393
+ const hasDetail = detailSections.length > 0 || e.params.length > 5;
394
+ const expandIcon = hasDetail ? `<span style="color:#444;font-size:10px">&#9654;</span> ` : "";
395
+
396
+ // Pretty-print params for detail view
397
+ let prettyParams = e.params;
398
+ try {
399
+ prettyParams = JSON.stringify(JSON.parse(e.params), null, 2);
400
+ } catch { /* keep as-is */ }
401
+
402
+ return `
403
+ <tr class="event-row" data-status="${e.status}" onclick="toggleDetail(${idx})">
404
+ <td class="mono">${expandIcon}${escapeHtml(time)}</td>
405
+ <td><strong>${escapeHtml(e.tool)}</strong></td>
406
+ <td class="${statusClass}">${e.status.toUpperCase()}</td>
407
+ <td class="mono">${e.risk_score.toFixed(2)}</td>
408
+ <td>${rules}</td>
409
+ <td class="mono">${e.latency_ms}ms</td>
410
+ <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#888">${reasonText}</td>
411
+ </tr>
412
+ <tr class="detail-row" id="detail-${idx}">
413
+ <td colspan="7" class="detail-cell">
414
+ <div class="detail-grid">${detailSections.join("")}</div>
415
+ ${e.params.length > 2 ? `<div class="detail-params">${escapeHtml(prettyParams)}</div>` : ""}
416
+ </td>
417
+ </tr>`;
418
+ }
419
+
420
+ // ── Risk Sparkline SVG ───────────────────────────────────────────────
421
+
422
+ function renderSparkline(events: ProxyEvent[]): string {
423
+ if (events.length === 0) return "";
424
+
425
+ const width = 800;
426
+ const height = 60;
427
+ const padding = 4;
428
+ const count = Math.min(events.length, 50);
429
+ const data = events.slice(0, count).reverse(); // oldest first for chart
430
+
431
+ const stepX = (width - padding * 2) / Math.max(count - 1, 1);
432
+
433
+ let points = "";
434
+ let dots = "";
435
+ for (let i = 0; i < data.length; i++) {
436
+ const x = padding + i * stepX;
437
+ const y = height - padding - data[i].risk_score * (height - padding * 2);
438
+ points += `${x},${y} `;
439
+
440
+ const color = data[i].status === "blocked" ? "#ef4444" : data[i].risk_score > 0.85 ? "#ef4444" : data[i].risk_score > 0.5 ? "#f59e0b" : "#22c55e";
441
+ const r = data[i].status === "blocked" ? 4 : 3;
442
+ dots += `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="0.8"/>`;
443
+
444
+ if (data[i].status === "blocked") {
445
+ // X marker for blocked
446
+ dots += `<line x1="${x - 3}" y1="${y - 3}" x2="${x + 3}" y2="${y + 3}" stroke="#ef4444" stroke-width="1.5"/>`;
447
+ dots += `<line x1="${x + 3}" y1="${y - 3}" x2="${x - 3}" y2="${y + 3}" stroke="#ef4444" stroke-width="1.5"/>`;
448
+ }
449
+ }
450
+
451
+ return `<div class="sparkline-container">
452
+ <svg viewBox="0 0 ${width} ${height}" width="100%" height="${height}" preserveAspectRatio="none">
453
+ <rect width="${width}" height="${height}" fill="#050508" rx="4"/>
454
+ <line x1="${padding}" y1="${height - padding - 0.5 * (height - padding * 2)}" x2="${width - padding}" y2="${height - padding - 0.5 * (height - padding * 2)}" stroke="#1a1a1a" stroke-width="0.5" stroke-dasharray="4"/>
455
+ <line x1="${padding}" y1="${height - padding - 0.85 * (height - padding * 2)}" x2="${width - padding}" y2="${height - padding - 0.85 * (height - padding * 2)}" stroke="#2a1515" stroke-width="0.5" stroke-dasharray="4"/>
456
+ <polyline points="${points}" fill="none" stroke="#6366f1" stroke-width="1.5" opacity="0.5"/>
457
+ ${dots}
458
+ <text x="${padding}" y="10" fill="#333" font-size="8" font-family="sans-serif">1.0</text>
459
+ <text x="${padding}" y="${height - 2}" fill="#333" font-size="8" font-family="sans-serif">0.0</text>
460
+ </svg>
461
+ </div>`;
462
+ }
463
+
464
+ // ── Session Summary ──────────────────────────────────────────────────
465
+
466
+ function renderSessionSummary(stats: SessionStats): string {
467
+ const duration = stats.firstCallAt && stats.lastCallAt
468
+ ? formatDuration(new Date(stats.lastCallAt).getTime() - new Date(stats.firstCallAt).getTime())
469
+ : "—";
470
+ const avgRisk = stats.toolCallCount > 0 ? (stats.totalRisk / stats.toolCallCount).toFixed(2) : "0.00";
471
+ const topRules = Object.entries(stats.rulesTriggered)
472
+ .sort(([, a], [, b]) => b - a)
473
+ .slice(0, 5)
474
+ .map(([rule, count]) => `<span class="badge-sm badge-rule">${escapeHtml(rule)} (${count})</span>`)
475
+ .join("");
476
+
477
+ const indicators: string[] = [];
478
+ if (stats.piiDetected) indicators.push(`<span class="badge-sm badge-pii">PII Detected</span>`);
479
+ if (stats.secretsDetected) indicators.push(`<span class="badge-sm badge-secret">Secrets Detected</span>`);
480
+
481
+ return `<div class="session-panel">
482
+ <div class="session-block"><span class="session-title">Session Duration</span><span class="session-val">${duration}</span></div>
483
+ <div class="session-block"><span class="session-title">Avg Risk</span><span class="session-val">${avgRisk}</span></div>
484
+ <div class="session-block"><span class="session-title">Unique Tools (${stats.uniqueTools.length})</span><span class="session-val">${stats.uniqueTools.slice(0, 8).map((t) => escapeHtml(t)).join(", ") || "—"}</span></div>
485
+ <div class="session-block"><span class="session-title">Top Rules</span><span class="session-val">${topRules || "—"}</span></div>
486
+ ${indicators.length ? `<div class="session-block"><span class="session-title">Alerts</span><span class="session-val">${indicators.join(" ")}</span></div>` : ""}
487
+ </div>`;
488
+ }
489
+
490
+ // ── Demo Panel ───────────────────────────────────────────────────────
491
+
492
+ function renderDemoPanel(opts: ProxyOptions): string {
493
+ const attacks = [
494
+ { id: "sql_injection", name: "SQL Injection", desc: 'DROP TABLE users via database.query' },
495
+ { id: "path_traversal", name: "Path Traversal", desc: '../../etc/passwd via read_file' },
496
+ { id: "prompt_injection", name: "Prompt Injection", desc: 'IGNORE ALL INSTRUCTIONS via write_file' },
497
+ { id: "ssrf", name: "SSRF", desc: '169.254.169.254 metadata via http_request' },
498
+ { id: "reverse_shell", name: "Reverse Shell", desc: '#!/bin/bash >& /dev/tcp/ via write_file' },
499
+ { id: "schema_injection", name: "Schema Injection", desc: '<functions> XML tag injection' },
500
+ { id: "encoded_attack", name: "Encoded Attack", desc: 'Base64-encoded rm -rf /' },
501
+ { id: "safe_call", name: "Safe Call", desc: 'Normal read_file /tmp/report.txt' },
502
+ ];
503
+
504
+ const cards = attacks.map((a) => `
505
+ <div class="demo-card" id="demo-${a.id}" onclick="runDemo('${a.id}')">
506
+ <div class="demo-card-name">${escapeHtml(a.name)}</div>
507
+ <div class="demo-card-desc">${escapeHtml(a.desc)}</div>
508
+ <div class="demo-card-result"></div>
509
+ </div>`).join("");
510
+
511
+ return `<div class="demo-panel">
512
+ <div class="demo-title">Demo Attacks <span class="badge badge-dryrun" style="font-size:9px">click to test</span></div>
513
+ <div class="demo-grid">${cards}</div>
514
+ </div>`;
515
+ }
516
+
517
+ // ── Report Generation ────────────────────────────────────────────────
518
+
519
+ function generateReport(events: ProxyEvent[], opts: ProxyOptions): string {
520
+ const blocked = events.filter((e) => e.status === "blocked");
521
+ const flagged = events.filter((e) => e.status === "flagged");
522
+ const total = events.length;
523
+ const avgLatency = total > 0 ? Math.round(events.reduce((s, e) => s + e.latency_ms, 0) / total) : 0;
524
+ const threatRate = total > 0 ? (((blocked.length + flagged.length) / total) * 100).toFixed(1) : "0";
525
+
526
+ // Count rules
527
+ const ruleCounts: Record<string, number> = {};
528
+ for (const e of events) {
529
+ for (const r of e.matched_rules ?? []) {
530
+ ruleCounts[r] = (ruleCounts[r] ?? 0) + 1;
531
+ }
532
+ }
533
+ const ruleRows = Object.entries(ruleCounts)
534
+ .sort(([, a], [, b]) => b - a)
535
+ .map(([rule, count]) => `| ${rule} | ${count} |`)
536
+ .join("\n");
537
+
538
+ const blockedRows = blocked
539
+ .slice(0, 20)
540
+ .map((e) => {
541
+ const time = e.timestamp.split("T")[1]?.slice(0, 8) ?? "";
542
+ const rules = e.matched_rules?.join(", ") ?? "";
543
+ return `| ${time} | ${e.tool} | ${e.risk_score.toFixed(2)} | ${rules} | ${(e.reason ?? "").slice(0, 50)} |`;
544
+ })
545
+ .join("\n");
546
+
547
+ return `# Clampd MCP Proxy Security Report
548
+ **Agent:** ${opts.agentId} | **Gateway:** ${opts.gatewayUrl}
549
+ **Generated:** ${new Date().toISOString()}
550
+
551
+ ## Summary
552
+ - Allowed: ${total - blocked.length - flagged.length} | Blocked: ${blocked.length} | Flagged: ${flagged.length}
553
+ - Threat Rate: ${threatRate}%
554
+ - Avg Latency: ${avgLatency}ms
555
+ - Total Calls: ${total}
556
+
557
+ ## Rules Triggered
558
+ | Rule | Count |
559
+ |------|-------|
560
+ ${ruleRows || "| — | — |"}
561
+
562
+ ## Blocked Calls
563
+ | Time | Tool | Risk | Rules | Reason |
564
+ |------|------|------|-------|--------|
565
+ ${blockedRows || "| — | — | — | — | — |"}
566
+ `;
567
+ }
568
+
569
+ // ── Utilities ─────────────────────────────────────────────────────────
570
+
571
+ function formatDuration(ms: number): string {
572
+ if (ms < 1000) return `${ms}ms`;
573
+ const secs = Math.floor(ms / 1000);
574
+ if (secs < 60) return `${secs}s`;
575
+ const mins = Math.floor(secs / 60);
576
+ if (mins < 60) return `${mins}m ${secs % 60}s`;
577
+ return `${Math.floor(mins / 60)}h ${mins % 60}m`;
578
+ }
579
+
580
+ function escapeHtml(str: string): string {
581
+ return str
582
+ .replace(/&/g, "&amp;")
583
+ .replace(/</g, "&lt;")
584
+ .replace(/>/g, "&gt;")
585
+ .replace(/"/g, "&quot;");
586
+ }
587
+
588
+ // ── Serve ─────────────────────────────────────────────────────────────
589
+
590
+ export function serveDashboard(
591
+ _req: IncomingMessage,
592
+ res: ServerResponse,
593
+ events: ProxyEvent[],
594
+ opts: ProxyOptions & { demoPanel?: boolean; sessionStats?: SessionStats },
595
+ ): void {
596
+ const html = renderDashboard(events, opts);
597
+ res.writeHead(200, {
598
+ "Content-Type": "text/html; charset=utf-8",
599
+ "Cache-Control": "no-cache",
600
+ });
601
+ res.end(html);
602
+ }