agenthusk 0.1.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.
package/src/report.js ADDED
@@ -0,0 +1,895 @@
1
+ const SEVERITIES = ["critical", "high", "medium", "low", "info"];
2
+ const SEVERITY_LABELS = {
3
+ critical: "Critical",
4
+ high: "High",
5
+ medium: "Medium",
6
+ low: "Low",
7
+ info: "Info"
8
+ };
9
+ const GUARANTEE = "Matched content values are excluded from content-derived report fields. Paths are anonymized by default; review metadata before sharing.";
10
+ const RAW_PATH_WARNING = "Unsafe path mode is active. Raw source paths are included for private local review and can contain sensitive text. Do not share this report.";
11
+ const HIDDEN_VALUE = "<hidden>";
12
+
13
+ const VALUE_PATTERNS = [
14
+ [/https:\/\/(?:discord|discordapp)\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/gi, HIDDEN_VALUE],
15
+ [/\bgh[pousr]_[A-Za-z0-9_]{30,}\b/g, HIDDEN_VALUE],
16
+ [/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g, HIDDEN_VALUE],
17
+ [/\bsk-(?!ant-)[A-Za-z0-9_-]{20,}\b/g, HIDDEN_VALUE],
18
+ [/\bAIza[A-Za-z0-9_-]{30,}\b/g, HIDDEN_VALUE],
19
+ [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, HIDDEN_VALUE],
20
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, HIDDEN_VALUE],
21
+ [/\b(Bearer\s+)[A-Za-z0-9._~+/=-]{16,}/gi, `$1${HIDDEN_VALUE}`],
22
+ [
23
+ /\b((?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|passwd|webhook[_-]?url)\b\s*[:=]\s*["']?)[A-Za-z0-9_./+:=@-]{8,}/gi,
24
+ `$1${HIDDEN_VALUE}`
25
+ ]
26
+ ];
27
+
28
+ function clamp(value, minimum, maximum) {
29
+ return Math.min(Math.max(value, minimum), maximum);
30
+ }
31
+
32
+ function finiteNumber(value, fallback = 0) {
33
+ const number = Number(value);
34
+ return Number.isFinite(number) ? number : fallback;
35
+ }
36
+
37
+ function integer(value, fallback = 0) {
38
+ return Math.max(0, Math.round(finiteNumber(value, fallback)));
39
+ }
40
+
41
+ function redactValues(value, fallback = "") {
42
+ let text = value == null ? fallback : String(value);
43
+ for (const [pattern, replacement] of VALUE_PATTERNS) {
44
+ pattern.lastIndex = 0;
45
+ text = text.replace(pattern, replacement);
46
+ }
47
+ return text;
48
+ }
49
+
50
+ function escapeHtml(value) {
51
+ return redactValues(value)
52
+ .replace(/&/g, "&amp;")
53
+ .replace(/</g, "&lt;")
54
+ .replace(/>/g, "&gt;")
55
+ .replace(/"/g, "&quot;")
56
+ .replace(/'/g, "&#39;");
57
+ }
58
+
59
+ function safeJson(value) {
60
+ return JSON.stringify(value)
61
+ .replace(/</g, "\\u003c")
62
+ .replace(/>/g, "\\u003e")
63
+ .replace(/&/g, "\\u0026")
64
+ .replace(/\u2028/g, "\\u2028")
65
+ .replace(/\u2029/g, "\\u2029");
66
+ }
67
+
68
+ function safeColor(value) {
69
+ const color = String(value ?? "");
70
+ return /^#[0-9a-f]{6}$/i.test(color) ? color : "#e27650";
71
+ }
72
+
73
+ function safeFingerprint(value) {
74
+ const fingerprint = String(value ?? "");
75
+ return /^[a-f0-9]{6,64}$/i.test(fingerprint) ? fingerprint : "unavailable";
76
+ }
77
+
78
+ function safeSeverity(value) {
79
+ return SEVERITIES.includes(value) ? value : "info";
80
+ }
81
+
82
+ function riskFromScore(score) {
83
+ if (score >= 75) return "critical";
84
+ if (score >= 45) return "high";
85
+ if (score >= 20) return "guarded";
86
+ return "low";
87
+ }
88
+
89
+ function normalizeStats(stats = {}) {
90
+ return {
91
+ filesVisited: integer(stats.filesVisited),
92
+ directoriesVisited: integer(stats.directoriesVisited),
93
+ entriesVisited: integer(stats.entriesVisited),
94
+ bytesVisited: integer(stats.bytesVisited),
95
+ bytesRead: integer(stats.bytesRead),
96
+ textFilesInspected: integer(stats.textFilesInspected),
97
+ binaryFilesSkipped: integer(stats.binaryFilesSkipped),
98
+ filesSkippedBySize: integer(stats.filesSkippedBySize),
99
+ symlinksSkipped: integer(stats.symlinksSkipped),
100
+ rootsMissing: integer(stats.rootsMissing),
101
+ rootsSkippedUnsafe: integer(stats.rootsSkippedUnsafe),
102
+ depthCapped: integer(stats.depthCapped),
103
+ findingsDropped: integer(stats.findingsDropped),
104
+ secretOccurrencesDropped: integer(stats.secretOccurrencesDropped),
105
+ coverageIncomplete: Boolean(stats.coverageIncomplete),
106
+ noFollowSupported: Boolean(stats.noFollowSupported),
107
+ permissionsSupported: Boolean(stats.permissionsSupported),
108
+ capped: Boolean(stats.capped)
109
+ };
110
+ }
111
+
112
+ function normalizeFinding(finding = {}, index) {
113
+ return {
114
+ id: redactValues(finding.id, `finding-${index + 1}`),
115
+ severity: safeSeverity(finding.severity),
116
+ category: redactValues(finding.category, "scan"),
117
+ title: redactValues(finding.title, "Untitled finding"),
118
+ detail: redactValues(finding.detail, "No additional detail was recorded."),
119
+ path: redactValues(finding.path, "unknown path"),
120
+ agent: redactValues(finding.agent, "unknown"),
121
+ agentLabel: redactValues(finding.agentLabel, "Unknown agent")
122
+ };
123
+ }
124
+
125
+ function normalizeReport(input = {}) {
126
+ const findings = Array.isArray(input.findings)
127
+ ? input.findings.map(normalizeFinding)
128
+ : [];
129
+ const severityCounts = Object.fromEntries(
130
+ SEVERITIES.map(severity => [
131
+ severity,
132
+ findings.filter(finding => finding.severity === severity).length
133
+ ])
134
+ );
135
+ const score = clamp(Math.round(finiteNumber(input.score)), 0, 100);
136
+ const pathsRedacted = input.pathsRedacted !== false;
137
+
138
+ return {
139
+ schemaVersion: integer(input.schemaVersion, 1),
140
+ generatedAt: redactValues(input.generatedAt, new Date().toISOString()),
141
+ home: redactValues(input.home, "~"),
142
+ pathsRedacted,
143
+ guarantee: pathsRedacted ? GUARANTEE : RAW_PATH_WARNING,
144
+ score,
145
+ risk: riskFromScore(score),
146
+ stats: normalizeStats(input.stats),
147
+ severityCounts,
148
+ agents: Array.isArray(input.agents)
149
+ ? input.agents.map((agent, index) => ({
150
+ id: redactValues(agent.id, `agent-${index + 1}`),
151
+ label: redactValues(agent.label, `Agent ${index + 1}`),
152
+ color: safeColor(agent.color),
153
+ path: redactValues(agent.path, "unknown path")
154
+ }))
155
+ : [],
156
+ findings,
157
+ secretOccurrences: Array.isArray(input.secretOccurrences)
158
+ ? input.secretOccurrences.map(occurrence => ({
159
+ agent: redactValues(occurrence.agent, "unknown"),
160
+ agentLabel: redactValues(occurrence.agentLabel, "Unknown agent"),
161
+ type: redactValues(occurrence.type, "Secret"),
162
+ fingerprint: safeFingerprint(occurrence.fingerprint),
163
+ path: redactValues(occurrence.path, "unknown path"),
164
+ line: integer(occurrence.line)
165
+ }))
166
+ : [],
167
+ duplicateSecrets: Array.isArray(input.duplicateSecrets)
168
+ ? input.duplicateSecrets.map(duplicate => ({
169
+ fingerprint: safeFingerprint(duplicate.fingerprint),
170
+ type: redactValues(duplicate.type, "Secret"),
171
+ files: Array.isArray(duplicate.files)
172
+ ? duplicate.files.map(file => redactValues(file, "unknown path"))
173
+ : [],
174
+ agents: Array.isArray(duplicate.agents)
175
+ ? duplicate.agents.map(agent => redactValues(agent, "Unknown agent"))
176
+ : []
177
+ }))
178
+ : []
179
+ };
180
+ }
181
+
182
+ function formatNumber(value) {
183
+ return integer(value).toLocaleString("en-US");
184
+ }
185
+
186
+ function formatBytes(value) {
187
+ const bytes = integer(value);
188
+ if (bytes < 1024) return `${bytes} B`;
189
+ const units = ["KB", "MB", "GB", "TB"];
190
+ let size = bytes / 1024;
191
+ let unit = units[0];
192
+ for (let index = 1; size >= 1024 && index < units.length; index += 1) {
193
+ size /= 1024;
194
+ unit = units[index];
195
+ }
196
+ return `${size >= 10 ? size.toFixed(0) : size.toFixed(1)} ${unit}`;
197
+ }
198
+
199
+ function formatTimestamp(value) {
200
+ const date = new Date(value);
201
+ if (Number.isNaN(date.getTime())) return "Unknown time";
202
+ return date.toISOString().replace("T", " ").replace(".000Z", " UTC");
203
+ }
204
+
205
+ function severityCards(report) {
206
+ return SEVERITIES.map((severity, index) => `
207
+ <article class="severity-card severity-card--${severity}" style="--delay:${index * 70}ms">
208
+ <span class="severity-card__label">${SEVERITY_LABELS[severity]}</span>
209
+ <strong>${formatNumber(report.severityCounts[severity])}</strong>
210
+ <span class="severity-card__rule"></span>
211
+ </article>`).join("");
212
+ }
213
+
214
+ function exposureAgents(report) {
215
+ const agents = new Map(report.agents.map(agent => [agent.id, agent]));
216
+ for (const finding of report.findings) {
217
+ if (!agents.has(finding.agent)) {
218
+ agents.set(finding.agent, {
219
+ id: finding.agent,
220
+ label: finding.agentLabel,
221
+ color: "#e27650",
222
+ path: "unlisted root"
223
+ });
224
+ }
225
+ }
226
+ return [...agents.values()];
227
+ }
228
+
229
+ function exposureMap(report) {
230
+ const agents = exposureAgents(report);
231
+ const exposure = agents.map(agent => {
232
+ const findings = report.findings.filter(finding => finding.agent === agent.id);
233
+ const fingerprints = report.secretOccurrences.filter(occurrence => occurrence.agent === agent.id);
234
+ return { ...agent, findings: findings.length, fingerprints: fingerprints.length };
235
+ });
236
+ const maximum = Math.max(1, ...exposure.map(agent => agent.findings + agent.fingerprints * 2));
237
+
238
+ if (exposure.length === 0) {
239
+ return `<div class="map-empty">No agent roots were discovered during this scan.</div>`;
240
+ }
241
+
242
+ return exposure.map((agent, index) => {
243
+ const signal = agent.findings + agent.fingerprints * 2;
244
+ const width = signal === 0 ? 6 : clamp(Math.round((signal / maximum) * 100), 12, 100);
245
+ return `
246
+ <article class="agent-row" style="--agent:${agent.color};--delay:${index * 85}ms">
247
+ <div class="agent-row__head">
248
+ <span class="agent-row__node"></span>
249
+ <div>
250
+ <strong>${escapeHtml(agent.label)}</strong>
251
+ <small>${escapeHtml(agent.path)}</small>
252
+ </div>
253
+ <span class="agent-row__total">${formatNumber(agent.findings)}</span>
254
+ </div>
255
+ <div class="agent-row__track"><i style="width:${width}%"></i></div>
256
+ <div class="agent-row__meta">
257
+ <span>${formatNumber(agent.findings)} findings</span>
258
+ <span>${formatNumber(agent.fingerprints)} fingerprints</span>
259
+ </div>
260
+ </article>`;
261
+ }).join("");
262
+ }
263
+
264
+ function duplicateCards(report) {
265
+ if (report.duplicateSecrets.length === 0) {
266
+ return `
267
+ <div class="empty-state">
268
+ <strong>No duplicate fingerprints.</strong>
269
+ <span>No redacted fingerprint appeared across multiple files.</span>
270
+ </div>`;
271
+ }
272
+
273
+ return report.duplicateSecrets.map(duplicate => `
274
+ <article class="duplicate-card">
275
+ <div class="duplicate-card__top">
276
+ <span>${escapeHtml(duplicate.type)}</span>
277
+ <code>${escapeHtml(duplicate.fingerprint)}</code>
278
+ </div>
279
+ <div class="duplicate-card__route">
280
+ ${duplicate.agents.map(agent => `<b>${escapeHtml(agent)}</b>`).join("<i>to</i>")}
281
+ </div>
282
+ <p>${formatNumber(duplicate.files.length)} files carry this value-hidden fingerprint.</p>
283
+ <ul>${duplicate.files.map(file => `<li>${escapeHtml(file)}</li>`).join("")}</ul>
284
+ </article>`).join("");
285
+ }
286
+
287
+ function findingRows(report) {
288
+ if (report.findings.length === 0) return "";
289
+
290
+ return report.findings.map(finding => {
291
+ const searchText = [
292
+ finding.severity,
293
+ finding.category,
294
+ finding.title,
295
+ finding.detail,
296
+ finding.path,
297
+ finding.agentLabel
298
+ ].join(" ").toLowerCase();
299
+ return `
300
+ <tr data-finding-row data-severity="${escapeHtml(finding.severity)}" data-category="${escapeHtml(finding.category)}" data-search="${escapeHtml(searchText)}">
301
+ <td><span class="severity-dot severity-dot--${finding.severity}"></span><b>${escapeHtml(SEVERITY_LABELS[finding.severity])}</b></td>
302
+ <td><span class="category-tag">${escapeHtml(finding.category)}</span></td>
303
+ <td>
304
+ <strong>${escapeHtml(finding.title)}</strong>
305
+ <p>${escapeHtml(finding.detail)}</p>
306
+ </td>
307
+ <td>
308
+ <span class="agent-inline">${escapeHtml(finding.agentLabel)}</span>
309
+ <code>${escapeHtml(finding.path)}</code>
310
+ </td>
311
+ </tr>`;
312
+ }).join("");
313
+ }
314
+
315
+ function categoryOptions(report) {
316
+ const categories = [...new Set(report.findings.map(finding => finding.category))].sort();
317
+ return categories
318
+ .map(category => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`)
319
+ .join("");
320
+ }
321
+
322
+ function stat(label, value, note = "") {
323
+ return `
324
+ <div class="scan-stat">
325
+ <span>${escapeHtml(label)}</span>
326
+ <b>${escapeHtml(value)}</b>
327
+ ${note ? `<small>${escapeHtml(note)}</small>` : ""}
328
+ </div>`;
329
+ }
330
+
331
+ /**
332
+ * Create a portable HTML report. Only the scanner's redacted report fields are
333
+ * retained, so unknown caller-supplied properties cannot leak into the file.
334
+ */
335
+ export function renderHtmlReport(input) {
336
+ const report = normalizeReport(input);
337
+ const categories = categoryOptions(report);
338
+ const timestamp = formatTimestamp(report.generatedAt);
339
+ const hiddenValues = report.secretOccurrences.length;
340
+ const scoreRotation = report.score * 3.6;
341
+
342
+ return `<!doctype html>
343
+ <html lang="en" data-schema-version="${report.schemaVersion}">
344
+ <head>
345
+ <meta charset="utf-8">
346
+ <meta name="viewport" content="width=device-width, initial-scale=1">
347
+ <meta name="color-scheme" content="light">
348
+ <title>AgentHusk // value-hidden forensic report</title>
349
+ <style>
350
+ :root {
351
+ color-scheme: light;
352
+ --paper: #f3ead8;
353
+ --paper-deep: #e8d8bd;
354
+ --paper-light: #fff9ed;
355
+ --ink: #171713;
356
+ --muted: #776f61;
357
+ --faint: rgba(23, 23, 19, .12);
358
+ --coral: #ed6a4d;
359
+ --coral-deep: #bd4a35;
360
+ --amber: #f3b63f;
361
+ --olive: #798149;
362
+ --sky: #6097a6;
363
+ --shadow: 0 18px 48px rgba(70, 45, 24, .14);
364
+ font-family: Georgia, "Times New Roman", serif;
365
+ background: var(--paper);
366
+ color: var(--ink);
367
+ }
368
+ * { box-sizing: border-box; }
369
+ html { scroll-behavior: smooth; }
370
+ body {
371
+ min-width: 320px;
372
+ margin: 0;
373
+ background:
374
+ linear-gradient(90deg, rgba(237, 106, 77, .08), transparent 24%, transparent 76%, rgba(243, 182, 63, .1)),
375
+ radial-gradient(circle at 15% 4%, rgba(255, 249, 237, .96), transparent 23rem),
376
+ var(--paper);
377
+ }
378
+ body::before {
379
+ position: fixed;
380
+ inset: 0;
381
+ z-index: -2;
382
+ content: "";
383
+ background-image:
384
+ linear-gradient(rgba(23, 23, 19, .045) 1px, transparent 1px),
385
+ linear-gradient(90deg, rgba(23, 23, 19, .045) 1px, transparent 1px);
386
+ background-size: 32px 32px;
387
+ mask-image: linear-gradient(to bottom, rgba(0,0,0,.7), rgba(0,0,0,.12));
388
+ }
389
+ .paper-noise {
390
+ position: fixed;
391
+ inset: 0;
392
+ z-index: -1;
393
+ width: 100%;
394
+ height: 100%;
395
+ opacity: .2;
396
+ pointer-events: none;
397
+ mix-blend-mode: multiply;
398
+ }
399
+ .shell { width: min(1420px, calc(100% - 40px)); margin: 0 auto; }
400
+ .mono, code, button, select, input, .eyebrow, .privacy-stamp, .brand small, .scan-stat span, .scan-stat small,
401
+ .severity-card__label, .agent-row__meta, .section-kicker, th, .result-count, .footer-note {
402
+ font-family: "Courier New", Courier, monospace;
403
+ }
404
+ .masthead {
405
+ display: flex;
406
+ align-items: center;
407
+ justify-content: space-between;
408
+ gap: 20px;
409
+ padding: 24px 0 18px;
410
+ border-bottom: 2px solid var(--ink);
411
+ }
412
+ .brand { display: flex; align-items: baseline; gap: 13px; letter-spacing: -.06em; }
413
+ .brand b { font-size: clamp(2rem, 4vw, 3.55rem); line-height: .8; }
414
+ .brand b::first-letter { color: var(--coral); }
415
+ .brand small { color: var(--muted); font-size: .62rem; font-weight: 700; letter-spacing: .16em; }
416
+ .privacy-stamp {
417
+ padding: 8px 11px 7px;
418
+ border: 1px solid var(--coral-deep);
419
+ color: var(--coral-deep);
420
+ font-size: .62rem;
421
+ font-weight: 700;
422
+ letter-spacing: .14em;
423
+ text-transform: uppercase;
424
+ transform: rotate(-1deg);
425
+ }
426
+ .hero {
427
+ display: grid;
428
+ grid-template-columns: 1.34fr .66fr;
429
+ gap: 28px;
430
+ padding: 72px 0 34px;
431
+ }
432
+ .eyebrow, .section-kicker {
433
+ display: flex;
434
+ align-items: center;
435
+ gap: 9px;
436
+ color: var(--coral-deep);
437
+ font-size: .67rem;
438
+ font-weight: 700;
439
+ letter-spacing: .16em;
440
+ text-transform: uppercase;
441
+ }
442
+ .eyebrow::before, .section-kicker::before { width: 35px; height: 2px; content: ""; background: currentColor; }
443
+ h1 {
444
+ max-width: 860px;
445
+ margin: 14px 0 18px;
446
+ font-size: clamp(4.8rem, 11vw, 10rem);
447
+ font-weight: 700;
448
+ letter-spacing: -.105em;
449
+ line-height: .8;
450
+ }
451
+ h1 em { color: var(--coral); font-style: italic; font-weight: 400; }
452
+ .hero-copy {
453
+ max-width: 760px;
454
+ margin: 0;
455
+ color: #514b41;
456
+ font-size: clamp(1rem, 1.8vw, 1.35rem);
457
+ line-height: 1.5;
458
+ }
459
+ .guarantee {
460
+ display: flex;
461
+ gap: 12px;
462
+ max-width: 760px;
463
+ margin-top: 28px;
464
+ padding: 15px 17px;
465
+ border: 1px solid var(--ink);
466
+ background: rgba(255, 249, 237, .62);
467
+ box-shadow: 6px 6px 0 var(--amber);
468
+ font-size: .88rem;
469
+ line-height: 1.45;
470
+ }
471
+ .guarantee b { flex: none; color: var(--coral-deep); font-family: "Courier New", Courier, monospace; font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; }
472
+ .score-card {
473
+ position: relative;
474
+ align-self: end;
475
+ min-height: 335px;
476
+ overflow: hidden;
477
+ padding: 24px;
478
+ border: 2px solid var(--ink);
479
+ background: var(--ink);
480
+ color: var(--paper-light);
481
+ box-shadow: 11px 11px 0 var(--coral);
482
+ }
483
+ .score-card::after {
484
+ position: absolute;
485
+ right: -68px;
486
+ bottom: -94px;
487
+ width: 230px;
488
+ height: 230px;
489
+ border: 1px solid rgba(243, 182, 63, .25);
490
+ border-radius: 50%;
491
+ content: "";
492
+ box-shadow: 0 0 0 28px rgba(243, 182, 63, .06), 0 0 0 55px rgba(243, 182, 63, .035);
493
+ }
494
+ .score-head { display: flex; justify-content: space-between; gap: 12px; color: var(--amber); font-family: "Courier New", Courier, monospace; font-size: .67rem; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
495
+ .score-ring {
496
+ display: grid;
497
+ width: 196px;
498
+ height: 196px;
499
+ margin: 20px auto 8px;
500
+ border-radius: 50%;
501
+ background: conic-gradient(var(--coral) ${scoreRotation}deg, rgba(255,255,255,.12) 0);
502
+ place-items: center;
503
+ }
504
+ .score-ring::before { width: 152px; height: 152px; border: 1px solid rgba(255,255,255,.15); border-radius: inherit; background: var(--ink); content: ""; grid-area: 1 / 1; }
505
+ .score-value { z-index: 1; grid-area: 1 / 1; font-size: 5.1rem; font-weight: 700; letter-spacing: -.11em; line-height: 1; transform: translateX(-.08em); }
506
+ .score-foot { position: relative; z-index: 1; display: flex; justify-content: space-between; border-top: 1px solid rgba(255,255,255,.22); padding-top: 12px; font-family: "Courier New", Courier, monospace; font-size: .68rem; letter-spacing: .1em; text-transform: uppercase; }
507
+ .score-foot b { color: var(--coral); }
508
+ .stat-strip {
509
+ display: grid;
510
+ grid-template-columns: repeat(7, 1fr);
511
+ margin: 18px 0 52px;
512
+ border-block: 1px solid var(--ink);
513
+ }
514
+ .scan-stat { min-width: 0; padding: 15px 14px 13px; border-right: 1px solid var(--faint); }
515
+ .scan-stat:last-child { border-right: 0; }
516
+ .scan-stat span, .scan-stat small { display: block; overflow: hidden; color: var(--muted); font-size: .61rem; letter-spacing: .08em; text-overflow: ellipsis; text-transform: uppercase; white-space: nowrap; }
517
+ .scan-stat b { display: block; margin: 7px 0 5px; font-size: 1.8rem; letter-spacing: -.07em; }
518
+ .section { padding: 28px 0 52px; }
519
+ .section-head { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
520
+ h2 { max-width: 800px; margin: 7px 0 0; font-size: clamp(2.25rem, 5vw, 4.45rem); letter-spacing: -.075em; line-height: .96; }
521
+ .section-note { max-width: 390px; margin: 0; color: var(--muted); font-size: .88rem; line-height: 1.45; }
522
+ .severity-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
523
+ .severity-card {
524
+ position: relative;
525
+ min-height: 166px;
526
+ padding: 15px;
527
+ border: 1px solid var(--ink);
528
+ background: rgba(255, 249, 237, .72);
529
+ box-shadow: 4px 4px 0 rgba(23, 23, 19, .14);
530
+ animation: lift .55s both;
531
+ animation-delay: var(--delay);
532
+ }
533
+ .severity-card::before { position: absolute; inset: 0 0 auto; height: 5px; content: ""; background: var(--tone); }
534
+ .severity-card--critical { --tone: var(--coral-deep); }
535
+ .severity-card--high { --tone: var(--coral); }
536
+ .severity-card--medium { --tone: var(--amber); }
537
+ .severity-card--low { --tone: var(--olive); }
538
+ .severity-card--info { --tone: var(--sky); }
539
+ .severity-card__label { color: var(--muted); font-size: .65rem; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
540
+ .severity-card strong { display: block; margin-top: 17px; font-size: 4.7rem; letter-spacing: -.12em; line-height: .9; }
541
+ .severity-card__rule { position: absolute; right: 15px; bottom: 15px; left: 15px; height: 8px; background: repeating-linear-gradient(90deg, var(--tone), var(--tone) 6px, transparent 6px, transparent 11px); opacity: .8; }
542
+ .map-layout { display: grid; grid-template-columns: .74fr 1.26fr; overflow: hidden; border: 2px solid var(--ink); background: rgba(255, 249, 237, .7); box-shadow: var(--shadow); }
543
+ .map-intro { display: flex; min-height: 420px; flex-direction: column; justify-content: space-between; padding: 25px; background: var(--ink); color: var(--paper-light); }
544
+ .map-intro h3 { max-width: 390px; margin: 20px 0; color: var(--amber); font-size: clamp(2.5rem, 5vw, 4.55rem); letter-spacing: -.09em; line-height: .9; }
545
+ .map-intro p { max-width: 430px; margin: 0; color: #cfc4b0; font-size: .88rem; line-height: 1.55; }
546
+ .map-legend { display: flex; gap: 19px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,.2); color: #cfc4b0; font-family: "Courier New", Courier, monospace; font-size: .62rem; letter-spacing: .08em; text-transform: uppercase; }
547
+ .map-board { display: grid; gap: 1px; padding: 15px; background-image: radial-gradient(rgba(23,23,19,.16) 1px, transparent 1px); background-size: 12px 12px; }
548
+ .agent-row { padding: 13px 15px; border: 1px solid rgba(23,23,19,.18); background: rgba(255, 249, 237, .9); animation: lift .55s both; animation-delay: var(--delay); }
549
+ .agent-row__head { display: grid; grid-template-columns: 15px 1fr auto; align-items: center; gap: 10px; }
550
+ .agent-row__node { width: 12px; height: 12px; border-radius: 50%; background: var(--agent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--agent), transparent 75%); }
551
+ .agent-row strong, .agent-row small { display: block; }
552
+ .agent-row strong { font-size: 1.02rem; }
553
+ .agent-row small { overflow: hidden; max-width: 330px; color: var(--muted); font-family: "Courier New", Courier, monospace; font-size: .62rem; text-overflow: ellipsis; white-space: nowrap; }
554
+ .agent-row__total { font-size: 2rem; font-weight: 700; letter-spacing: -.08em; }
555
+ .agent-row__track { height: 8px; margin: 10px 0 8px; overflow: hidden; background: rgba(23,23,19,.1); }
556
+ .agent-row__track i { display: block; height: 100%; background: var(--agent); }
557
+ .agent-row__meta { display: flex; gap: 16px; color: var(--muted); font-size: .6rem; letter-spacing: .06em; text-transform: uppercase; }
558
+ .map-empty { display: grid; min-height: 280px; padding: 30px; color: var(--muted); font-style: italic; place-items: center; }
559
+ .duplicate-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
560
+ .duplicate-card, .empty-state { padding: 18px; border: 1px solid var(--ink); background: rgba(255, 249, 237, .76); box-shadow: 5px 5px 0 var(--amber); }
561
+ .duplicate-card__top { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: var(--coral-deep); font-size: 1.1rem; font-weight: 700; }
562
+ code { font-size: .69rem; }
563
+ .duplicate-card code { padding: 4px 7px; border: 1px solid var(--ink); background: var(--ink); color: var(--amber); }
564
+ .duplicate-card__route { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 23px 0 14px; }
565
+ .duplicate-card__route b { padding: 5px 8px; border: 1px solid var(--coral-deep); color: var(--coral-deep); font-family: "Courier New", Courier, monospace; font-size: .63rem; letter-spacing: .06em; text-transform: uppercase; }
566
+ .duplicate-card__route i { color: var(--muted); font-size: .72rem; }
567
+ .duplicate-card p, .duplicate-card li, .empty-state span { color: var(--muted); font-size: .82rem; line-height: 1.45; }
568
+ .duplicate-card ul { margin: 12px 0 0; padding-left: 17px; }
569
+ .empty-state { display: flex; min-height: 130px; flex-direction: column; gap: 8px; justify-content: center; }
570
+ .findings-section { margin-top: 26px; padding: 28px 0 68px; border-top: 2px solid var(--ink); }
571
+ .filter-bar {
572
+ display: grid;
573
+ grid-template-columns: auto minmax(180px, 1fr) 170px auto;
574
+ gap: 9px;
575
+ align-items: center;
576
+ margin: 20px 0 12px;
577
+ }
578
+ .filter-buttons { display: flex; flex-wrap: wrap; gap: 5px; }
579
+ button, select, input {
580
+ min-height: 38px;
581
+ border: 1px solid var(--ink);
582
+ border-radius: 0;
583
+ background: rgba(255,249,237,.78);
584
+ color: var(--ink);
585
+ font-size: .68rem;
586
+ }
587
+ button { padding: 0 10px; cursor: pointer; letter-spacing: .04em; text-transform: uppercase; }
588
+ button:hover, button.is-active { background: var(--ink); color: var(--paper-light); }
589
+ select, input { width: 100%; padding: 0 10px; }
590
+ .result-count { color: var(--muted); font-size: .63rem; letter-spacing: .04em; text-align: right; text-transform: uppercase; white-space: nowrap; }
591
+ .table-wrap { overflow-x: auto; border: 1px solid var(--ink); box-shadow: var(--shadow); }
592
+ table { width: 100%; min-width: 920px; border-collapse: collapse; background: rgba(255, 249, 237, .82); }
593
+ th { padding: 11px 12px; border-bottom: 2px solid var(--ink); background: var(--ink); color: var(--paper-light); font-size: .62rem; letter-spacing: .1em; text-align: left; text-transform: uppercase; }
594
+ td { padding: 13px 12px; border-bottom: 1px solid var(--faint); font-size: .82rem; vertical-align: top; }
595
+ tr:last-child td { border-bottom: 0; }
596
+ td:first-child { white-space: nowrap; }
597
+ td p { max-width: 620px; margin: 4px 0 0; color: var(--muted); font-size: .78rem; line-height: 1.45; }
598
+ td code { display: block; max-width: 350px; margin-top: 5px; overflow-wrap: anywhere; color: var(--muted); }
599
+ .severity-dot { display: inline-block; width: 9px; height: 9px; margin-right: 7px; border-radius: 50%; background: var(--tone); }
600
+ .severity-dot--critical { --tone: var(--coral-deep); }
601
+ .severity-dot--high { --tone: var(--coral); }
602
+ .severity-dot--medium { --tone: var(--amber); }
603
+ .severity-dot--low { --tone: var(--olive); }
604
+ .severity-dot--info { --tone: var(--sky); }
605
+ .category-tag, .agent-inline { display: inline-block; font-family: "Courier New", Courier, monospace; font-size: .61rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
606
+ .category-tag { padding: 3px 6px; border: 1px solid rgba(23,23,19,.3); }
607
+ .agent-inline { color: var(--coral-deep); }
608
+ .table-empty { padding: 32px; border: 1px solid var(--ink); color: var(--muted); background: rgba(255, 249, 237, .78); font-style: italic; text-align: center; }
609
+ footer { display: flex; justify-content: space-between; gap: 20px; padding: 19px 0 25px; border-top: 2px solid var(--ink); }
610
+ .footer-note { color: var(--muted); font-size: .61rem; letter-spacing: .07em; line-height: 1.5; text-transform: uppercase; }
611
+ .footer-note strong { color: var(--coral-deep); }
612
+ @keyframes lift {
613
+ from { opacity: 0; transform: translateY(10px); }
614
+ to { opacity: 1; transform: translateY(0); }
615
+ }
616
+ @media (prefers-reduced-motion: reduce) {
617
+ html { scroll-behavior: auto; }
618
+ .severity-card, .agent-row { animation: none; }
619
+ }
620
+ @media (max-width: 900px) {
621
+ .hero { grid-template-columns: 1fr; padding-top: 50px; }
622
+ .score-card { width: min(100%, 420px); }
623
+ .stat-strip { grid-template-columns: repeat(3, 1fr); }
624
+ .severity-grid { grid-template-columns: repeat(3, 1fr); }
625
+ .map-layout { grid-template-columns: 1fr; }
626
+ .map-intro { min-height: 270px; }
627
+ .filter-bar { grid-template-columns: 1fr 1fr; }
628
+ .filter-buttons { grid-column: 1 / -1; }
629
+ .result-count { text-align: left; }
630
+ }
631
+ @media (max-width: 600px) {
632
+ .shell { width: min(100% - 24px, 1420px); }
633
+ .masthead { align-items: start; flex-direction: column; }
634
+ .brand b { font-size: 2.65rem; }
635
+ .brand small { display: none; }
636
+ h1 { font-size: clamp(4.2rem, 22vw, 7.2rem); }
637
+ .guarantee { align-items: start; flex-direction: column; }
638
+ .stat-strip { grid-template-columns: repeat(2, 1fr); }
639
+ .severity-grid { grid-template-columns: repeat(2, 1fr); }
640
+ .severity-card:last-child { grid-column: 1 / -1; }
641
+ .section-head { align-items: start; flex-direction: column; }
642
+ .duplicate-grid { grid-template-columns: 1fr; }
643
+ .filter-bar { grid-template-columns: 1fr; }
644
+ footer { flex-direction: column; }
645
+ }
646
+ </style>
647
+ </head>
648
+ <body>
649
+ <svg class="paper-noise" aria-hidden="true">
650
+ <filter id="paper-grain">
651
+ <feTurbulence baseFrequency=".78" numOctaves="3" seed="17" stitchTiles="stitch"></feTurbulence>
652
+ <feColorMatrix type="saturate" values="0"></feColorMatrix>
653
+ </filter>
654
+ <rect width="100%" height="100%" filter="url(#paper-grain)"></rect>
655
+ </svg>
656
+
657
+ <header class="shell masthead">
658
+ <div class="brand"><b>agenthusk</b><small>LOCAL-FIRST AGENT FORENSICS</small></div>
659
+ <div class="privacy-stamp">${report.pathsRedacted ? "paths anonymized" : "unsafe raw paths"} / no external calls</div>
660
+ </header>
661
+
662
+ <main class="shell">
663
+ <section class="hero">
664
+ <div>
665
+ <div class="eyebrow">Forensic residue report // ${escapeHtml(timestamp)}</div>
666
+ <h1>Residue leaves an <em>agent husk.</em></h1>
667
+ <p class="hero-copy">${formatNumber(report.findings.length)} local findings form the review queue. Coverage cap: <b>${report.stats.capped ? "reached" : "not hit"}</b> after ${formatNumber(report.stats.filesVisited)} files visited and ${formatNumber(report.stats.textFilesInspected)} text files inspected. The risk signal supports triage; it is not proof of compromise.</p>
668
+ <div class="guarantee">
669
+ <b>${report.pathsRedacted ? "Default disclosure boundary" : "Private report warning"}</b>
670
+ <span>${escapeHtml(report.guarantee)}</span>
671
+ </div>
672
+ </div>
673
+ <aside class="score-card">
674
+ <div class="score-head"><span>Risk signal</span><span>triage / 100</span></div>
675
+ <div class="score-ring"><span class="score-value">${report.score}</span></div>
676
+ <div class="score-foot"><span>Signal, not proof</span><b>${escapeHtml(report.risk)} band</b></div>
677
+ </aside>
678
+ </section>
679
+
680
+ <section class="stat-strip" aria-label="Scan statistics">
681
+ ${stat("Finding evidence", formatNumber(report.findings.length), "local signals")}
682
+ ${stat("Coverage cap", report.stats.capped ? "REACHED" : "NOT HIT", `${formatNumber(report.stats.filesVisited)} files visited`)}
683
+ ${stat("Files visited", formatNumber(report.stats.filesVisited))}
684
+ ${stat("Text inspected", formatNumber(report.stats.textFilesInspected))}
685
+ ${stat("Data traversed", formatBytes(report.stats.bytesVisited))}
686
+ ${stat("Agent roots", formatNumber(report.agents.length), `${report.stats.rootsMissing} absent / ${report.stats.rootsSkippedUnsafe} unsafe`)}
687
+ ${stat("Hidden fingerprints", formatNumber(hiddenValues))}
688
+ </section>
689
+
690
+ <section class="section">
691
+ <div class="section-head">
692
+ <div>
693
+ <span class="section-kicker">Severity ledger</span>
694
+ <h2>Signals, weighted by urgency.</h2>
695
+ </div>
696
+ <p class="section-note">Counts summarize redacted metadata only. Findings identify where to inspect locally without copying credential values into this report.</p>
697
+ </div>
698
+ <div class="severity-grid">${severityCards(report)}</div>
699
+ </section>
700
+
701
+ <section class="section">
702
+ <div class="section-head">
703
+ <div>
704
+ <span class="section-kicker">Agent exposure map</span>
705
+ <h2>Residue clusters around tools.</h2>
706
+ </div>
707
+ <p class="section-note">Each lane combines local findings with value-hidden fingerprints. Longer bars indicate a denser review queue.</p>
708
+ </div>
709
+ <div class="map-layout">
710
+ <div class="map-intro">
711
+ <span class="section-kicker">Local surface</span>
712
+ <h3>${formatNumber(report.agents.length)} roots.<br>${formatNumber(report.findings.length)} signals.</h3>
713
+ <p>Agent storage can preserve transcripts, copied environment files, shell history, and server configuration long after a task ends.</p>
714
+ <div class="map-legend"><span>dot / agent</span><span>bar / exposure</span></div>
715
+ </div>
716
+ <div class="map-board">${exposureMap(report)}</div>
717
+ </div>
718
+ </section>
719
+
720
+ <section class="section">
721
+ <div class="section-head">
722
+ <div>
723
+ <span class="section-kicker">Duplicate fingerprints</span>
724
+ <h2>One trace. Multiple surfaces.</h2>
725
+ </div>
726
+ <p class="section-note">A duplicate means the same HMAC fingerprint appeared in more than one file. The underlying secret value remains excluded.</p>
727
+ </div>
728
+ <div class="duplicate-grid">${duplicateCards(report)}</div>
729
+ </section>
730
+
731
+ <section class="findings-section">
732
+ <div class="section-head">
733
+ <div>
734
+ <span class="section-kicker">Finding index</span>
735
+ <h2>Review the local trail.</h2>
736
+ </div>
737
+ <p class="section-note">Filter in place. The controls operate only on rows embedded in this HTML file and never transmit data.</p>
738
+ </div>
739
+ <div class="filter-bar">
740
+ <div class="filter-buttons" role="group" aria-label="Filter findings by severity">
741
+ <button class="is-active" type="button" data-severity-filter="all">All</button>
742
+ ${SEVERITIES.map(severity => `<button type="button" data-severity-filter="${severity}">${SEVERITY_LABELS[severity]} ${report.severityCounts[severity]}</button>`).join("")}
743
+ </div>
744
+ <input type="search" data-search-input placeholder="Search title, path, agent..." aria-label="Search findings">
745
+ <select data-category-filter aria-label="Filter findings by category">
746
+ <option value="all">All categories</option>
747
+ ${categories}
748
+ </select>
749
+ <span class="result-count" data-result-count>${formatNumber(report.findings.length)} / ${formatNumber(report.findings.length)} findings</span>
750
+ </div>
751
+ <div class="table-wrap">
752
+ <table>
753
+ <thead><tr><th>Severity</th><th>Category</th><th>Signal</th><th>Local surface</th></tr></thead>
754
+ <tbody>${findingRows(report)}</tbody>
755
+ </table>
756
+ </div>
757
+ <div class="table-empty" data-filter-empty ${report.findings.length === 0 ? "" : "hidden"}>No findings match the current local filter.</div>
758
+ </section>
759
+ </main>
760
+
761
+ <footer class="shell">
762
+ <span class="footer-note"><strong>${report.pathsRedacted ? "Default disclosure boundary" : "Private report warning"}:</strong> ${escapeHtml(report.guarantee)}</span>
763
+ <span class="footer-note">AgentHusk schema ${report.schemaVersion} // generated ${escapeHtml(timestamp)} // portable offline artifact</span>
764
+ </footer>
765
+
766
+ <script id="agenthusk-report-data" type="application/json">${safeJson(report)}</script>
767
+ <script>
768
+ (() => {
769
+ const embeddedReport = JSON.parse(document.getElementById("agenthusk-report-data").textContent);
770
+ const rows = [...document.querySelectorAll("[data-finding-row]")];
771
+ const buttons = [...document.querySelectorAll("[data-severity-filter]")];
772
+ const search = document.querySelector("[data-search-input]");
773
+ const category = document.querySelector("[data-category-filter]");
774
+ const count = document.querySelector("[data-result-count]");
775
+ const empty = document.querySelector("[data-filter-empty]");
776
+ let severity = "all";
777
+
778
+ document.documentElement.dataset.schemaVersion = embeddedReport.schemaVersion;
779
+
780
+ function applyFilters() {
781
+ const needle = search.value.trim().toLowerCase();
782
+ let visible = 0;
783
+ for (const row of rows) {
784
+ const include = (severity === "all" || row.dataset.severity === severity)
785
+ && (category.value === "all" || row.dataset.category === category.value)
786
+ && (!needle || row.dataset.search.includes(needle));
787
+ row.hidden = !include;
788
+ if (include) visible += 1;
789
+ }
790
+ count.textContent = visible + " / " + rows.length + " findings";
791
+ empty.hidden = visible !== 0;
792
+ }
793
+
794
+ for (const button of buttons) {
795
+ button.addEventListener("click", () => {
796
+ severity = button.dataset.severityFilter;
797
+ for (const candidate of buttons) candidate.classList.toggle("is-active", candidate === button);
798
+ applyFilters();
799
+ });
800
+ }
801
+ search.addEventListener("input", applyFilters);
802
+ category.addEventListener("change", applyFilters);
803
+ })();
804
+ </script>
805
+ </body>
806
+ </html>`;
807
+ }
808
+
809
+ function svgAgentNodes(report) {
810
+ const agents = exposureAgents(report).slice(0, 5);
811
+ if (agents.length === 0) {
812
+ return `<text x="74" y="504" class="map-empty">NO AGENT ROOTS DISCOVERED</text>`;
813
+ }
814
+
815
+ return agents.map((agent, index) => {
816
+ const x = 76 + index * 148;
817
+ const findings = report.findings.filter(finding => finding.agent === agent.id).length;
818
+ const radius = clamp(15 + findings * 3, 15, 34);
819
+ return `
820
+ <circle cx="${x}" cy="473" r="${radius + 8}" fill="none" stroke="${agent.color}" stroke-opacity=".25"/>
821
+ <circle cx="${x}" cy="473" r="${radius}" fill="${agent.color}"/>
822
+ <text x="${x}" y="536" class="node-label" text-anchor="middle">${escapeHtml(agent.label)}</text>
823
+ <text x="${x}" y="557" class="node-count" text-anchor="middle">${formatNumber(findings)} SIGNALS</text>`;
824
+ }).join("");
825
+ }
826
+
827
+ /**
828
+ * Create an SVG social card with aggregate metadata only. No paths, findings,
829
+ * or matched secret values are exposed in the shareable image.
830
+ */
831
+ export function renderShareCard(input) {
832
+ const report = normalizeReport(input);
833
+ const timestamp = formatTimestamp(report.generatedAt).slice(0, 10);
834
+ const critical = report.severityCounts.critical;
835
+ const high = report.severityCounts.high;
836
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-labelledby="title desc">
837
+ <title id="title">AgentHusk local-first agent forensic report</title>
838
+ <desc id="desc">A value-hidden scan summary with ${report.findings.length} local findings and a risk signal of ${report.score}. The signal is not proof.</desc>
839
+ <defs>
840
+ <pattern id="grid" width="24" height="24" patternUnits="userSpaceOnUse">
841
+ <path d="M24 0H0V24" fill="none" stroke="#171713" stroke-opacity=".09"/>
842
+ </pattern>
843
+ <filter id="noise">
844
+ <feTurbulence baseFrequency=".8" numOctaves="3" seed="17" stitchTiles="stitch"/>
845
+ <feColorMatrix type="saturate" values="0"/>
846
+ <feComponentTransfer><feFuncA type="table" tableValues="0 .12"/></feComponentTransfer>
847
+ </filter>
848
+ <style>
849
+ .serif { font-family: Georgia, "Times New Roman", serif; }
850
+ .mono { font-family: "Courier New", Courier, monospace; }
851
+ .label { font: 700 12px "Courier New", Courier, monospace; letter-spacing: 2px; fill: #bd4a35; }
852
+ .metric { font: 700 48px Georgia, "Times New Roman", serif; letter-spacing: -4px; fill: #fff9ed; }
853
+ .metric-label { font: 700 10px "Courier New", Courier, monospace; letter-spacing: 1.3px; fill: #776f61; }
854
+ .node-label { font: 700 12px Georgia, "Times New Roman", serif; fill: #171713; }
855
+ .node-count, .map-empty { font: 700 9px "Courier New", Courier, monospace; letter-spacing: 1px; fill: #776f61; }
856
+ </style>
857
+ </defs>
858
+ <rect width="1200" height="630" fill="#f3ead8"/>
859
+ <rect width="1200" height="630" fill="url(#grid)"/>
860
+ <rect width="1200" height="630" filter="url(#noise)" opacity=".65"/>
861
+ <rect x="0" y="0" width="26" height="630" fill="#ed6a4d"/>
862
+ <rect x="1072" y="0" width="128" height="630" fill="#171713"/>
863
+ <rect x="1101" y="0" width="1" height="630" fill="#f3b63f" fill-opacity=".4"/>
864
+
865
+ <text x="72" y="79" class="serif" font-size="58" font-weight="700" letter-spacing="-5" fill="#171713">agenthusk</text>
866
+ <text x="76" y="108" class="label">LOCAL-FIRST AGENT FORENSICS // ${escapeHtml(timestamp)}</text>
867
+ <text x="74" y="208" class="serif" font-size="94" font-weight="700" letter-spacing="-8" fill="#171713">Residue leaves</text>
868
+ <text x="74" y="292" class="serif" font-size="94" font-style="italic" letter-spacing="-8" fill="#ed6a4d">an agent husk.</text>
869
+ <text x="76" y="353" class="mono" font-size="15" fill="#514b41">LOCAL SCAN / ${formatNumber(report.findings.length)} FINDINGS / COVERAGE CAP: ${report.stats.capped ? "REACHED" : "NOT HIT"}</text>
870
+ <text x="76" y="378" class="mono" font-size="12" letter-spacing="1" fill="#776f61">${formatNumber(report.stats.filesVisited)} FILES VISITED / ${formatNumber(report.stats.textFilesInspected)} TEXT FILES INSPECTED / VALUE-HIDDEN</text>
871
+
872
+ <path d="M74 413H813" stroke="#171713" stroke-width="2"/>
873
+ <text x="74" y="444" class="label">AGENT EXPOSURE MAP / EVIDENCE DENSITY</text>
874
+ ${svgAgentNodes(report)}
875
+
876
+ <rect x="840" y="56" width="285" height="518" fill="#171713"/>
877
+ <text x="875" y="100" class="mono" font-size="12" font-weight="700" letter-spacing="2" fill="#f3b63f">RISK SIGNAL / 100</text>
878
+ <text x="875" y="123" class="mono" font-size="10" font-weight="700" letter-spacing="1.4" fill="#cfc4b0">SIGNAL, NOT PROOF</text>
879
+ <circle cx="985" cy="252" r="92" fill="none" stroke="#ffffff" stroke-opacity=".14" stroke-width="18"/>
880
+ <circle cx="985" cy="252" r="92" fill="none" stroke="#ed6a4d" stroke-width="18" pathLength="100" stroke-dasharray="${report.score} 100" transform="rotate(-90 985 252)"/>
881
+ <text x="985" y="279" class="serif" font-size="102" font-weight="700" letter-spacing="-10" text-anchor="middle" fill="#fff9ed">${report.score}</text>
882
+ <text x="985" y="334" class="mono" font-size="12" font-weight="700" letter-spacing="2" text-anchor="middle" fill="#ed6a4d">${escapeHtml(report.risk.toUpperCase())} TRIAGE BAND</text>
883
+
884
+ <path d="M874 376H1091" stroke="#ffffff" stroke-opacity=".2"/>
885
+ <text x="875" y="423" class="metric" fill="#fff9ed">${report.findings.length}</text>
886
+ <text x="875" y="446" class="metric-label" fill="#cfc4b0">FINDINGS</text>
887
+ <text x="958" y="423" class="metric" fill="#fff9ed">${critical}</text>
888
+ <text x="958" y="446" class="metric-label" fill="#cfc4b0">CRITICAL</text>
889
+ <text x="1033" y="423" class="metric" fill="#fff9ed">${high}</text>
890
+ <text x="1033" y="446" class="metric-label" fill="#cfc4b0">HIGH</text>
891
+ <rect x="874" y="485" width="218" height="51" fill="#f3b63f"/>
892
+ <text x="983" y="507" class="mono" font-size="10" font-weight="700" letter-spacing="1.4" text-anchor="middle" fill="#171713">VALUE-HIDDEN GUARANTEE</text>
893
+ <text x="983" y="524" class="mono" font-size="9" font-weight="700" letter-spacing=".7" text-anchor="middle" fill="#171713">MATCHED VALUES STAY HIDDEN</text>
894
+ </svg>`;
895
+ }