From aabe5b664a0998e8123a87d1bec5f5d39ea43aad Mon Sep 17 00:00:00 2001 From: Jithin Date: Tue, 4 Aug 2026 11:53:02 +0530 Subject: [PATCH 1/2] feat(report): redesign hunt report to match the run report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autonomous hunt report had drifted into a visually separate product from the `opfor run` report: its own colour tokens (amber accent vs red), its own type scale, a generic shield glyph instead of the Opfor wordmark, heavy card shadows, and a bespoke turn view with no navigation. Both renderers also inlined their own copy of the brand SVGs, so any rebrand had to happen twice. Rebuild the hunt renderer on the run report's visual language — black cover band, shared wordmark, red accent, section rhythm, exec strip with the safety gauge, and the transcript + turn rail with scroll-spy — on a slightly roomier type scale, since a hunt carries far more per screen than a suite run. Hunt-specific sections (recon fingerprint, vuln-class matrix, attack tree, decision log, inventions) are kept, rebuilt on the shared card primitives. Also: - Extract the wordmark + icons to report/brand.ts so both renderers share one copy. Verified byte-identical before moving; the run report is visually unchanged by it. - Surface the scout and verifier models. Both were configurable but absent from AutonomousReport, so neither could be rendered; the report showed only commander and operator despite hunt running three agents. - Give every thread a finding card with its transcript. Defended threads were previously bare chips, so runs with no confirmed findings — the common case — had no inspectable transcripts at all. - Fix executive-summary label alignment in both reports: exec-strip-item centred its content vertically, so cards with differing content heights put every label at a different height. Co-Authored-By: Claude Opus 5 (1M context) --- core/src/autonomous/orchestrator/run.ts | 2 + core/src/autonomous/report/html.ts | 1161 +++++++++++++++-------- core/src/autonomous/report/types.ts | 4 + core/src/report/brand.ts | 45 + core/src/report/render.ts | 62 +- 5 files changed, 834 insertions(+), 440 deletions(-) create mode 100644 core/src/report/brand.ts diff --git a/core/src/autonomous/orchestrator/run.ts b/core/src/autonomous/orchestrator/run.ts index 141d1763..21464c0a 100644 --- a/core/src/autonomous/orchestrator/run.ts +++ b/core/src/autonomous/orchestrator/run.ts @@ -337,5 +337,7 @@ export async function runAutonomous( const report = mapRunLogToReport(runLog); report.commanderModel = options.commanderModel; report.operatorModel = options.operatorModel; + report.scoutModel = options.scoutModel; + report.verifierModel = options.verifierModel ?? options.commanderModel; return report; } diff --git a/core/src/autonomous/report/html.ts b/core/src/autonomous/report/html.ts index b05f2ab8..7039973d 100644 --- a/core/src/autonomous/report/html.ts +++ b/core/src/autonomous/report/html.ts @@ -1,9 +1,21 @@ -// Self-contained, executive-grade HTML report for the autonomous runner. No external assets. -// Layout: dark cover → sticky nav → executive summary (verdict, severity bar, top findings) → -// vuln-class matrix → key findings → attack tree → per-finding conversation cards → synthesis. +// Self-contained HTML report for the autonomous hunt runner. No external assets. +// Shares the run report's visual language (cover band, section rhythm, exec strip, safety +// gauge, transcript + turn rail) on a slightly roomier type scale, since a hunt carries more +// per screen than a suite run: cover → nav → exec summary → scope → recon → vuln-class matrix +// → findings (full transcripts) → attack tree → recommendations → appendices. import type { AutonomousReport, ReportFinding, ReportTurn, Severity } from "./types.js"; import { renderForest } from "../state/observe.js"; +import { formatStandardsLabel } from "../../evaluators/standards.js"; +import { + OPFOR_LOGO_SVG, + LOCK_ICON, + COPY_ICON, + ATTACKER_ICON, + AGENT_ICON, + SEVERITY_ICON, + CHEVRON_ICON, +} from "../../report/brand.js"; function esc(s: string): string { return s @@ -13,9 +25,29 @@ function esc(s: string): string { .replace(/"/g, """) .replace(/'/g, "'"); } + function truncate(s: string, n: number): string { return s.length > n ? s.slice(0, n) + "…" : s; } + +/** Format a run duration for display (e.g. 754000 → "12m 34s"). */ +function formatDuration(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +/** Map a safety score (0–100) to a red/amber/green hex colour. */ +function safetyColor(score: number): string { + if (score >= 70) return "#059669"; + if (score >= 50) return "#D97706"; + return "#DC2626"; +} + const SEV_HEX: Record = { critical: "#DC2626", high: "#EA580C", @@ -24,86 +56,195 @@ const SEV_HEX: Record = { }; const SEV_ORDER: Severity[] = ["critical", "high", "medium", "low"]; -function expandableBlock(content: string, fadeColor = "#F1F5F9"): string { - return `
-
${content}
-
- -
`; +/** Semi-circular SVG gauge for a 0-100 percentage score. */ +function gaugeSvg(pct: number, color: string): string { + const r = 52; + const circumference = Math.PI * r; + const offset = circumference * (1 - Math.max(0, Math.min(100, pct)) / 100); + return ` + + + `; +} + +/** Wall-clock run time, when the report carries a usable `startedAt` (older ones don't). */ +function runDurationMs(r: AutonomousReport): number | undefined { + if (!r.startedAt) return undefined; + const started = Date.parse(r.startedAt); + const ended = Date.parse(r.generatedAt); + if (Number.isNaN(started) || Number.isNaN(ended) || ended < started) return undefined; + return ended - started; +} + +// ── Transcript ─────────────────────────────────────────────────── + +/** Role label row: icon + name. */ +function roleLabel(icon: string, name: string): string { + return `
${icon}${name}
`; +} + +/** One conversation turn: attacker prompt as plain text, target response as a bubble that's + * tinted when the judge cited this turn in `failingTurns`. `id` anchors the turn for the + * rail's click-to-scroll + scroll-spy. */ +function renderTurn(t: ReportTurn, failing: boolean, id: string): string { + const tags = [t.persona, t.strategy] + .filter((x): x is string => !!x) + .map((x) => `${esc(x)}`) + .join(""); + return ` +
+
+ Turn ${t.turnIndex}${failing ? " — breach" : ""} + + ${tags ? `${tags}` : ""} +
+
+ ${roleLabel(ATTACKER_ICON, "Attacker")} +
${esc(truncate(t.prompt, 8000))}
+
+
+
+ ${roleLabel(AGENT_ICON, "Target")} +
${esc(truncate(t.response, 8000))}
+
+
+
`; } -function renderTurn(t: ReportTurn, failing: boolean): string { - const score = typeof t.score === "number" ? t.score : undefined; - const scoreColor = - score === undefined ? "#94A3B8" : score <= 3 ? "#DC2626" : score <= 6 ? "#D97706" : "#059669"; - const tag = - `${t.persona ?? "-"} / ${t.strategy ?? "-"}` + (score !== undefined ? ` · ${score}/10` : ""); +/** Collapsible transcript with a turn rail. Breach turns are marked red on the rail so a long + * thread can be navigated straight to where it broke. */ +function renderTranscript(f: ReportFinding, tId: string): string { + if (f.turns.length === 0) return ""; + const failing = new Set(f.failingTurns ?? []); + const turnId = (i: number): string => `${tId}-turn-${i}`; + + const rail = + f.turns.length > 1 + ? `
${f.turns + .map((t) => { + const bad = failing.has(t.turnIndex); + return ``; + }) + .join( + "" + )}
` + : ""; + + const turns = f.turns + .map((t) => renderTurn(t, failing.has(t.turnIndex), turnId(t.turnIndex))) + .join(""); + return ` -
- - T${t.turnIndex} - Turn ${t.turnIndex}${failing ? " — breach" : ""} - ${esc(tag)} - -
Operator
${expandableBlock(esc(truncate(t.prompt, 8000)))}
-
Target
${expandableBlock(esc(truncate(t.response, 8000)), "#FFFFFF")}
-
`; +
+
+
Conversation Transcript ${f.turns.length} turn${f.turns.length === 1 ? "" : "s"}
+
+ ${rail} +
${turns}
+
+
+
+ `; } -function renderFindingCard(f: ReportFinding): string { - const sevColor = SEV_HEX[f.severity]; - const failSet = new Set(f.failingTurns ?? []); - const turns = f.turns.map((t) => renderTurn(t, failSet.has(t.turnIndex))).join(""); - const standards = - f.standards && Object.keys(f.standards).length - ? Object.entries(f.standards) - .map(([k, v]) => `${esc(k)}:${esc(v)}`) - .join(" ") +// ── Finding card ───────────────────────────────────────────────── + +/** One finding as a card: header (severity, thread, verdict) + reasoning, evidence, self-check, + * confidence/standards, and the collapsible transcript. PASS/ERROR findings skip the + * evidence/confidence blocks, which carry no signal for a defended thread. */ +function renderFindingCard(f: ReportFinding, index: number): string { + const failed = f.verdict === "FAIL"; + const sevColor = SEV_HEX[f.severity] ?? "#64748B"; + const tId = `f${index}`; + + const verdictLabel = failed ? "Fail" : f.verdict === "PASS" ? "Pass" : "Error"; + const verdictClass = failed + ? "verdict-fail" + : f.verdict === "PASS" + ? "verdict-pass" + : "verdict-error"; + + const pills = [ + `class: ${esc(f.vulnClassId)}`, + f.strategy ? `strategy: ${esc(f.strategy)}` : "", + f.personaArc.length + ? `personas: ${esc(f.personaArc.join(" → "))}` + : "", + f.gen ? `gen ${f.gen}` : "", + f.crossSessionCorroborated + ? `✓ corroborated · ${f.corroboratingThreads?.length ?? 2} independent threads` + : "", + ] + .filter(Boolean) + .join(""); + + const evidence = + failed && f.evidence && f.evidence !== "N/A" + ? `
${esc(truncate(f.evidence, 1400))}
` : ""; + const sc = f.selfCheck; - const selfCheckBlock = sc + const selfCheck = sc ? `
Independent verifier: ${esc(sc.verdict)} · score ${sc.score}/10 · confidence ${sc.confidence}% — ${esc(sc.reasoning)}
` : ""; - const corr = f.crossSessionCorroborated - ? `✓ corroborated · ${f.corroboratingThreads?.length ?? 2} independent threads` - : ""; + + const standardsLabel = formatStandardsLabel(f.standards); + const metaRow = + failed || standardsLabel + ? `
+ ${failed ? `
${f.confidence}%
` : ""} + ${standardsLabel ? `
${esc(standardsLabel)}
` : ""} +
` + : ""; + return ` -
- - - ${esc(f.severity)} - ${esc(f.name)} - ${f.confidence}% - -
-
- ${esc(f.vulnClassId)}${standards} - thread ${esc(f.threadId)}${f.gen ? ` · gen ${f.gen}` : ""} - ${f.strategy ? `strategy: ${esc(f.strategy)}` : ""} - ${f.personaArc.length ? `personas: ${esc(f.personaArc.join(" → "))}` : ""} - ${corr} +
+
+
+ ${String(index + 1).padStart(2, "0")} +
+ ${esc(f.name || f.vulnClassId)} + | + ${SEVERITY_ICON}${esc(f.severity.toUpperCase())} +
- ${f.evidence && f.evidence !== "N/A" ? `
${esc(truncate(f.evidence, 1400))}
` : ""} -
${esc(f.reasoning)}
- ${selfCheckBlock} - ${turns ? `
${turns}
` : ""} +
+ thread ${esc(f.threadId)}| + ${verdictLabel} +
+
+
+ ${pills ? `
${pills}
` : ""} +
+ +
${esc(f.reasoning)}
+
+ ${evidence} + ${selfCheck} + ${metaRow} + ${renderTranscript(f, tId)}
-
`; + `; } -function renderAttackTree(r: AutonomousReport): string { +// ── Attack tree ────────────────────────────────────────────────── + +function renderAttackTree(r: AutonomousReport, num: number): string { if (r.findings.length === 0) return ""; - // A thread can have several findings (multiple classes / cross-class hits), so group by - // threadId and aggregate — otherwise the node would show only the last one. + // A thread can produce several findings (multiple classes / cross-class hits), so group by + // threadId and aggregate — otherwise a node would show only the last one. const byId = new Map(); for (const f of r.findings) { const arr = byId.get(f.threadId); if (arr) arr.push(f); else byId.set(f.threadId, [f]); } - const ids = [...byId.keys()]; const tree = renderForest( - ids, + [...byId.keys()], (id) => byId.get(id)![0].parentThreadId, (id) => { const fs = byId.get(id)!; @@ -121,12 +262,14 @@ function renderAttackTree(r: AutonomousReport): string { ); const e = r.exploration; return `
-
5
Attack Tree
+
${num}
Attack Tree
${r.summary.threads} threads · ${e.leadsFlagged} leads (${e.leadsSpawned} expanded / ${e.leadsDismissed} dropped) · depth ${e.maxDepthReached}
-
${esc(tree)}
+
${esc(tree)}
`; } +// ── Public API ─────────────────────────────────────────────────── + export function renderReportHtml(r: AutonomousReport): string { const now = new Date(r.generatedAt); const dateStr = now.toLocaleDateString("en-US", { @@ -137,29 +280,39 @@ export function renderReportHtml(r: AutonomousReport): string { const timeStr = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" }); const fails = r.findings.filter((f) => f.verdict === "FAIL"); - const sevCount = (s: Severity) => fails.filter((f) => f.severity === s).length; - const crit = sevCount("critical"), - high = sevCount("high"), - med = sevCount("medium"), - low = sevCount("low"); + const defended = r.findings.filter((f) => f.verdict === "PASS"); + const errored = r.findings.filter((f) => f.verdict === "ERROR"); + const sevCount = (s: Severity): number => fails.filter((f) => f.severity === s).length; + const crit = sevCount("critical"); + const high = sevCount("high"); + const med = sevCount("medium"); + const low = sevCount("low"); const vulnerable = r.summary.confirmed > 0; - const verdict = vulnerable ? "VULNERABLE" : "DEFENDED"; + const verdict = vulnerable ? "Vulnerable" : "Defended"; const risk = crit > 0 - ? { label: "Critical Risk", color: "#B91C1C" } + ? { label: "Critical Risk", color: "#991B1B" } : high > 0 ? { label: "High Risk", color: "#DC2626" } : vulnerable ? { label: "Medium Risk", color: "#D97706" } : { label: "Low Risk", color: "#059669" }; + // Safety score mirrors the run report's 0-100 scale: the inverse of attack success. + // N/A when nothing was conclusively scored (no confirmed and no defended threads). + const scoreable = r.summary.confirmed + r.summary.defended; + const safetyScore = scoreable > 0 ? 100 - r.summary.attackSuccessRate : null; + const durationMs = runDurationMs(r); + const ranked = [...fails].sort( (a, b) => SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity) || b.confidence - a.confidence ); + // Confirmed first (worst severity, then confidence), then defended, then errored — every + // thread gets a card so its transcript stays reachable even on an all-defended run. + const ordered = [...ranked, ...defended, ...errored]; - // Severity distribution bar (proportional segments). const sevSeg = ( [ ["critical", crit], @@ -174,27 +327,12 @@ export function renderReportHtml(r: AutonomousReport): string { `
` ) .join(""); - const sevLegend = (["critical", "high", "medium", "low"] as Severity[]) - .map( - (s) => - `${sevCount(s)} ${s}` - ) - .join(""); - - // Top findings preview ("what went wrong" at a glance). - const topFindings = ranked - .slice(0, 6) - .map( - (f) => ` - - ${esc(f.name)} - ${esc(f.vulnClassId)} - ${esc(f.severity)} · ${f.confidence}% - ` - ) - .join(""); + const sevLegend = SEV_ORDER.map( + (s) => + `${sevCount(s)} ${s}` + ).join(""); - // Per-class matrix. + // ── Vulnerability-class matrix ── const classes = [...new Set(r.findings.map((f) => f.vulnClassId))]; const classRows = classes .map((cls) => { @@ -204,8 +342,14 @@ export function renderReportHtml(r: AutonomousReport): string { const denom = c.length + d; const rate = denom > 0 ? Math.round((c.length / denom) * 100) : 0; const worst = SEV_ORDER.find((s) => c.some((f) => f.severity === s)); - const wc = worst ? SEV_HEX[worst] : "#94A3B8"; - return { cls, confirmed: c.length, defended: d, rate, worst, wc }; + return { + cls, + confirmed: c.length, + defended: d, + rate, + worst, + wc: worst ? SEV_HEX[worst] : "", + }; }) .sort((a, b) => b.confirmed - a.confirmed || b.rate - a.rate); const classTable = classRows @@ -213,7 +357,7 @@ export function renderReportHtml(r: AutonomousReport): string { (x) => ` ${esc(x.cls)} - ${x.worst ? `${esc(x.worst)}` : "—"} + ${x.worst ? `${esc(x.worst)}` : "—"} ${x.confirmed} ${x.defended}
${x.rate}%
@@ -221,361 +365,588 @@ export function renderReportHtml(r: AutonomousReport): string { ) .join(""); - // Findings detail grouped by class. - const byClass = new Map(); - for (const f of ranked) - (byClass.get(f.vulnClassId) ?? byClass.set(f.vulnClassId, []).get(f.vulnClassId)!).push(f); - const detailHtml = [...byClass.entries()] - .map( - ([cls, list]) => - `
${esc(cls)} ${list.length} confirmed
${list.map(renderFindingCard).join("")}
` - ) - .join(""); - - const defended = r.findings.filter((f) => f.verdict === "PASS"); - const errored = r.findings.filter((f) => f.verdict === "ERROR"); - const chip = (f: ReportFinding) => - `${esc(f.threadId)} · ${esc(f.vulnClassId)}`; + const narrative = r.synthesisComplete + ? esc(r.executiveNarrative) + : `Assessment of ${esc(r.target.name)}: ${r.summary.confirmed} confirmed vulnerabilit${r.summary.confirmed === 1 ? "y" : "ies"} (${crit} critical, ${high} high) across ${r.summary.threads} attack threads — ${r.summary.attackSuccessRate}% attack-success rate.${r.truncated ? ` Run truncated: ${esc(r.truncationReason ?? "")}.` : ""}`; - const recs = r.recommendations.length - ? `
7
Recommendations
-
    ${r.recommendations.map((x) => `
  1. ${esc(x)}
  2. `).join("")}
` - : ""; + const outcomeLabel = r.objectiveOutcome.replace(/-/g, " "); + + // ── Section numbering + nav (both sections and links are conditional) ── + let sectionNo = 0; + const num = (): number => ++sectionNo; + const nav: string[] = []; + const link = (href: string, label: string): void => { + nav.push(`${label}`); + }; + + const execNo = num(); + link("exec", "Summary"); + const scopeNo = num(); + link("scope", "Scope"); + const reconNo = num(); + link("recon", "Recon"); + const classesNo = classes.length > 0 ? num() : 0; + if (classesNo) link("classes", "Categories"); + const findingsNo = num(); + link("findings", "Findings"); + const treeNo = r.findings.length > 0 ? num() : 0; + if (treeNo) link("tree", "Attack Tree"); + const recsNo = r.recommendations.length > 0 ? num() : 0; + if (recsNo) link("recs", "Recommendations"); + const hasAppendix = + r.responsePatterns.length > 0 || + r.inventions.length > 0 || + r.decisionLog.length > 0 || + r.strategiesUsed.length > 0; + const appendixNo = hasAppendix ? num() : 0; + if (appendixNo) link("appendix", "Appendices"); + + // ── Appendices ── const patterns = r.responsePatterns.length - ? `
8
Response Patterns
-
${r.responsePatterns.map((p) => ``).join("")}
${esc(p.pattern)}${esc(p.observation)}
` - : ""; - const decisionLog = r.decisionLog.length - ? `
Decision log (${r.decisionLog.length})
${r.decisionLog + ? `
Response Patterns (${r.responsePatterns.length})${CHEVRON_ICON}
${r.responsePatterns .map( - (d) => - `
${esc(d.action)} ${d.threadId ? `${esc(d.threadId)} ` : ""}${esc(d.rationale)}
` + (p) => `` ) - .join("")}` + .join("")}
${esc(p.pattern)}${esc(p.observation)}
` : ""; const inventions = r.inventions.length - ? `
Novel techniques invented (${r.inventions.length})
    ${r.inventions.map((i) => `
  • ${esc(i.kind)}: ${esc(i.name)} — ${esc(i.description)}
  • `).join("")}
` + ? `
Novel Techniques Invented (${r.inventions.length})${CHEVRON_ICON}
    ${r.inventions + .map( + (i) => `
  • ${esc(i.kind)}: ${esc(i.name)} — ${esc(i.description)}
  • ` + ) + .join("")}
` : ""; const strategies = r.strategiesUsed.length - ? `
${r.strategiesUsed.map((s) => `${esc(s)}`).join("")}
` + ? `
Strategies Used (${r.strategiesUsed.length})${CHEVRON_ICON}
${r.strategiesUsed + .map((s) => `${esc(s)}`) + .join("")}
` + : ""; + const decisionLog = r.decisionLog.length + ? `
Decision Log (${r.decisionLog.length})${CHEVRON_ICON}
${r.decisionLog + .map( + (d) => + `
${esc(d.action)}${d.threadId ? `${esc(d.threadId)}` : ""}${esc(d.rationale)}
` + ) + .join("")}
` : ""; - - const narrative = r.synthesisComplete - ? esc(r.executiveNarrative) - : `Assessment of ${esc(r.target.name)}: ${r.summary.confirmed} confirmed vulnerabilit${r.summary.confirmed === 1 ? "y" : "ies"} (${crit} critical, ${high} high) across ${r.summary.threads} attack threads — ${r.summary.attackSuccessRate}% attack-success rate.${r.truncated ? ` Run truncated: ${esc(r.truncationReason ?? "")}.` : ""}`; return ` - - -Opfor Hunt — ${esc(r.target.name)} + + + + +Opfor Hunt Report — ${esc(r.target.name)} - -
-
-
-
Opfor
Autonomous Red-Team
-
Confidential
-
-
Autonomous Red-Team Assessment
-
Adaptive adversarial evaluation · ${esc(dateStr)}
-
Objective${esc(r.objective)}
-
-
Target
${esc(truncate(r.target.name, 50))}
-
Endpoint
${esc(truncate(r.target.endpoint, 50))}
-
Assessment Date
${esc(dateStr)}, ${esc(timeStr)}
-
Commander · Operator
${esc(r.commanderModel)} · ${esc(r.operatorModel)}
-
Cost
${r.totalCostUsd !== undefined ? "$" + r.totalCostUsd.toFixed(2) : "—"}
-
Report ID
${esc(r.reportId.slice(0, 8))}
+ .appendix>summary svg{margin-left:auto;color:var(--muted-2);transition:transform .2s} + .appendix[open]>summary svg{transform:rotate(180deg)} + .appendix-body{padding:0 19px 17px;font-size:13.5px;color:var(--text-2)} + .inv-list{padding-left:18px;display:flex;flex-direction:column;gap:9px;line-height:1.65} + table.kv{width:100%;border-collapse:collapse} + table.kv td{padding:12px 0;font-size:13.5px;border-bottom:1px solid var(--line);vertical-align:top;line-height:1.65} + table.kv tr:last-child td{border-bottom:none} + .kv-k{font-weight:600;padding-right:18px;color:var(--text);width:250px} + .decision{padding:10px 0;border-top:1px solid var(--line);line-height:1.62} + .decision:first-child{border-top:none} + .decision-action{display:inline-block;font-size:10.5px;font-weight:700;text-transform:uppercase;padding:2px 7px;border-radius:4px;margin-right:7px;background:var(--surface-2);border:1px solid var(--line)} + .decision-thread{margin-right:7px;color:var(--muted)} + .decision-fork{color:#7c3aed}.decision-dispatch{color:#2563eb}.decision-stop{color:var(--fail)}.decision-pivot{color:#d97706}.decision-continue{color:var(--muted)} + .no-findings{background:var(--pass-bg);border:1px solid var(--pass-border);border-radius:10px;padding:20px;text-align:center;color:var(--pass);font-weight:600;font-size:14px} + + /* ── Footer ── */ + .report-footer{max-width:1080px;margin:48px auto 0;padding:20px 28px;border-top:1px solid var(--line);display:flex;justify-content:space-between;align-items:center} + .footer-left{font-size:12.5px;color:var(--muted)} + .footer-right{font-size:12.5px;color:var(--muted-2);font-family:ui-monospace,monospace} + + @media print{ + body{background:#fff;padding:0} + .nav{display:none} + .cover{-webkit-print-color-adjust:exact;print-color-adjust:exact} + .scope-card,.eval-detail,.exec-strip,.matrix-wrap{break-inside:avoid;box-shadow:none} + .transcript-body{max-height:none;overflow:visible} + .turn-rail-wrap{position:static} + .turn-rail{max-height:none;overflow:visible} + .rail-fade{display:none} + } + @media(max-width:820px){ + .cover-meta{grid-template-columns:1fr} + .exec-strip{flex-direction:column} + .exec-strip-item{border-right:none;border-bottom:1px solid var(--line)} + .exec-strip-item:last-child{border-bottom:none} + .scope-grid{grid-template-columns:1fr} + .eval-meta-row{gap:20px} + .turn-rail-wrap{display:none} + .transcript-body{padding-left:15px} + .turn-row.attacker-row{padding-left:18px} + } + + + + +
+
+
+
+
Autonomous Hunt Assessment Report
+
${esc(r.objective)}
+
+ ${OPFOR_LOGO_SVG} +
+
+ ${LOCK_ICON} Confidential + Autonomous Hunt + Report ID: ${esc(r.reportId.slice(0, 13))} + + + ${esc(dateStr)}, ${esc(timeStr)} +
+
+
Target System
${esc(truncate(r.target.name, 40))}
+
Objective Outcome
${esc(outcomeLabel)}
+
Run Cost
${r.totalCostUsd !== undefined ? "$" + r.totalCostUsd.toFixed(2) : "—"}
+
-
+
-
1
Executive Summary
-
-
-
${vulnerable ? '' : ''}
-
Overall Verdict
${verdict}
objective ${esc(r.objectiveOutcome)}${r.truncated ? " · run truncated" : ""}
-
-
${risk.label}
+
+
${execNo}
+
Executive Summary
- ${vulnerable ? `
${sevSeg || '
'}
${sevLegend}
` : ""} -
-
Confirmed Vulnerabilities
${r.summary.confirmed}
${crit} critical · ${high} high
-
Attack Success Rate
${r.summary.attackSuccessRate}%
${r.summary.confirmed}/${r.summary.confirmed + r.summary.defended} attempts breached
-
Defended
${r.summary.defended}
held under pressure
-
Exploration
${r.summary.threads}
threads · ${r.exploration.leadsSpawned} follow-up wave(s)
+
+
+
Overall Verdict
+
+
${verdict}
+
${risk.label.toUpperCase()}
+
+
Objective ${esc(outcomeLabel)}${r.truncated ? " · run truncated" : ""}
+
+
+
Safety Score
+ ${gaugeSvg(safetyScore ?? 0, safetyScore === null ? "#94A3B8" : safetyColor(safetyScore))} +
${safetyScore === null ? "N/A" : `${safetyScore}%`}
+
+
+
Attack Threads
+
${r.summary.threads}
+
+
${r.summary.confirmed} confirmed
+
${r.summary.defended} defended
+ ${r.summary.errors > 0 ? `
${r.summary.errors} errored
` : ""} +
+
+
+
Attack Success
+
${r.summary.attackSuccessRate}%
+
${durationMs !== undefined ? `${formatDuration(durationMs)} wall clock` : `${classes.length} class${classes.length === 1 ? "" : "es"} tested`}
+
+ ${vulnerable && sevSeg ? `
${sevSeg}
${sevLegend}
` : ""}
${narrative}
- ${topFindings ? `
Top findings — what went wrong
${topFindings}
` : ""}
-
-
2
Reconnaissance
${r.recon.probeCount} benign probe(s)
-
${esc(r.recon.fingerprint)}
+
+
+
${scopeNo}
+
Assessment Scope
+
-
Observed Guardrails
${r.recon.guardrails.length ? `
${r.recon.guardrails.map((g) => `${esc(g)}`).join("")}
` : '
None recorded.
'}
-
Candidate Weak Points
${r.recon.weakPoints.length ? `
${r.recon.weakPoints.map((w) => `${esc(w)}`).join("")}
` : '
None recorded.
'}
+
+
Target
+
System${esc(r.target.name)}
+
Endpoint${esc(truncate(r.target.endpoint, 60))}
+
Vuln Classes Tested${classes.length}
+
Recon Probes${r.recon.probeCount}
+
+
+
Attack Agents
+
Commander plans & synthesizes${esc(r.commanderModel || "—")}
+
Operator runs attack threads${esc(r.operatorModel || "—")}
+
Scout recon & lead triage${esc(r.scoutModel || "—")}
+
Verifier independent self-check${esc(r.verifierModel || r.commanderModel || "—")}
+
+
+
Exploration
+
Attack Threads${r.summary.threads}
+
Leads Flagged${r.exploration.leadsFlagged}
+
Leads Expanded / Dismissed${r.exploration.leadsSpawned} / ${r.exploration.leadsDismissed}
+
Max Depth Reached${r.exploration.maxDepthReached}${r.exploration.maxDepthReached === 0 ? " (root wave only)" : ""}
+
+
+
Run
+
Total Cost${r.totalCostUsd !== undefined ? "$" + r.totalCostUsd.toFixed(2) : "—"}
+
Duration${durationMs !== undefined ? formatDuration(durationMs) : "—"}
+
Truncated${r.truncated ? esc(r.truncationReason ?? "Yes") : "No"}
+
Commander Synthesis${r.synthesisComplete ? "Complete" : "Not submitted"}
+
-
-
3
Vulnerability Categories
${classes.length} classes tested
-
- - ${classTable} -
Vulnerability ClassWorstConfirmedDefendedSuccess Rate
+
+
+
${reconNo}
+
Reconnaissance
+
${r.recon.probeCount} benign probe${r.recon.probeCount === 1 ? "" : "s"}
+
+
+
Fingerprint
+
${esc(r.recon.fingerprint)}
+
+
+
+
Observed Guardrails
+ ${r.recon.guardrails.length ? `
${r.recon.guardrails.map((g) => `${esc(g)}`).join("")}
` : '
None recorded.
'} +
+
+
Candidate Weak Points
+ ${r.recon.weakPoints.length ? `
${r.recon.weakPoints.map((w) => `${esc(w)}`).join("")}
` : '
None recorded.
'} +
+
${ - ranked.length > 0 - ? `
4
Key Findings
${ranked.length} confirmed
-
${findingBlock( - "Critical", - ranked.filter((f) => f.severity === "critical"), - "#DC2626" - )}${findingBlock( - "High", - ranked.filter((f) => f.severity === "high"), - "#EA580C" - )}
- ${ - med + low > 0 - ? `
${findingBlock( - "Medium", - ranked.filter((f) => f.severity === "medium"), - "#D97706" - )}${findingBlock( - "Low", - ranked.filter((f) => f.severity === "low"), - "#16A34A" - )}
` - : "" - }
` - : `
4
Key Findings
No vulnerabilities confirmed — the target defended all evaluated vectors.
` + classesNo + ? `
+
+
${classesNo}
+
Vulnerability Categories
+
${classes.length} class${classes.length === 1 ? "" : "es"} tested
+
+
+ + + ${classTable} +
Vulnerability ClassWorstConfirmedDefendedSuccess Rate
+
+
` + : "" } - ${renderAttackTree(r)} - -
-
6
Findings Detail
${fails.length} confirmed · ${defended.length} defended · ${errored.length} errored
- ${detailHtml || '
No confirmed findings to detail.
'} - ${defended.length ? `
Defended threads (${defended.length})
${defended.map(chip).join("")}
` : ""} - ${errored.length ? `
Errored threads (${errored.length})
${errored.map(chip).join("")}
` : ""} +
+
+
${findingsNo}
+
Findings
+
${fails.length} confirmed · ${defended.length} defended${errored.length ? ` · ${errored.length} errored` : ""}${fails.length ? ` · red rail marker = breach turn` : ""}
+
+ ${ordered.length ? ordered.map((f, i) => renderFindingCard(f, i)).join("") : '
No attack threads were recorded for this run.
'}
- ${recs} - ${patterns} + ${treeNo ? renderAttackTree(r, treeNo) : ""} - ${strategies || decisionLog || inventions ? `
9
Appendices
${strategies}${decisionLog}${inventions}
` : ""} + ${ + recsNo + ? `
+
${recsNo}
Recommendations
+
    ${r.recommendations.map((x) => `
  1. ${esc(x)}
  2. `).join("")}
+
` + : "" + } + + ${ + appendixNo + ? `
+
${appendixNo}
Appendices
+ ${patterns}${inventions}${strategies}${decisionLog} +
` + : "" + }
- + -`; -} +(function(){ + document.querySelectorAll('.copy-btn').forEach(function(btn){ + btn.addEventListener('click', function(){ + navigator.clipboard.writeText(btn.dataset.copy || ''); + var svg = btn.querySelector('svg'); + if (svg) svg.style.color = '#4ADE80'; + }); + }); -/** Ranked critical/high/etc. block for the Key Findings overview. */ -function findingBlock(label: string, list: ReportFinding[], color: string): string { - if (list.length === 0) return ""; - return `
-
${esc(label)} - ${list.length}
-
    - ${list - .map( - (f) => `
  1. ${esc(f.name)} - ${f.confidence}% - ${esc(f.vulnClassId)}${f.crossSessionCorroborated ? " · ✓corr" : ""} - ${f.evidence && f.evidence !== "N/A" ? `
    “${esc(truncate(f.evidence.replace(/\s+/g, " "), 150))}”
    ` : ""}
  2. ` - ) - .join("")} -
-
`; + // The rail scrolls independently of the transcript, so on a long thread turns can be hidden + // above and/or below its viewport. Fade whichever edge is currently hiding turns. + function refreshRailFade(body){ + var rail = body.querySelector('.turn-rail'); + var wrap = body.querySelector('.turn-rail-wrap'); + if (!rail || !wrap) return; + var top = wrap.querySelector('.rail-fade-top'); + var bottom = wrap.querySelector('.rail-fade-bottom'); + if (top) top.classList.toggle('visible', rail.scrollTop > 2); + if (bottom) bottom.classList.toggle('visible', rail.scrollTop + rail.clientHeight < rail.scrollHeight - 2); + } + + // Keep the scroll-spy's active step inside the rail's viewport, clear of the fade gradient. + function revealStep(rail, step){ + if (!rail || !step) return; + var pad = 26; + var above = step.offsetTop - rail.offsetTop; + var below = above + step.offsetHeight; + if (above - pad < rail.scrollTop) rail.scrollTop = above - pad; + else if (below + pad > rail.scrollTop + rail.clientHeight) rail.scrollTop = below + pad - rail.clientHeight; + } + + document.querySelectorAll('.transcript-toggle').forEach(function(btn){ + btn.addEventListener('click', function(){ + var wrap = btn.closest('.eval-body').querySelector('.transcript-wrap[data-for="' + btn.dataset.target + '"]'); + if(!wrap) return; + var open = wrap.classList.toggle('open'); + btn.querySelector('.tt-label').textContent = open ? 'View less details' : 'View more details'; + btn.querySelector('svg').style.transform = open ? 'rotate(180deg)' : 'rotate(0deg)'; + if (open) { + requestAnimationFrame(function(){ + var flagged = wrap.querySelector('.turn-highlight'); + if (flagged) flagged.scrollIntoView({ behavior: 'smooth', block: 'center' }); + var body = wrap.querySelector('.transcript-body'); + if (body) refreshRailFade(body); + }); + } + }); + }); + + document.querySelectorAll('.transcript-body').forEach(function(body){ + var steps = body.querySelectorAll('.turn-step'); + if (!steps.length) return; + var rail = body.querySelector('.turn-rail'); + var turns = body.querySelectorAll('.turn[id]'); + var inView = {}; + var setActive = function(id){ + steps.forEach(function(s){ + var on = s.dataset.turn === id; + s.classList.toggle('active', on); + if (on) revealStep(rail, s); + }); + }; + steps.forEach(function(step){ + step.addEventListener('click', function(){ + var target = document.getElementById(step.dataset.turn); + if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + }); + var observer = new IntersectionObserver(function(entries){ + entries.forEach(function(entry){ + inView[entry.target.id] = entry.isIntersecting; + }); + var topmost = null; + turns.forEach(function(t){ if (!topmost && inView[t.id]) topmost = t.id; }); + if (topmost) setActive(topmost); + refreshRailFade(body); + }, { root: body, threshold: 0.4 }); + turns.forEach(function(t){ observer.observe(t); }); + if (rail) rail.addEventListener('scroll', function(){ refreshRailFade(body); }); + body.addEventListener('scroll', function(){ refreshRailFade(body); }); + }); +})(); + + +`; } diff --git a/core/src/autonomous/report/types.ts b/core/src/autonomous/report/types.ts index c07ac7ce..a50ce0a1 100644 --- a/core/src/autonomous/report/types.ts +++ b/core/src/autonomous/report/types.ts @@ -92,6 +92,10 @@ export interface AutonomousReport { objectiveOutcome: "achieved" | "partially-achieved" | "not-achieved" | "inconclusive"; commanderModel: string; operatorModel: string; + /** Optional — older reports predate these fields. */ + scoutModel?: string; + /** Falls back to the commander model when unset. */ + verifierModel?: string; /** Whether the run was truncated by a budget/turn ceiling. */ truncated: boolean; truncationReason?: string; diff --git a/core/src/report/brand.ts b/core/src/report/brand.ts new file mode 100644 index 00000000..f74a7595 --- /dev/null +++ b/core/src/report/brand.ts @@ -0,0 +1,45 @@ +/** + * Inline brand assets shared by the report renderers (run + autonomous hunt). + * Self-contained SVG — reports must render with no external asset fetches. + */ + +/** Opfor wordmark — white text, red X. */ +export const OPFOR_LOGO_SVG = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const LOCK_ICON = ``; +export const COPY_ICON = ``; +export const ATTACKER_ICON = ``; +export const AGENT_ICON = ``; +export const SEVERITY_ICON = ``; +export const CHEVRON_ICON = ``; diff --git a/core/src/report/render.ts b/core/src/report/render.ts index 005e9f8c..06237191 100644 --- a/core/src/report/render.ts +++ b/core/src/report/render.ts @@ -4,6 +4,14 @@ */ import type { ReportViewModel, ResultViewModel, TurnViewModel, DetailCard } from "./types.js"; import { formatStandardsLabel } from "../evaluators/standards.js"; +import { + OPFOR_LOGO_SVG, + LOCK_ICON, + COPY_ICON, + ATTACKER_ICON, + AGENT_ICON, + SEVERITY_ICON, +} from "./brand.js"; /** Format a token count for display (e.g. 51300 → "51.3K"). */ function formatTokenCount(n: number): string { @@ -82,52 +90,12 @@ function modeLabels(mode: "agent" | "mcp"): ModeLabels { }; } -// ── Opfor wordmark (white text, red X) — self-contained, no external asset ── -const OPFOR_LOGO_SVG = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -const LOCK_ICON = ``; -const COPY_ICON = ``; -const ATTACKER_ICON = ``; -const AGENT_ICON = ``; -const SEVERITY_ICON = ``; - /** Semi-circular SVG gauge for a 0-100 percentage score. */ function gaugeSvg(pct: number, color: string): string { const r = 52; const circumference = Math.PI * r; const offset = circumference * (1 - Math.max(0, Math.min(100, pct)) / 100); - return ` + return ` @@ -335,7 +303,9 @@ export function renderReport(model: ReportViewModel): string { /* ── Executive summary strip ── */ .exec-strip{display:flex;align-items:stretch;border:1px solid var(--line);border-radius:12px;background:var(--surface);overflow:hidden;margin-bottom:12px} - .exec-strip-item{flex:1;padding:18px 22px;border-right:1px solid var(--line);display:flex;flex-direction:column;justify-content:center;min-width:0} + /* flex-start, not center: the cards hold different amounts of content, and centering each + one vertically would land every label at a different height. */ + .exec-strip-item{flex:1;padding:18px 22px;border-right:1px solid var(--line);display:flex;flex-direction:column;justify-content:flex-start;min-width:0} .exec-strip-item:last-child{border-right:none} .exec-strip-label{font-size:11px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted);margin-bottom:8px} .exec-verdict-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} @@ -344,7 +314,9 @@ export function renderReport(model: ReportViewModel): string { .exec-verdict-text.fail{color:var(--fail)} .exec-verdict-text.error{color:#D97706} .exec-risk{font-size:11px;font-weight:600;padding:4px 11px;border-radius:999px;border:1px solid;white-space:nowrap;cursor:default} - .gauge-value{font-size:22px;font-weight:800;color:var(--text);text-align:center;margin-top:-30px} + /* Width matches the gauge so the value centres under the arc while the block itself stays + left-aligned with the label, like every other card in the strip. */ + .gauge-value{font-size:22px;font-weight:800;color:var(--text);width:120px;text-align:center;margin-top:-30px} .gauge-sub{font-size:12px;color:var(--muted);text-align:center;margin-top:2px} .sc-value{font-size:26px;font-weight:800;line-height:1;color:var(--text)} .sc-dots{display:flex;flex-direction:column;gap:3px;margin-top:8px} @@ -491,8 +463,8 @@ export function renderReport(model: ReportViewModel): string {
${overallVerdict === "ERROR" ? "Inconclusive" : riskLevel.label.toUpperCase()}
-
-
Safety Score
+
+
Safety Score
${gaugeSvg(noScoreableTests ? 0 : summary.safetyScore, gaugeColor)}
${noScoreableTests ? "N/A" : `${summary.safetyScore}%`}
From 44be1b4839dbec1f712ee7c424e948a25d565dbf Mon Sep 17 00:00:00 2001 From: Jithin Date: Tue, 4 Aug 2026 12:20:00 +0530 Subject: [PATCH 2/2] fix(report): correct thread counts, share report helpers, address review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attack Tree claimed "9 threads" directly above a tree rendering 6 nodes. summary.threads is set to findings.length in mapRunLog, but one thread can produce several findings, so every thread-labelled number was really a finding count. Derive the true count from distinct threadIds and use it for the tree subtitle, the exploration row, and the narrative; headline the executive card as "Findings" since its dots are per-finding verdicts, with the thread count as its subline. Also say "confirmed findings" in the Findings subtitle so it can't be read as a thread count. Record verifierModel only when verification actually ran. verifyEnabled gates whether the self_check tool is granted, so recording it unconditionally made the report advertise a verifier for runs that never verified. Finish the extraction brand.ts started: esc, truncate, formatDuration, formatTokenCount, safetyColor, gaugeSvg, roleLabel and SEV_HEX/SEV_ORDER were byte-identical copies in both renderers. They now live in report/format.ts. Confirmed behaviour-neutral by diffing rendered output before and after. Namespace the logo's twelve gradient ids (paintN_r → opfor-logo-grad-N). SVG ids share the document id space, so the generic names would collide if a report ever embedded the wordmark twice. Drop the unused .gauge-sub rule, which no template emitted. Co-Authored-By: Claude Opus 5 (1M context) --- core/src/autonomous/orchestrator/run.ts | 6 +- core/src/autonomous/report/html.ts | 82 ++++++------------------- core/src/report/brand.ts | 48 +++++++-------- core/src/report/format.ts | 74 ++++++++++++++++++++++ core/src/report/render.ts | 75 +++------------------- 5 files changed, 133 insertions(+), 152 deletions(-) create mode 100644 core/src/report/format.ts diff --git a/core/src/autonomous/orchestrator/run.ts b/core/src/autonomous/orchestrator/run.ts index 21464c0a..078dfab7 100644 --- a/core/src/autonomous/orchestrator/run.ts +++ b/core/src/autonomous/orchestrator/run.ts @@ -338,6 +338,10 @@ export async function runAutonomous( report.commanderModel = options.commanderModel; report.operatorModel = options.operatorModel; report.scoutModel = options.scoutModel; - report.verifierModel = options.verifierModel ?? options.commanderModel; + // Left unset when verification never ran, so the report doesn't advertise a verifier that + // was never granted the self_check tool. The renderer falls back to "—". + report.verifierModel = verifyEnabled + ? (options.verifierModel ?? options.commanderModel) + : undefined; return report; } diff --git a/core/src/autonomous/report/html.ts b/core/src/autonomous/report/html.ts index 7039973d..d2ff4883 100644 --- a/core/src/autonomous/report/html.ts +++ b/core/src/autonomous/report/html.ts @@ -16,57 +16,16 @@ import { SEVERITY_ICON, CHEVRON_ICON, } from "../../report/brand.js"; - -function esc(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n) + "…" : s; -} - -/** Format a run duration for display (e.g. 754000 → "12m 34s"). */ -function formatDuration(ms: number): string { - const totalSeconds = Math.round(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (hours > 0) return `${hours}h ${minutes}m`; - if (minutes > 0) return `${minutes}m ${seconds}s`; - return `${seconds}s`; -} - -/** Map a safety score (0–100) to a red/amber/green hex colour. */ -function safetyColor(score: number): string { - if (score >= 70) return "#059669"; - if (score >= 50) return "#D97706"; - return "#DC2626"; -} - -const SEV_HEX: Record = { - critical: "#DC2626", - high: "#EA580C", - medium: "#D97706", - low: "#16A34A", -}; -const SEV_ORDER: Severity[] = ["critical", "high", "medium", "low"]; - -/** Semi-circular SVG gauge for a 0-100 percentage score. */ -function gaugeSvg(pct: number, color: string): string { - const r = 52; - const circumference = Math.PI * r; - const offset = circumference * (1 - Math.max(0, Math.min(100, pct)) / 100); - return ` - - - `; -} +import { + esc, + truncate, + formatDuration, + safetyColor, + gaugeSvg, + roleLabel, + SEV_HEX, + SEV_ORDER, +} from "../../report/format.js"; /** Wall-clock run time, when the report carries a usable `startedAt` (older ones don't). */ function runDurationMs(r: AutonomousReport): number | undefined { @@ -79,11 +38,6 @@ function runDurationMs(r: AutonomousReport): number | undefined { // ── Transcript ─────────────────────────────────────────────────── -/** Role label row: icon + name. */ -function roleLabel(icon: string, name: string): string { - return `
${icon}${name}
`; -} - /** One conversation turn: attacker prompt as plain text, target response as a bubble that's * tinted when the judge cited this turn in `failingTurns`. `id` anchors the turn for the * rail's click-to-scroll + scroll-spy. */ @@ -263,7 +217,7 @@ function renderAttackTree(r: AutonomousReport, num: number): string { const e = r.exploration; return `
${num}
Attack Tree
-
${r.summary.threads} threads · ${e.leadsFlagged} leads (${e.leadsSpawned} expanded / ${e.leadsDismissed} dropped) · depth ${e.maxDepthReached}
+
${byId.size} thread${byId.size === 1 ? "" : "s"} · ${e.leadsFlagged} leads (${e.leadsSpawned} expanded / ${e.leadsDismissed} dropped) · depth ${e.maxDepthReached}
${esc(tree)}
`; } @@ -301,6 +255,9 @@ export function renderReportHtml(r: AutonomousReport): string { // Safety score mirrors the run report's 0-100 scale: the inverse of attack success. // N/A when nothing was conclusively scored (no confirmed and no defended threads). + // summary.threads is really a finding count (mapRunLog sets it to findings.length), and one + // thread can yield several findings — so derive the real thread count for thread-labelled UI. + const threadCount = new Set(r.findings.map((f) => f.threadId)).size; const scoreable = r.summary.confirmed + r.summary.defended; const safetyScore = scoreable > 0 ? 100 - r.summary.attackSuccessRate : null; const durationMs = runDurationMs(r); @@ -367,7 +324,7 @@ export function renderReportHtml(r: AutonomousReport): string { const narrative = r.synthesisComplete ? esc(r.executiveNarrative) - : `Assessment of ${esc(r.target.name)}: ${r.summary.confirmed} confirmed vulnerabilit${r.summary.confirmed === 1 ? "y" : "ies"} (${crit} critical, ${high} high) across ${r.summary.threads} attack threads — ${r.summary.attackSuccessRate}% attack-success rate.${r.truncated ? ` Run truncated: ${esc(r.truncationReason ?? "")}.` : ""}`; + : `Assessment of ${esc(r.target.name)}: ${r.summary.confirmed} confirmed vulnerabilit${r.summary.confirmed === 1 ? "y" : "ies"} (${crit} critical, ${high} high) across ${threadCount} attack thread${threadCount === 1 ? "" : "s"} — ${r.summary.attackSuccessRate}% attack-success rate.${r.truncated ? ` Run truncated: ${esc(r.truncationReason ?? "")}.` : ""}`; const outcomeLabel = r.objectiveOutcome.replace(/-/g, " "); @@ -732,13 +689,14 @@ export function renderReportHtml(r: AutonomousReport): string {
${safetyScore === null ? "N/A" : `${safetyScore}%`}
-
Attack Threads
-
${r.summary.threads}
+
Findings
+
${r.findings.length}
${r.summary.confirmed} confirmed
${r.summary.defended} defended
${r.summary.errors > 0 ? `
${r.summary.errors} errored
` : ""}
+
across ${threadCount} attack thread${threadCount === 1 ? "" : "s"}
Attack Success
@@ -772,7 +730,7 @@ export function renderReportHtml(r: AutonomousReport): string {
Exploration
-
Attack Threads${r.summary.threads}
+
Attack Threads${threadCount}
Leads Flagged${r.exploration.leadsFlagged}
Leads Expanded / Dismissed${r.exploration.leadsSpawned} / ${r.exploration.leadsDismissed}
Max Depth Reached${r.exploration.maxDepthReached}${r.exploration.maxDepthReached === 0 ? " (root wave only)" : ""}
@@ -831,7 +789,7 @@ export function renderReportHtml(r: AutonomousReport): string {
${findingsNo}
Findings
-
${fails.length} confirmed · ${defended.length} defended${errored.length ? ` · ${errored.length} errored` : ""}${fails.length ? ` · red rail marker = breach turn` : ""}
+
${fails.length} confirmed finding${fails.length === 1 ? "" : "s"} · ${defended.length} defended${errored.length ? ` · ${errored.length} errored` : ""}${fails.length ? ` · red rail marker = breach turn` : ""}
${ordered.length ? ordered.map((f, i) => renderFindingCard(f, i)).join("") : '
No attack threads were recorded for this run.
'}
diff --git a/core/src/report/brand.ts b/core/src/report/brand.ts index f74a7595..2d6240c8 100644 --- a/core/src/report/brand.ts +++ b/core/src/report/brand.ts @@ -7,33 +7,33 @@ export const OPFOR_LOGO_SVG = ` - - - - - - + + + + + + - - - - - - + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + `; diff --git a/core/src/report/format.ts b/core/src/report/format.ts new file mode 100644 index 00000000..222a9544 --- /dev/null +++ b/core/src/report/format.ts @@ -0,0 +1,74 @@ +/** + * Formatting + rendering primitives shared by the report renderers + * (`report/render.ts` for `opfor run`, `autonomous/report/html.ts` for `opfor hunt`). + * Kept together so the two reports can't drift on escaping, number formatting, or severity colour. + */ + +import type { Severity } from "../evaluators/schema.js"; + +/** Escape HTML special characters to prevent XSS in report output. */ +export function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Truncate a string to `n` characters, appending an ellipsis if clipped. */ +export function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + "…" : s; +} + +/** Format a token count for display (e.g. 51300 → "51.3K"). */ +export function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +/** Format a run duration for display (e.g. 754000 → "12m 34s"). */ +export function formatDuration(ms: number): string { + const totalSeconds = Math.round(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +/** Map a safety score (0–100) to a red/amber/green hex colour. */ +export function safetyColor(score: number): string { + if (score >= 70) return "#059669"; + if (score >= 50) return "#D97706"; + return "#DC2626"; +} + +export const SEV_HEX: Record = { + critical: "#DC2626", + high: "#EA580C", + medium: "#D97706", + low: "#16A34A", +}; + +/** Worst-first, for ranking findings and picking a group's headline severity. */ +export const SEV_ORDER: Severity[] = ["critical", "high", "medium", "low"]; + +/** Semi-circular SVG gauge for a 0-100 percentage score. */ +export function gaugeSvg(pct: number, color: string): string { + const r = 52; + const circumference = Math.PI * r; + const offset = circumference * (1 - Math.max(0, Math.min(100, pct)) / 100); + return ` + + + `; +} + +/** Transcript role label row: icon + name. */ +export function roleLabel(icon: string, name: string): string { + return `
${icon}${name}
`; +} diff --git a/core/src/report/render.ts b/core/src/report/render.ts index 06237191..2227c9db 100644 --- a/core/src/report/render.ts +++ b/core/src/report/render.ts @@ -12,53 +12,16 @@ import { AGENT_ICON, SEVERITY_ICON, } from "./brand.js"; - -/** Format a token count for display (e.g. 51300 → "51.3K"). */ -function formatTokenCount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return String(n); -} - -/** Format a run duration for display (e.g. 754000 → "12m 34s"). */ -function formatDuration(ms: number): string { - const totalSeconds = Math.round(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - if (hours > 0) return `${hours}h ${minutes}m`; - if (minutes > 0) return `${minutes}m ${seconds}s`; - return `${seconds}s`; -} - -/** Escape HTML special characters to prevent XSS in report output. */ -function esc(s: string): string { - return s - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -/** Truncate a string to `n` characters, appending an ellipsis if clipped. */ -function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n) + "…" : s; -} - -/** Map a safety score (0–100) to a red/amber/green hex colour. */ -function safetyColor(score: number): string { - if (score >= 70) return "#059669"; - if (score >= 50) return "#D97706"; - return "#DC2626"; -} - -const SEV_HEX: Record = { - critical: "#DC2626", - high: "#EA580C", - medium: "#D97706", - low: "#16A34A", -}; +import { + esc, + truncate, + formatTokenCount, + formatDuration, + safetyColor, + gaugeSvg, + roleLabel, + SEV_HEX, +} from "./format.js"; // ── Mode-specific labels ───────────────────────────────────────── @@ -90,18 +53,6 @@ function modeLabels(mode: "agent" | "mcp"): ModeLabels { }; } -/** Semi-circular SVG gauge for a 0-100 percentage score. */ -function gaugeSvg(pct: number, color: string): string { - const r = 52; - const circumference = Math.PI * r; - const offset = circumference * (1 - Math.max(0, Math.min(100, pct)) / 100); - return ` - - - `; -} - // ── Public API ─────────────────────────────────────────────────── /** Render a complete HTML report from a {@link ReportViewModel}. */ @@ -317,7 +268,6 @@ export function renderReport(model: ReportViewModel): string { /* Width matches the gauge so the value centres under the arc while the block itself stays left-aligned with the label, like every other card in the strip. */ .gauge-value{font-size:22px;font-weight:800;color:var(--text);width:120px;text-align:center;margin-top:-30px} - .gauge-sub{font-size:12px;color:var(--muted);text-align:center;margin-top:2px} .sc-value{font-size:26px;font-weight:800;line-height:1;color:var(--text)} .sc-dots{display:flex;flex-direction:column;gap:3px;margin-top:8px} .sc-dot-row{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)} @@ -586,11 +536,6 @@ export function renderReport(model: ReportViewModel): string { // ── Result detail card ──────────────────────────────────────────── -/** Role label row: icon + name. */ -function roleLabel(icon: string, name: string): string { - return `
${icon}${name}
`; -} - /** "TURN N ────" heading rule that opens each turn block. */ function turnHeading(turnIndex: number): string { return `
Turn ${turnIndex}
`;