diff --git a/core/src/autonomous/orchestrator/run.ts b/core/src/autonomous/orchestrator/run.ts index 141d176..078dfab 100644 --- a/core/src/autonomous/orchestrator/run.ts +++ b/core/src/autonomous/orchestrator/run.ts @@ -337,5 +337,11 @@ export async function runAutonomous( const report = mapRunLogToReport(runLog); report.commanderModel = options.commanderModel; report.operatorModel = options.operatorModel; + report.scoutModel = options.scoutModel; + // 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 b05f2ab..d2ff488 100644 --- a/core/src/autonomous/report/html.ts +++ b/core/src/autonomous/report/html.ts @@ -1,109 +1,204 @@ -// 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"; - -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; +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"; +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 { + 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; } -const SEV_HEX: Record = { - critical: "#DC2626", - high: "#EA580C", - medium: "#D97706", - low: "#16A34A", -}; -const SEV_ORDER: Severity[] = ["critical", "high", "medium", "low"]; - -function expandableBlock(content: string, fadeColor = "#F1F5F9"): string { - return `
-
${content}
-
- -
`; + +// ── Transcript ─────────────────────────────────────────────────── + +/** 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())} +
+
+
+ thread ${esc(f.threadId)}| + ${verdictLabel} +
+
+
+ ${pills ? `
${pills}
` : ""} +
+ +
${esc(f.reasoning)}
- ${f.evidence && f.evidence !== "N/A" ? `
${esc(truncate(f.evidence, 1400))}
` : ""} -
${esc(f.reasoning)}
- ${selfCheckBlock} - ${turns ? `
${turns}
` : ""} + ${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 +216,14 @@ function renderAttackTree(r: AutonomousReport): string { ); const e = r.exploration; return `
-
5
Attack Tree
-
${r.summary.threads} threads · ${e.leadsFlagged} leads (${e.leadsSpawned} expanded / ${e.leadsDismissed} dropped) · depth ${e.maxDepthReached}
-
${esc(tree)}
+
${num}
Attack Tree
+
${byId.size} thread${byId.size === 1 ? "" : "s"} · ${e.leadsFlagged} leads (${e.leadsSpawned} expanded / ${e.leadsDismissed} dropped) · depth ${e.maxDepthReached}
+
${esc(tree)}
`; } +// ── Public API ─────────────────────────────────────────────────── + export function renderReportHtml(r: AutonomousReport): string { const now = new Date(r.generatedAt); const dateStr = now.toLocaleDateString("en-US", { @@ -137,29 +234,42 @@ 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). + // 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); + 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 +284,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 +299,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 +314,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 +322,589 @@ 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 recs = r.recommendations.length - ? `
7
Recommendations
-
    ${r.recommendations.map((x) => `
  1. ${esc(x)}
  2. `).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 ${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, " "); + + // ── 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}%`}
+
+
+
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
+
${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${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)" : ""}
+
+
+
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 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.
'}
- ${recs} - ${patterns} + ${treeNo ? renderAttackTree(r, treeNo) : ""} + + ${ + recsNo + ? `
+
${recsNo}
Recommendations
+
    ${r.recommendations.map((x) => `
  1. ${esc(x)}
  2. `).join("")}
+
` + : "" + } - ${strategies || decisionLog || inventions ? `
9
Appendices
${strategies}${decisionLog}${inventions}
` : ""} + ${ + 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 c07ac7c..a50ce0a 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 0000000..2d6240c --- /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/format.ts b/core/src/report/format.ts new file mode 100644 index 0000000..222a954 --- /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 005e9f8..2227c9d 100644 --- a/core/src/report/render.ts +++ b/core/src/report/render.ts @@ -4,53 +4,24 @@ */ import type { ReportViewModel, ResultViewModel, TurnViewModel, DetailCard } from "./types.js"; import { formatStandardsLabel } from "../evaluators/standards.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 { + OPFOR_LOGO_SVG, + LOCK_ICON, + COPY_ICON, + ATTACKER_ICON, + AGENT_ICON, + SEVERITY_ICON, +} from "./brand.js"; +import { + esc, + truncate, + formatTokenCount, + formatDuration, + safetyColor, + gaugeSvg, + roleLabel, + SEV_HEX, +} from "./format.js"; // ── Mode-specific labels ───────────────────────────────────────── @@ -82,58 +53,6 @@ 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 ` - - - `; -} - // ── Public API ─────────────────────────────────────────────────── /** Render a complete HTML report from a {@link ReportViewModel}. */ @@ -335,7 +254,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,8 +265,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} - .gauge-sub{font-size:12px;color:var(--muted);text-align:center;margin-top:2px} + /* 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} .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)} @@ -491,8 +413,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}%`}
@@ -614,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}
`;