From 9f47d4c7a155aa9622eec0e87dd60d3973e1ced3 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 03:08:14 -0400 Subject: [PATCH 1/5] Browse passive auth report artifacts --- .../artifacts/[artifactId]/route.ts | 81 +++ src/components/ChatWorkspace.tsx | 1 + src/components/chat/ArtifactReportDialog.tsx | 496 ++++++++++++++++++ src/components/chat/ProjectThreadPanel.tsx | 62 ++- src/server/chat/projectAdapter.ts | 4 + src/server/chat/types.ts | 1 + src/styles/chat.css | 341 ++++++++++++ tests/integration/artifact-report-api.test.ts | 141 +++++ 8 files changed, 1112 insertions(+), 15 deletions(-) create mode 100644 src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts create mode 100644 src/components/chat/ArtifactReportDialog.tsx create mode 100644 tests/integration/artifact-report-api.test.ts diff --git a/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts b/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts new file mode 100644 index 000000000..4780ad516 --- /dev/null +++ b/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts @@ -0,0 +1,81 @@ +import { listArtifacts } from "../../../../../../server/chat/service"; +import { getArtifactService } from "../../../../../../server/evidence"; +import { handleApiError, notFound, ok } from "../../../../_shared/http"; + +export const dynamic = "force-dynamic"; + +const RAW_EVIDENCE_MAX_BYTES = 32 * 1024; +const NO_STORE = { headers: { "Cache-Control": "no-store" } }; + +type Context = { + params: Promise<{ projectId: string; artifactId: string }>; +}; + +export async function GET(_request: Request, context: Context) { + try { + const { projectId, artifactId } = await context.params; + const artifact = ( + await listArtifacts(projectId, { + artifactId, + limit: 1, + }) + )[0]; + + if (!artifact) { + return notFound(`Artifact ${artifactId} was not found in this project.`); + } + + const reader = getArtifactService().readArtifactText; + if (!reader) { + return ok( + { + artifact, + evidence: { + status: "unavailable" as const, + message: "Text preview is unavailable for this artifact.", + maxBytes: RAW_EVIDENCE_MAX_BYTES, + }, + }, + NO_STORE, + ); + } + + try { + const content = await reader({ + projectId, + artifactId, + maxBytes: RAW_EVIDENCE_MAX_BYTES, + }); + return ok( + { + artifact, + evidence: { + status: "text" as const, + text: content.text, + truncated: content.truncated, + maxBytes: RAW_EVIDENCE_MAX_BYTES, + }, + }, + NO_STORE, + ); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + const binary = /not a text artifact|binary/i.test(message); + return ok( + { + artifact, + evidence: { + status: binary ? ("binary" as const) : ("unavailable" as const), + message: binary + ? "This artifact is binary, so a text preview is not available." + : "The artifact is recorded, but its text preview is currently unavailable.", + maxBytes: RAW_EVIDENCE_MAX_BYTES, + }, + }, + NO_STORE, + ); + } + } catch (error) { + return handleApiError(error); + } +} diff --git a/src/components/ChatWorkspace.tsx b/src/components/ChatWorkspace.tsx index 836d56e40..8e13c825c 100644 --- a/src/components/ChatWorkspace.tsx +++ b/src/components/ChatWorkspace.tsx @@ -2837,6 +2837,7 @@ export function ChatWorkspace({ projectOverview={ project ? ( ; + blockers: Array<{ + reason: string; + evidence: string; + evidenceArtifactIds: string[]; + }>; + unknowns: string[]; + nextSteps: { + passive: string[]; + approvalGated: string[]; + reportOrPatch: string[]; + }; +}; + +export function ArtifactReportDialog({ + projectId, + artifactId, + onClose, +}: { + projectId: string; + artifactId: string; + onClose(): void; +}) { + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + const [rawArtifactId, setRawArtifactId] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setDetail(null); + setError(null); + setRawArtifactId(null); + void readArtifact(projectId, artifactId, controller.signal) + .then(setDetail) + .catch((cause: unknown) => { + if (!controller.signal.aborted) { + setError(cause instanceof Error ? cause.message : "Artifact preview is unavailable."); + } + }); + return () => controller.abort(); + }, [artifactId, projectId]); + + useEffect(() => { + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [onClose]); + + const report = useMemo(() => parsePassiveAuthReport(detail), [detail]); + + return ( +
+
event.stopPropagation()} + > +
+ + +

{detail?.artifact.name ?? "Artifact report"}

+ {artifactId} +
+ +
+ +
+ {error ?

{error}

: null} + {!detail && !error ?

Loading artifact…

: null} + {detail && report ? ( + + ) : null} + {detail && !report ? : null} + {rawArtifactId ? ( + setRawArtifactId(null)} + /> + ) : null} +
+
+
+ ); +} + +function PassiveAuthReportView({ + report, + onRevealEvidence, +}: { + report: PassiveAuthReport; + onRevealEvidence(artifactId: string): void; +}) { + return ( +
+
+ + Target +

{report.targetId}

+
+ +
+ + + {report.authRoutes.length ? ( +
+ {report.authRoutes.map((route) => ( +
+
+

{formatLabel(route.category)}

+ + {route.confidence} confidence + +
+
    + {route.urls.map((url) => ( +
  • {url}
  • + ))} +
+ +
+ ))} +
+ ) : ( +

No authentication routes were established.

+ )} +
+ + + {report.blockers.length ? ( +
+ {report.blockers.map((blocker, index) => ( +
+
+
+

{blocker.evidence}

+ +
+ ))} +
+ ) : ( +

No passive-review blockers were recorded.

+ )} +
+ + + {report.unknowns.length ? ( +
+ {report.unknowns.map((unknown) => ( +
+

{unknown}

+ +
+ ))} +
+ ) : ( +

+ No unresolved auth-surface unknowns were recorded. +

+ )} +
+ + + + + + +
+ ); +} + +function ReportSection({ + title, + count, + tone, + children, +}: { + title: string; + count?: number; + tone?: "warning"; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {typeof count === "number" ? {count} : null} +
+ {children} +
+ ); +} + +function NextStepGroup({ + title, + steps, + artifactIds, + onReveal, +}: { + title: string; + steps: string[]; + artifactIds: string[]; + onReveal(artifactId: string): void; +}) { + return ( +
+

{title}

+ {steps.length ? ( +
    + {steps.map((step) => ( +
  1. + {step} + +
  2. + ))} +
+ ) : ( +

No steps recorded.

+ )} +
+ ); +} + +function ClaimEvidence({ + artifactIds, + onReveal, +}: { + artifactIds: string[]; + onReveal(artifactId: string): void; +}) { + const ids = [...new Set(artifactIds)]; + return ( +
+ + Raw evidence · {ids.length} artifact ID{ids.length === 1 ? "" : "s"} + + {ids.length ? ( +
    + {ids.map((artifactId) => ( +
  • + +
  • + ))} +
+ ) : ( +

No raw artifact ID is linked to this claim.

+ )} +
+ ); +} + +function GenericArtifactView({ detail }: { detail: ArtifactDetail }) { + const [revealed, setRevealed] = useState(false); + return ( +
+

Artifact preview

+ {detail.evidence.status === "text" ? ( + revealed ? ( + + ) : ( + + ) + ) : ( +

{detail.evidence.message}

+ )} +
+ ); +} + +function RawEvidencePanel({ + projectId, + artifactId, + onClose, +}: { + projectId: string; + artifactId: string; + onClose(): void; +}) { + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const controller = new AbortController(); + setDetail(null); + setError(null); + void readArtifact(projectId, artifactId, controller.signal) + .then(setDetail) + .catch((cause: unknown) => { + if (!controller.signal.aborted) { + setError(cause instanceof Error ? cause.message : "Raw evidence is unavailable."); + } + }); + return () => controller.abort(); + }, [artifactId, projectId]); + + return ( + + ); +} + +function EvidenceText({ evidence }: { evidence: Extract }) { + return ( +
+

+

+
{evidence.text}
+ {evidence.truncated ?

Preview truncated at {formatBytes(evidence.maxBytes)}.

: null} +
+ ); +} + +async function readArtifact(projectId: string, artifactId: string, signal: AbortSignal) { + const response = await fetch( + `/api/projects/${encodeURIComponent(projectId)}/artifacts/${encodeURIComponent(artifactId)}`, + { cache: "no-store", signal }, + ); + const body = (await response.json().catch(() => null)) as + | ArtifactDetail + | { error?: string } + | null; + if (!response.ok) { + throw new Error(body && "error" in body && body.error ? body.error : "Artifact not found."); + } + if (!body || !("artifact" in body) || !("evidence" in body)) { + throw new Error("Artifact response was incomplete."); + } + return body; +} + +function parsePassiveAuthReport(detail: ArtifactDetail | null): PassiveAuthReport | null { + if (!detail || detail.evidence.status !== "text") return null; + if (detail.artifact.metadata?.workflow !== "passive-auth-surface-v1") return null; + try { + const value = JSON.parse(detail.evidence.text) as Record; + if ( + typeof value.targetId !== "string" || + !isStringArray(value.rawArtifactIds) || + !Array.isArray(value.authRoutes) || + !Array.isArray(value.blockers) || + !isStringArray(value.unknowns) || + !isRecord(value.nextSteps) + ) { + return null; + } + const authRoutes = value.authRoutes.flatMap((candidate) => { + if (!isRecord(candidate) || typeof candidate.category !== "string") return []; + if (candidate.confidence !== "high" && candidate.confidence !== "medium") return []; + if (!isStringArray(candidate.urls) || !isStringArray(candidate.evidenceArtifactIds)) + return []; + const confidence: "high" | "medium" = candidate.confidence; + return [ + { + category: candidate.category, + confidence, + urls: candidate.urls, + evidenceArtifactIds: candidate.evidenceArtifactIds, + }, + ]; + }); + const blockers = value.blockers.flatMap((candidate) => { + if ( + !isRecord(candidate) || + typeof candidate.reason !== "string" || + typeof candidate.evidence !== "string" || + !isStringArray(candidate.evidenceArtifactIds) + ) { + return []; + } + return [ + { + reason: candidate.reason, + evidence: candidate.evidence, + evidenceArtifactIds: candidate.evidenceArtifactIds, + }, + ]; + }); + const passive = value.nextSteps.passive; + const approvalGated = value.nextSteps.approvalGated; + const reportOrPatch = value.nextSteps.reportOrPatch; + if (!isStringArray(passive) || !isStringArray(approvalGated) || !isStringArray(reportOrPatch)) { + return null; + } + return { + targetId: value.targetId, + rawArtifactIds: value.rawArtifactIds, + authRoutes, + blockers, + unknowns: value.unknowns, + nextSteps: { passive, approvalGated, reportOrPatch }, + }; + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function formatLabel(value: string) { + return value.replaceAll("-", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function formatBytes(value: number) { + return value >= 1024 ? `${Math.round(value / 1024)} KiB` : `${value} bytes`; +} diff --git a/src/components/chat/ProjectThreadPanel.tsx b/src/components/chat/ProjectThreadPanel.tsx index 13f66d23a..4d00c1da7 100644 --- a/src/components/chat/ProjectThreadPanel.tsx +++ b/src/components/chat/ProjectThreadPanel.tsx @@ -22,6 +22,7 @@ import type { ApprovalDuration } from "../../server/approvals/types"; import type { RequestDecisionView } from "../chatWorkspaceView"; import { type ApprovalCardProps, ApprovalSplitButton } from "../tool-ui/approval-card"; import type { ChatProjectStatsView } from "../types"; +import { ArtifactReportDialog } from "./ArtifactReportDialog"; import type { ThreadView } from "./messageUtils"; import { type ArtifactMetadataView, formatCost, formatThreadTime } from "./messageUtils"; @@ -146,6 +147,7 @@ function formatRelativeTime(value?: string) { } export function ProjectThreadPanel({ + projectId, stats, artifacts, threads, @@ -168,6 +170,7 @@ export function ProjectThreadPanel({ yoloDurationMinutes, onYoloModeChange, }: { + projectId: string; stats: ProjectStatsView; artifacts: ArtifactMetadataView[]; threads: ThreadView[]; @@ -197,6 +200,7 @@ export function ProjectThreadPanel({ const [editingDecision, setEditingDecision] = useState(); const [isSaving, setIsSaving] = useState(false); const [countdownNow, setCountdownNow] = useState(() => Date.now()); + const [selectedArtifactId, setSelectedArtifactId] = useState(null); const totalCount = requestDecisions.length + (pendingApproval ? 1 : 0); const isYoloMode = approvalMode === "yolo"; const isLegacyAutoMode = approvalMode === "auto"; @@ -216,6 +220,23 @@ export function ProjectThreadPanel({ const interval = window.setInterval(() => setCountdownNow(Date.now()), 1000); return () => window.clearInterval(interval); }, [isYoloMode, yoloExpiresAt]); + + useEffect(() => { + const syncSelection = () => { + setSelectedArtifactId(new URL(window.location.href).searchParams.get("artifact")); + }; + syncSelection(); + window.addEventListener("popstate", syncSelection); + return () => window.removeEventListener("popstate", syncSelection); + }, [projectId]); + + const selectArtifact = (artifactId: string | null) => { + const url = new URL(window.location.href); + if (artifactId) url.searchParams.set("artifact", artifactId); + else url.searchParams.delete("artifact"); + window.history.pushState({}, "", url); + setSelectedArtifactId(artifactId); + }; const accessStats = useMemo(() => { const targets = requestDecisions.filter((decision) => decision.kind === "target").length; const commands = requestDecisions.filter((decision) => decision.kind === "command").length; @@ -704,21 +725,24 @@ export function ProjectThreadPanel({ const description = artifactDescription(artifact); const timestamp = artifact.updatedAt || artifact.createdAt; return ( -
  • - - - {artifact.name} - {description} - - +
  • +
  • ); })} @@ -730,6 +754,14 @@ export function ProjectThreadPanel({ ) : null} + {selectedArtifactId ? ( + selectArtifact(null)} + /> + ) : null} +
    { const values: unknown[] = [projectId]; const where = ["project_id = $1"]; + if (options.artifactId) { + values.push(options.artifactId); + where.push(`id = $${values.length}`); + } if (options.threadId) { values.push(options.threadId); where.push( diff --git a/src/server/chat/types.ts b/src/server/chat/types.ts index 4679e6271..32eec89da 100644 --- a/src/server/chat/types.ts +++ b/src/server/chat/types.ts @@ -168,6 +168,7 @@ export type ArtifactMetadata = { }; export type ArtifactListOptions = { + artifactId?: string; threadId?: string; includeProjectScoped?: boolean; limit?: number; diff --git a/src/styles/chat.css b/src/styles/chat.css index 84f8e7f14..03912bbde 100644 --- a/src/styles/chat.css +++ b/src/styles/chat.css @@ -3228,9 +3228,14 @@ align-items: start; gap: 0.42rem; min-width: 0; + width: 100%; border: 1px solid transparent; border-radius: 0.375rem; + background: transparent; + color: inherit; padding: 0.4rem; + cursor: pointer; + text-align: left; transition: border-color 140ms ease, background 140ms ease; @@ -3241,6 +3246,342 @@ background: rgb(255 255 255 / 4%); } +.artifact-sidebar-item:focus-visible { + border-color: var(--accent); + outline: none; +} + +.artifact-report-backdrop { + position: fixed; + inset: 0; + z-index: 70; + display: grid; + place-items: center; + padding: 1.25rem; + background: rgb(5 7 10 / 68%); + backdrop-filter: blur(8px); +} + +.artifact-report-dialog { + width: min(940px, 100%); + max-height: min(880px, calc(100vh - 2.5rem)); + overflow: hidden; + border: 1px solid rgb(146 163 157 / 24%); + border-radius: 10px; + background: #101716; + color: #eef5f1; + box-shadow: 0 24px 80px rgb(0 0 0 / 48%); +} + +.artifact-report-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.8rem; + align-items: start; + padding: 1rem 1.1rem; + border-bottom: 1px solid rgb(146 163 157 / 18%); +} + +.artifact-report-header h2, +.artifact-report-header code, +.artifact-report-section h3, +.artifact-claim h4, +.artifact-next-step-group h4, +.raw-evidence-panel h3, +.generic-artifact-view h3 { + margin: 0; +} + +.artifact-report-header h2 { + overflow: hidden; + color: #f0f7f3; + font-size: 1rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.artifact-report-header code { + display: block; + margin-top: 0.22rem; + color: #8ea49d; + font-size: 0.72rem; +} + +.artifact-report-icon { + display: grid; + place-items: center; + width: 2.1rem; + height: 2.1rem; + border-radius: 8px; + background: rgb(83 199 167 / 14%); + color: #9ff0d2; +} + +.artifact-report-content { + display: grid; + gap: 1rem; + max-height: calc(min(880px, 100vh - 2.5rem) - 4.3rem); + padding: 1rem 1.1rem 1.25rem; + overflow: auto; +} + +.passive-auth-report { + display: grid; + gap: 1rem; +} + +.artifact-report-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 0.9rem; + border: 1px solid rgb(83 199 167 / 22%); + border-radius: 8px; + background: rgb(83 199 167 / 7%); +} + +.artifact-report-summary h3 { + margin: 0.12rem 0 0; + font-size: 1.05rem; +} + +.artifact-report-eyebrow { + color: #8ea49d; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.artifact-report-section { + display: grid; + gap: 0.7rem; +} + +.artifact-report-section > header { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.artifact-report-section > header h3, +.generic-artifact-view h3, +.raw-evidence-panel h3 { + color: #e8f1ed; + font-size: 0.9rem; +} + +.artifact-report-section > header > span { + display: inline-grid; + place-items: center; + min-width: 1.35rem; + height: 1.35rem; + border: 1px solid rgb(146 163 157 / 22%); + border-radius: 999px; + color: #9cafaa; + font-size: 0.68rem; +} + +.artifact-claim-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(270px, 100%), 1fr)); + gap: 0.6rem; +} + +.artifact-claim, +.artifact-next-step-group { + display: grid; + align-content: start; + gap: 0.55rem; + min-width: 0; + padding: 0.75rem; + border: 1px solid rgb(146 163 157 / 18%); + border-radius: 8px; + background: rgb(255 255 255 / 2.5%); +} + +.artifact-claim.is-warning { + border-color: rgb(235 177 76 / 24%); + background: rgb(235 177 76 / 5%); +} + +.artifact-claim header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.55rem; +} + +.artifact-claim h4, +.artifact-next-step-group h4 { + color: #dce8e3; + font-size: 0.8rem; +} + +.artifact-claim p, +.artifact-claim ul, +.artifact-next-step-group ol, +.artifact-report-empty, +.claim-evidence p { + margin: 0; + color: #a9bbb5; + font-size: 0.78rem; + line-height: 1.48; +} + +.artifact-claim ul, +.artifact-next-step-group ol { + padding-left: 1.2rem; +} + +.artifact-next-step-group + .artifact-next-step-group { + margin-top: 0.1rem; +} + +.artifact-next-step-group li + li { + margin-top: 0.65rem; +} + +.confidence-badge { + flex: none; + padding: 0.18rem 0.42rem; + border: 1px solid rgb(146 163 157 / 22%); + border-radius: 999px; + color: #aec0ba; + font-size: 0.66rem; + text-transform: capitalize; +} + +.confidence-badge.is-high { + border-color: rgb(83 199 167 / 28%); + color: #9ff0d2; +} + +.claim-evidence { + min-width: 0; +} + +.claim-evidence summary { + width: fit-content; + color: #91cdbb; + cursor: pointer; + font-size: 0.7rem; + font-weight: 650; +} + +.claim-evidence ul { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; +} + +.claim-evidence button, +.artifact-reveal-button { + display: inline-flex; + align-items: center; + gap: 0.32rem; + border: 1px solid rgb(83 199 167 / 24%); + border-radius: 6px; + background: rgb(83 199 167 / 8%); + color: #a9e3d1; + padding: 0.34rem 0.5rem; + cursor: pointer; + font-size: 0.7rem; +} + +.claim-evidence button:hover, +.artifact-reveal-button:hover { + background: rgb(83 199 167 / 14%); +} + +.artifact-report-notice { + margin: 0; + padding: 0.8rem; + border: 1px solid rgb(146 163 157 / 18%); + border-radius: 8px; + background: rgb(255 255 255 / 3%); + color: #a9bbb5; + font-size: 0.82rem; +} + +.artifact-report-notice.is-error { + border-color: rgb(255 111 133 / 28%); + color: #ffc0ca; +} + +.generic-artifact-view, +.raw-evidence-panel { + display: grid; + gap: 0.7rem; +} + +.raw-evidence-panel { + padding: 0.85rem; + border: 1px solid rgb(83 199 167 / 24%); + border-radius: 8px; + background: #0c1211; +} + +.raw-evidence-panel > header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.7rem; +} + +.raw-evidence-text { + display: grid; + gap: 0.45rem; +} + +.raw-evidence-text p { + display: flex; + align-items: center; + gap: 0.32rem; + margin: 0; + color: #8fa49d; + font-size: 0.7rem; +} + +.raw-evidence-text pre { + max-height: 18rem; + margin: 0; + padding: 0.75rem; + overflow: auto; + border: 1px solid rgb(146 163 157 / 16%); + border-radius: 6px; + background: #090e0d; + color: #cfddd8; + font-size: 0.72rem; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 640px) { + .artifact-report-backdrop { + align-items: stretch; + padding: 0; + } + + .artifact-report-dialog { + max-height: 100vh; + border-radius: 0; + } + + .artifact-report-content { + max-height: calc(100vh - 4.3rem); + } + + .artifact-report-summary { + display: grid; + } +} + .artifact-sidebar-icon { display: grid; place-items: center; diff --git a/tests/integration/artifact-report-api.test.ts b/tests/integration/artifact-report-api.test.ts new file mode 100644 index 000000000..4ae2fa681 --- /dev/null +++ b/tests/integration/artifact-report-api.test.ts @@ -0,0 +1,141 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; +import { GET } from "../../src/app/api/projects/[projectId]/artifacts/[artifactId]/route"; +import { createSqlitePool, getDatabaseConfig } from "../../src/server/db/client"; +import { createArtifactService, setArtifactService } from "../../src/server/evidence"; + +const tempRoots: string[] = []; +let previousDatabaseUrl: string | undefined; + +afterEach(async () => { + setArtifactService(undefined); + if (previousDatabaseUrl === undefined) delete process.env.EH_APP_DB_URL; + else process.env.EH_APP_DB_URL = previousDatabaseUrl; + previousDatabaseUrl = undefined; + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("artifact report API", () => { + it("returns a bounded redacted text preview and hides artifacts from other projects", async () => { + const pool = await useTempDatabase(); + try { + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-report", + "Report project", + "report-project", + ]); + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-other", + "Other project", + "other-project", + ]); + + const service = createArtifactService({ storage: null }); + setArtifactService(service); + const report = await service.createArtifact({ + projectId: "project-report", + projectScoped: true, + name: "passive-auth-surface-target.json", + kind: "report", + contentType: "application/json", + content: `Authorization: Bearer sk-testabcdefghijklmnopqrstuvwxyz1234567890\n${"x".repeat(40_000)}`, + source: "passive-recon", + indexForRag: false, + metadata: { workflow: "passive-auth-surface-v1" }, + }); + const other = await service.createArtifact({ + projectId: "project-other", + projectScoped: true, + name: "other.txt", + content: "private to another project", + source: "terminal-note", + indexForRag: false, + }); + + const response = await readArtifact("project-report", report.id); + const body = (await response.json()) as { + artifact: { id: string }; + evidence: { status: string; text: string; truncated: boolean; maxBytes: number }; + }; + + expect(response.status).toBe(200); + expect(body.artifact.id).toBe(report.id); + expect(body.evidence).toMatchObject({ + status: "text", + truncated: true, + maxBytes: 32 * 1024, + }); + expect(body.evidence.text).toContain("Authorization: [redacted]"); + expect(body.evidence.text).not.toContain("sk-testabcdefghijklmnopqrstuvwxyz1234567890"); + + const crossProjectResponse = await readArtifact("project-report", other.id); + expect(crossProjectResponse.status).toBe(404); + } finally { + await pool.end(); + } + }); + + it("describes binary and unavailable evidence without failing the artifact view", async () => { + const pool = await useTempDatabase(); + try { + await pool.query("INSERT INTO projects (id, name, slug) VALUES ($1, $2, $3)", [ + "project-evidence", + "Evidence project", + "evidence-project", + ]); + await pool.query( + `INSERT INTO artifacts + (id, project_id, kind, name, content_type, storage_key, inline_content) + VALUES + ($1, $2, $3, $4, $5, $6, NULL), + ($7, $2, $3, $8, $9, NULL, NULL)`, + [ + "artifact-binary", + "project-evidence", + "file", + "capture.bin", + "application/octet-stream", + "objects/capture.bin", + "artifact-unavailable", + "missing.txt", + "text/plain", + ], + ); + setArtifactService(createArtifactService({ storage: null })); + + const binary = (await (await readArtifact("project-evidence", "artifact-binary")).json()) as { + evidence: { status: string }; + }; + const unavailable = (await ( + await readArtifact("project-evidence", "artifact-unavailable") + ).json()) as { evidence: { status: string } }; + + expect(binary.evidence.status).toBe("binary"); + expect(unavailable.evidence.status).toBe("unavailable"); + } finally { + await pool.end(); + } + }); +}); + +async function useTempDatabase() { + previousDatabaseUrl = process.env.EH_APP_DB_URL; + const root = await mkdtemp(join(tmpdir(), "exploit-hunter-artifact-report-")); + tempRoots.push(root); + process.env.EH_APP_DB_URL = `sqlite://${join(root, "app.sqlite")}`; + const pool = createSqlitePool(getDatabaseConfig()); + await pool.query("SELECT 1"); + return pool; +} + +function readArtifact(projectId: string, artifactId: string) { + return GET( + new Request(`http://localhost:3210/api/projects/${projectId}/artifacts/${artifactId}`), + { + params: Promise.resolve({ projectId, artifactId }), + }, + ); +} From 9d680327250030498ea19386cc73db282a05d4c6 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 08:24:47 -0400 Subject: [PATCH 2/5] Fix passive report review boundaries --- .../artifacts/[artifactId]/route.ts | 57 +++ src/components/chat/ArtifactReportDialog.tsx | 149 ++++---- src/components/chat/ProjectThreadPanel.tsx | 20 +- src/server/evidence/artifact-service.ts | 12 +- src/server/recon/passive-auth-surface.ts | 345 ++++++++++-------- src/server/storage/s3.ts | 107 +++++- tests/integration/artifact-report-api.test.ts | 181 ++++++++- tests/integration/artifact-service.test.ts | 3 +- .../upload-artifact-usage-e2e.test.ts | 12 + .../playwright/artifact-report-focus.spec.ts | 101 +++++ 10 files changed, 738 insertions(+), 249 deletions(-) create mode 100644 tests/playwright/artifact-report-focus.spec.ts diff --git a/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts b/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts index 4780ad516..8de2c18f2 100644 --- a/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts +++ b/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts @@ -1,5 +1,9 @@ import { listArtifacts } from "../../../../../../server/chat/service"; import { getArtifactService } from "../../../../../../server/evidence"; +import { + PASSIVE_AUTH_REPORT_MAX_BYTES, + parsePassiveAuthSurfaceSummary, +} from "../../../../../../server/recon/passive-auth-surface"; import { handleApiError, notFound, ok } from "../../../../_shared/http"; export const dynamic = "force-dynamic"; @@ -26,10 +30,15 @@ export async function GET(_request: Request, context: Context) { } const reader = getArtifactService().readArtifactText; + const report = await readPassiveAuthReport(artifact.metadata?.workflow, reader, { + projectId, + artifactId, + }); if (!reader) { return ok( { artifact, + ...(report ? { report } : {}), evidence: { status: "unavailable" as const, message: "Text preview is unavailable for this artifact.", @@ -49,6 +58,7 @@ export async function GET(_request: Request, context: Context) { return ok( { artifact, + ...(report ? { report } : {}), evidence: { status: "text" as const, text: content.text, @@ -64,6 +74,7 @@ export async function GET(_request: Request, context: Context) { return ok( { artifact, + ...(report ? { report } : {}), evidence: { status: binary ? ("binary" as const) : ("unavailable" as const), message: binary @@ -79,3 +90,49 @@ export async function GET(_request: Request, context: Context) { return handleApiError(error); } } + +async function readPassiveAuthReport( + workflow: unknown, + reader: ReturnType["readArtifactText"], + input: { projectId: string; artifactId: string }, +) { + if (workflow !== "passive-auth-surface-v1") return undefined; + if (!reader) { + return { + status: "unavailable" as const, + message: "The structured passive-auth report is currently unavailable.", + }; + } + + try { + const content = await reader({ ...input, maxBytes: PASSIVE_AUTH_REPORT_MAX_BYTES }); + if (content.truncated) { + return { + status: "over_limit" as const, + message: "This passive-auth report exceeds the structured report size limit.", + }; + } + let value: unknown; + try { + value = JSON.parse(content.text); + } catch { + return { + status: "invalid" as const, + message: "This passive-auth report is not valid JSON.", + }; + } + const parsed = parsePassiveAuthSurfaceSummary(value); + if (!parsed.success) { + return { + status: "invalid" as const, + message: "This passive-auth report does not match the supported report structure.", + }; + } + return { status: "ready" as const, data: parsed.data }; + } catch { + return { + status: "unavailable" as const, + message: "The structured passive-auth report is currently unavailable.", + }; + } +} diff --git a/src/components/chat/ArtifactReportDialog.tsx b/src/components/chat/ArtifactReportDialog.tsx index cc81d1bfc..9636cecf9 100644 --- a/src/components/chat/ArtifactReportDialog.tsx +++ b/src/components/chat/ArtifactReportDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { AlertTriangle, ExternalLink, FileStack, ShieldCheck, X } from "lucide-react"; -import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import type { ArtifactMetadataView } from "./messageUtils"; type EvidencePreview = @@ -11,6 +11,9 @@ type EvidencePreview = type ArtifactDetail = { artifact: ArtifactMetadataView; evidence: EvidencePreview; + report?: + | { status: "ready"; data: PassiveAuthReport } + | { status: "invalid" | "over_limit" | "unavailable"; message: string }; }; type PassiveAuthReport = { @@ -47,6 +50,7 @@ export function ArtifactReportDialog({ const [detail, setDetail] = useState(null); const [error, setError] = useState(null); const [rawArtifactId, setRawArtifactId] = useState(null); + const dialogRef = useRef(null); useEffect(() => { const controller = new AbortController(); @@ -64,22 +68,31 @@ export function ArtifactReportDialog({ }, [artifactId, projectId]); useEffect(() => { - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") onClose(); + const dialog = dialogRef.current; + if (!dialog) return; + focusFirstDialogControl(dialog); + const containFocus = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onClose(); + return; + } + if (event.key !== "Tab") return; + trapDialogTabKey(dialog, event); }; - window.addEventListener("keydown", closeOnEscape); - return () => window.removeEventListener("keydown", closeOnEscape); + dialog.addEventListener("keydown", containFocus); + return () => dialog.removeEventListener("keydown", containFocus); }, [onClose]); - const report = useMemo(() => parsePassiveAuthReport(detail), [detail]); - return (
    event.stopPropagation()} >
    @@ -98,10 +111,18 @@ export function ArtifactReportDialog({
    {error ?

    {error}

    : null} {!detail && !error ?

    Loading artifact…

    : null} - {detail && report ? ( - + {detail?.report?.status === "ready" ? ( + + ) : null} + {detail?.report && detail.report.status !== "ready" ? ( +

    {detail.report.message}

    + ) : null} + {detail && detail.report?.status !== "ready" ? ( + ) : null} - {detail && !report ? : null} {rawArtifactId ? ( ; - if ( - typeof value.targetId !== "string" || - !isStringArray(value.rawArtifactIds) || - !Array.isArray(value.authRoutes) || - !Array.isArray(value.blockers) || - !isStringArray(value.unknowns) || - !isRecord(value.nextSteps) - ) { - return null; - } - const authRoutes = value.authRoutes.flatMap((candidate) => { - if (!isRecord(candidate) || typeof candidate.category !== "string") return []; - if (candidate.confidence !== "high" && candidate.confidence !== "medium") return []; - if (!isStringArray(candidate.urls) || !isStringArray(candidate.evidenceArtifactIds)) - return []; - const confidence: "high" | "medium" = candidate.confidence; - return [ - { - category: candidate.category, - confidence, - urls: candidate.urls, - evidenceArtifactIds: candidate.evidenceArtifactIds, - }, - ]; - }); - const blockers = value.blockers.flatMap((candidate) => { - if ( - !isRecord(candidate) || - typeof candidate.reason !== "string" || - typeof candidate.evidence !== "string" || - !isStringArray(candidate.evidenceArtifactIds) - ) { - return []; - } - return [ - { - reason: candidate.reason, - evidence: candidate.evidence, - evidenceArtifactIds: candidate.evidenceArtifactIds, - }, - ]; - }); - const passive = value.nextSteps.passive; - const approvalGated = value.nextSteps.approvalGated; - const reportOrPatch = value.nextSteps.reportOrPatch; - if (!isStringArray(passive) || !isStringArray(approvalGated) || !isStringArray(reportOrPatch)) { - return null; - } - return { - targetId: value.targetId, - rawArtifactIds: value.rawArtifactIds, - authRoutes, - blockers, - unknowns: value.unknowns, - nextSteps: { passive, approvalGated, reportOrPatch }, - }; - } catch { - return null; - } +function formatLabel(value: string) { + return value.replaceAll("-", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +const FOCUSABLE_SELECTOR = [ + "button:not([disabled])", + "[href]", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +function focusFirstDialogControl(dialog: HTMLElement) { + const focusable = readFocusableElements(dialog); + (focusable[0] ?? dialog).focus(); } -function isStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every((item) => typeof item === "string"); +function trapDialogTabKey(dialog: HTMLElement, event: KeyboardEvent) { + const focusable = readFocusableElements(dialog); + if (!focusable.length) { + event.preventDefault(); + dialog.focus(); + return; + } + const first = focusable[0]; + const last = focusable.at(-1); + if ( + event.shiftKey && + (document.activeElement === first || !dialog.contains(document.activeElement)) + ) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } } -function formatLabel(value: string) { - return value.replaceAll("-", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +function readFocusableElements(dialog: HTMLElement) { + return [...dialog.querySelectorAll(FOCUSABLE_SELECTOR)].filter( + (element) => + element.getClientRects().length > 0 && element.getAttribute("aria-hidden") !== "true", + ); } function formatBytes(value: number) { diff --git a/src/components/chat/ProjectThreadPanel.tsx b/src/components/chat/ProjectThreadPanel.tsx index 4d00c1da7..a5972f822 100644 --- a/src/components/chat/ProjectThreadPanel.tsx +++ b/src/components/chat/ProjectThreadPanel.tsx @@ -17,7 +17,7 @@ import { Trash2, X, } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ApprovalDuration } from "../../server/approvals/types"; import type { RequestDecisionView } from "../chatWorkspaceView"; import { type ApprovalCardProps, ApprovalSplitButton } from "../tool-ui/approval-card"; @@ -201,6 +201,7 @@ export function ProjectThreadPanel({ const [isSaving, setIsSaving] = useState(false); const [countdownNow, setCountdownNow] = useState(() => Date.now()); const [selectedArtifactId, setSelectedArtifactId] = useState(null); + const artifactButtonsRef = useRef(new Map()); const totalCount = requestDecisions.length + (pendingApproval ? 1 : 0); const isYoloMode = approvalMode === "yolo"; const isLegacyAutoMode = approvalMode === "auto"; @@ -230,13 +231,20 @@ export function ProjectThreadPanel({ return () => window.removeEventListener("popstate", syncSelection); }, [projectId]); - const selectArtifact = (artifactId: string | null) => { + const selectArtifact = useCallback((artifactId: string | null) => { const url = new URL(window.location.href); if (artifactId) url.searchParams.set("artifact", artifactId); else url.searchParams.delete("artifact"); window.history.pushState({}, "", url); setSelectedArtifactId(artifactId); - }; + }, []); + const closeArtifact = useCallback(() => { + const trigger = selectedArtifactId + ? artifactButtonsRef.current.get(selectedArtifactId) + : undefined; + selectArtifact(null); + window.requestAnimationFrame(() => trigger?.focus()); + }, [selectArtifact, selectedArtifactId]); const accessStats = useMemo(() => { const targets = requestDecisions.filter((decision) => decision.kind === "target").length; const commands = requestDecisions.filter((decision) => decision.kind === "command").length; @@ -727,6 +735,10 @@ export function ProjectThreadPanel({ return (