From 3352564287db0074e1ca2b99651527a5ee358ce7 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Wed, 26 Aug 2026 21:47:50 -0400 Subject: [PATCH 1/5] Expose canonical target inventory --- .../api/projects/[projectId]/targets/route.ts | 25 +++ src/server/chat/research-cockpit.ts | 14 ++ src/server/targets/inventory.ts | 198 ++++++++++++++++++ .../integration/target-inventory-api.test.ts | 162 ++++++++++++++ 4 files changed, 399 insertions(+) create mode 100644 src/app/api/projects/[projectId]/targets/route.ts create mode 100644 src/server/targets/inventory.ts create mode 100644 tests/integration/target-inventory-api.test.ts diff --git a/src/app/api/projects/[projectId]/targets/route.ts b/src/app/api/projects/[projectId]/targets/route.ts new file mode 100644 index 000000000..cb5194f60 --- /dev/null +++ b/src/app/api/projects/[projectId]/targets/route.ts @@ -0,0 +1,25 @@ +import { getProjectOverview } from "../../../../../server/chat/service"; +import { listProjectTargetInventory } from "../../../../../server/targets/inventory"; +import { handleApiError, notFound, ok } from "../../../_shared/http"; + +export const dynamic = "force-dynamic"; + +type Context = { + params: Promise<{ projectId: string }>; +}; + +export async function GET(_request: Request, context: Context) { + try { + const { projectId } = await context.params; + const project = await getProjectOverview(projectId); + if (!project) { + return notFound(`Project ${projectId} was not found.`); + } + return ok( + { targets: await listProjectTargetInventory(projectId) }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch (error) { + return handleApiError(error); + } +} diff --git a/src/server/chat/research-cockpit.ts b/src/server/chat/research-cockpit.ts index 1bc657bb5..b8f0e9cda 100644 --- a/src/server/chat/research-cockpit.ts +++ b/src/server/chat/research-cockpit.ts @@ -19,6 +19,10 @@ import type { ToolRunStatus, } from "../db/types"; import type { SchedulerStageId, SchedulerTaskStatus } from "../scheduler"; +import { + listProjectTargetInventoryRows, + type TargetInventoryRecord, +} from "../targets/inventory"; import type { ValidationConfidence, ValidationPlanStatus } from "../validation-plans"; export type ResearchCockpitCountSummary = { @@ -195,6 +199,7 @@ export type ProjectResearchCockpit = { workspaces: ResearchCockpitWorkspaceSummary; blockers: ResearchCockpitBlockerSummary; }; + targets: TargetInventoryRecord[]; recentFindings: ResearchCockpitFinding[]; attackPaths: ResearchCockpitAttackPath[]; activeBlockers: ResearchCockpitBlocker[]; @@ -383,6 +388,7 @@ async function buildProjectResearchCockpitWithDb( attackPaths, pendingApprovals, recentToolFailures, + targets, ] = await Promise.all([ readCoreCounts(db, scope), readValidationPlanCounts(db, scope, databaseConfig), @@ -403,6 +409,7 @@ async function buildProjectResearchCockpitWithDb( readAttackPaths(db, projectId, options.threadId), readPendingApprovals(db, scope), readRecentToolFailures(db, scope), + listProjectTargetInventoryRows(db, projectId), ]); const { tasks: taskCounts, @@ -431,6 +438,7 @@ async function buildProjectResearchCockpitWithDb( workspaces: workspaceSummary, blockers: summarizeActiveBlockers(activeBlockers), }, + targets: filterCockpitTargets(targets, options.threadId), recentFindings, attackPaths, activeBlockers, @@ -463,6 +471,12 @@ async function buildProjectResearchCockpitWithDb( return cockpit; } +function filterCockpitTargets(targets: TargetInventoryRecord[], threadId?: string) { + return threadId + ? targets.filter((target) => !target.threadId || target.threadId === threadId) + : targets; +} + async function readActiveBlockers( projectId: string, threadId: string | undefined, diff --git a/src/server/targets/inventory.ts b/src/server/targets/inventory.ts new file mode 100644 index 000000000..7f2e6ca6c --- /dev/null +++ b/src/server/targets/inventory.ts @@ -0,0 +1,198 @@ +import type { Queryable } from "../db/client"; +import { withDatabase } from "../db/client"; +import { + type AuthorizationRecord, + listProjectAuthorizationRows, + listProjectTargetRows, + type TargetKind, + type TargetRelationshipInput, +} from "."; + +export type TargetInventoryAuthorizationState = + | "authorized" + | "pending" + | "denied" + | "expired" + | "revoked" + | "consumed" + | "unrecorded"; + +export type TargetInventoryAuthorization = { + state: TargetInventoryAuthorizationState; + authorizationId?: string; + status?: AuthorizationRecord["status"]; + networkProfile?: string; + expiresAt?: string; + updatedAt?: string; +}; + +export type TargetInventoryRecord = { + id: string; + projectId: string; + threadId?: string; + kind: TargetKind; + label: string; + locator: string; + scope: Record; + relationships: TargetRelationshipInput[]; + authorization: TargetInventoryAuthorization; + createdAt: string; + updatedAt: string; +}; + +export type TargetInventoryOptions = { + now?: Date; +}; + +export async function listProjectTargetInventory( + projectId: string, + options: TargetInventoryOptions = {}, +): Promise { + return withDatabase((db) => + listProjectTargetInventoryRows(db, projectId, options), + ); +} + +export async function listProjectTargetInventoryRows( + db: Queryable, + projectId: string, + options: TargetInventoryOptions = {}, +): Promise { + const [targets, authorizations] = await Promise.all([ + listProjectTargetRows(db, projectId), + listProjectAuthorizationRows(db, projectId), + ]); + const now = options.now?.getTime() ?? Date.now(); + const authorizationsByTarget = new Map(); + for (const authorization of authorizations) { + if (!authorization.targetId) continue; + const records = authorizationsByTarget.get(authorization.targetId) ?? []; + records.push(authorization); + authorizationsByTarget.set(authorization.targetId, records); + } + + return targets.map((target) => ({ + id: target.id, + projectId: target.projectId, + ...(target.threadId ? { threadId: target.threadId } : {}), + kind: target.kind, + label: target.label, + locator: target.locator, + scope: { ...target.scope }, + relationships: readRelationships(target.metadata.relationships), + authorization: resolveTargetInventoryAuthorization( + authorizationsByTarget.get(target.id) ?? [], + now, + ), + createdAt: target.createdAt, + updatedAt: target.updatedAt, + })); +} + +export function resolveTargetInventoryAuthorization( + records: AuthorizationRecord[], + now: number = Date.now(), +): TargetInventoryAuthorization { + const ordered = [...records].sort((left, right) => + right.createdAt.localeCompare(left.createdAt), + ); + const activeDecision = ordered.find((record) => + isActiveAuthorizationDecision(record, now), + ); + const current = activeDecision ?? ordered[0]; + if (!current) return { state: "unrecorded" }; + + return { + state: + activeDecision?.status === "approved" + ? "authorized" + : activeDecision?.status === "denied" + ? "denied" + : inactiveAuthorizationState(current, now), + authorizationId: current.id, + status: current.status, + ...(current.networkProfile + ? { networkProfile: current.networkProfile } + : {}), + ...(current.expiresAt ? { expiresAt: current.expiresAt } : {}), + updatedAt: current.updatedAt, + }; +} + +function isActiveAuthorizationDecision( + record: AuthorizationRecord, + now: number, +) { + return ( + (record.status === "approved" || record.status === "denied") && + !record.revokedAt && + (!record.singleUse || !record.consumedAt) && + (!record.expiresAt || Date.parse(record.expiresAt) > now) + ); +} + +function inactiveAuthorizationState( + record: AuthorizationRecord, + now: number, +): Exclude { + if (record.revokedAt || record.status === "revoked") return "revoked"; + if (record.consumedAt) return "consumed"; + if ( + record.status === "expired" || + (record.expiresAt && Date.parse(record.expiresAt) <= now) + ) { + return "expired"; + } + if (record.status === "denied") return "denied"; + return "pending"; +} + +function readRelationships(value: unknown): TargetRelationshipInput[] { + if (!Array.isArray(value)) return []; + return value.flatMap((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return []; + const relationship = item as Record; + if ( + typeof relationship.fromTargetId !== "string" || + typeof relationship.toTargetId !== "string" || + !isRelationshipKind(relationship.kind) + ) { + return []; + } + return [ + { + fromTargetId: relationship.fromTargetId, + toTargetId: relationship.toTargetId, + kind: relationship.kind, + evidence: Array.isArray(relationship.evidence) + ? relationship.evidence.filter( + (entry): entry is string => typeof entry === "string", + ) + : [], + metadata: + relationship.metadata && + typeof relationship.metadata === "object" && + !Array.isArray(relationship.metadata) + ? (relationship.metadata as Record) + : {}, + }, + ]; + }); +} + +function isRelationshipKind( + value: unknown, +): value is TargetRelationshipInput["kind"] { + return ( + typeof value === "string" && + [ + "deploys_to", + "depends_on", + "exposes", + "implements", + "hosts", + "related_to", + "tests", + ].includes(value) + ); +} diff --git a/tests/integration/target-inventory-api.test.ts b/tests/integration/target-inventory-api.test.ts new file mode 100644 index 000000000..7c39b1b41 --- /dev/null +++ b/tests/integration/target-inventory-api.test.ts @@ -0,0 +1,162 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { GET as readCockpit } from "../../src/app/api/projects/[projectId]/cockpit/route"; +import { GET as readTargets } from "../../src/app/api/projects/[projectId]/targets/route"; +import { getProjectStore } from "../../src/server/chat/projectAdapter"; +import { withDatabase } from "../../src/server/db/client"; +import { + createTargetAuthorization, + upsertProjectTarget, +} from "../../src/server/targets"; + +describe("canonical target inventory API", () => { + let databaseRoot: string; + let previousDatabaseUrl: string | undefined; + + beforeEach(async () => { + previousDatabaseUrl = process.env.EH_APP_DB_URL; + databaseRoot = await mkdtemp( + join(tmpdir(), "exploit-hunter-target-inventory-"), + ); + process.env.EH_APP_DB_URL = `sqlite://${join(databaseRoot, "app.sqlite")}`; + }); + + afterEach(async () => { + if (previousDatabaseUrl === undefined) delete process.env.EH_APP_DB_URL; + else process.env.EH_APP_DB_URL = previousDatabaseUrl; + await rm(databaseRoot, { recursive: true, force: true }); + }); + + it("reconstructs the same durable target records in the inventory and cockpit after reload", async () => { + const store = await getProjectStore(); + const project = await store.createProject({ name: "Target inventory" }); + const thread = await store.createThread(project.id, { + title: "Passive map", + }); + await upsertProjectTarget(project.id, { + id: "repo-target", + threadId: thread.id, + kind: "repo", + label: "Service repository", + locator: "https://github.com/example/service.git", + scope: { boundary: "source-only" }, + }); + await upsertProjectTarget(project.id, { + id: "web-target", + threadId: thread.id, + kind: "web", + label: "Service staging", + locator: "https://staging.example.test", + scope: { boundary: "passive" }, + }); + await withDatabase((db) => + db.query( + `INSERT INTO target_relationships + (project_id, from_target_id, to_target_id, kind, evidence, metadata) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)`, + [ + project.id, + "repo-target", + "web-target", + "deploys_to", + JSON.stringify(["artifact://deployment-map"]), + JSON.stringify({}), + ], + ), + ); + const authorization = await createTargetAuthorization(project.id, { + targetId: "web-target", + status: "approved", + grantedBy: "operator", + networkProfile: "approved-targets", + scope: { activity: "passive" }, + }); + const deniedAuthorization = await createTargetAuthorization(project.id, { + targetId: "repo-target", + status: "denied", + grantedBy: "operator", + scope: { reason: "Source review is out of scope" }, + }); + + const context = { params: Promise.resolve({ projectId: project.id }) }; + const inventory = await responseBody<{ + targets: Array>; + }>( + await readTargets( + new Request(`http://localhost:3210/api/projects/${project.id}/targets`), + context, + ), + ); + const firstCockpit = await responseBody<{ + cockpit: { targets: Array> }; + }>( + await readCockpit( + new Request(`http://localhost:3210/api/projects/${project.id}/cockpit`), + context, + ), + ); + const reloadedCockpit = await responseBody<{ + cockpit: { targets: Array> }; + }>( + await readCockpit( + new Request(`http://localhost:3210/api/projects/${project.id}/cockpit`), + context, + ), + ); + + expect(firstCockpit.cockpit.targets).toEqual(inventory.targets); + expect(reloadedCockpit.cockpit.targets).toEqual(inventory.targets); + expect(inventory.targets).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "repo-target", + projectId: project.id, + threadId: thread.id, + kind: "repo", + locator: "https://github.com/example/service.git", + scope: { boundary: "source-only" }, + relationships: [ + expect.objectContaining({ + fromTargetId: "repo-target", + toTargetId: "web-target", + kind: "deploys_to", + }), + ], + authorization: expect.objectContaining({ + state: "denied", + authorizationId: deniedAuthorization.id, + status: "denied", + }), + }), + expect.objectContaining({ + id: "web-target", + kind: "web", + locator: "https://staging.example.test", + scope: { boundary: "passive" }, + relationships: [ + expect.objectContaining({ + fromTargetId: "repo-target", + toTargetId: "web-target", + kind: "deploys_to", + }), + ], + authorization: expect.objectContaining({ + state: "authorized", + authorizationId: authorization.id, + status: "approved", + networkProfile: "approved-targets", + }), + }), + ]), + ); + }); +}); + +async function responseBody(response: Response): Promise { + expect(response.status).toBe(200); + return (await response.json()) as T; +} From eb9240d115fdc442b66e460095638e31f3ed68b8 Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 01:16:47 -0400 Subject: [PATCH 2/5] Render canonical target inventory --- src/components/chat/WorkspaceDashboard.tsx | 107 ++++++++++++++++ src/components/chat/messageUtils.ts | 33 +++++ src/styles/chat.css | 119 ++++++++++++++++++ .../integration/target-inventory-api.test.ts | 66 ++++++++++ 4 files changed, 325 insertions(+) diff --git a/src/components/chat/WorkspaceDashboard.tsx b/src/components/chat/WorkspaceDashboard.tsx index e270612a3..806122e80 100644 --- a/src/components/chat/WorkspaceDashboard.tsx +++ b/src/components/chat/WorkspaceDashboard.tsx @@ -11,6 +11,7 @@ import { LoaderCircle, ShieldAlert, ShieldCheck, + Target, Terminal, } from "lucide-react"; import type { SecurityResearchSurfaceAction } from "../../lib/render-json"; @@ -38,6 +39,37 @@ import { } from "./messageUtils"; type ProjectStatsView = ChatProjectStatsView; +type CockpitTarget = ResearchCockpitView["targets"][number]; + +const SENSITIVE_SCOPE_KEY = /authorization|cookie|credential|header|key|password|secret|token/i; + +function boundedTargetText(value: string, maxLength = 96) { + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; +} + +function targetAuthorizationLabel(state: CockpitTarget["authorization"]["state"]) { + return state === "unrecorded" ? "Not authorized" : state; +} + +function targetScopeSummary(scope: Record) { + return Object.entries(scope) + .filter(([key, value]) => !SENSITIVE_SCOPE_KEY.test(key) && isTargetScopeScalar(value)) + .slice(0, 3) + .map(([key, value]) => `${key}: ${boundedTargetText(String(value), 48)}`); +} + +function isTargetScopeScalar(value: unknown): value is string | number | boolean { + return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; +} + +function targetRelationshipLabel( + target: CockpitTarget, + relationship: CockpitTarget["relationships"][number], +) { + const relatedTargetId = + relationship.fromTargetId === target.id ? relationship.toTargetId : relationship.fromTargetId; + return `${relationship.kind.replaceAll("_", " ")} · ${relatedTargetId}`; +} function getPlanIcon(status: DashboardPlanItem["status"]) { switch (status) { @@ -178,6 +210,7 @@ export function WorkspaceDashboard({ targetAuthorizationDecisions, }); const attackPaths = cockpit?.attackPaths ?? []; + const targets = cockpit?.targets ?? []; const completeSignals = planItems.filter((item) => item.status === "completed").length + evidenceItems.filter((item) => item.done).length; @@ -195,6 +228,7 @@ export function WorkspaceDashboard({ planItems.length > 0 || evidenceItems.length > 0 || attackPaths.length > 0 || + targets.length > 0 || Boolean(latestFinding) || Boolean(latestApproval); const shouldUseCompactDashboard = !hasDashboardContent; @@ -308,6 +342,79 @@ export function WorkspaceDashboard({
+ {targets.length > 0 ? ( +
+
+
+ + + Target inventory + +

+ Target inventory +

+
+ + {targets.length} target{targets.length === 1 ? "" : "s"} + +
+
    + {targets.map((target) => { + const scopeSummary = targetScopeSummary(target.scope); + return ( +
  • +
    +
    + {boundedTargetText(target.label)} + + {boundedTargetText(target.locator, 120)} + +
    + + {targetAuthorizationLabel(target.authorization.state)} + +
    +
    + {target.kind} + ID {boundedTargetText(target.id, 42)} + {target.authorization.networkProfile ? ( + {target.authorization.networkProfile} + ) : null} +
    + {scopeSummary.length > 0 ? ( +

    + {scopeSummary.map((entry) => ( + {entry} + ))} +

    + ) : null} + {target.relationships.length > 0 ? ( +
      + {target.relationships.map((relationship) => { + const label = targetRelationshipLabel(target, relationship); + return ( +
    • + {boundedTargetText(label, 72)} +
    • + ); + })} +
    + ) : null} +
  • + ); + })} +
+
+ ) : null} + {planItems.length > 0 ? (
; + relationships: Array<{ + fromTargetId: string; + toTargetId: string; + kind: string; + evidence: string[]; + metadata: Record; + }>; + authorization: { + state: + | "authorized" + | "pending" + | "denied" + | "expired" + | "revoked" + | "consumed" + | "unrecorded"; + authorizationId?: string; + status?: string; + networkProfile?: string; + expiresAt?: string; + updatedAt?: string; + }; + createdAt: string; + updatedAt: string; + }>; signals: Array<{ key: string; status: "clear" | "info" | "warning" | "blocked"; diff --git a/src/styles/chat.css b/src/styles/chat.css index 84f8e7f14..ea9345f9b 100644 --- a/src/styles/chat.css +++ b/src/styles/chat.css @@ -2939,6 +2939,125 @@ line-height: 1.45; } +.dashboard-target-list { + display: grid; + gap: 0.7rem; + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; + min-height: 0; + max-height: 40vh; + overscroll-behavior: contain; +} + +.dashboard-target-list > li { + display: grid; + gap: 0.52rem; + min-width: 0; + border: 1px solid rgb(255 255 255 / 8%); + border-radius: 0.42rem; + background: rgb(255 255 255 / 3%); + padding: 0.68rem; +} + +.dashboard-target-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.65rem; + min-width: 0; +} + +.dashboard-target-heading strong, +.dashboard-target-heading small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashboard-target-heading strong { + color: var(--text); + font-size: 0.84rem; +} + +.dashboard-target-heading small { + margin-top: 0.12rem; + color: var(--text-soft); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.68rem; +} + +.dashboard-target-authorization { + display: inline-flex; + align-items: center; + min-height: 1.45rem; + border: 1px solid rgb(244 199 106 / 34%); + border-radius: 0.375rem; + background: rgb(244 199 106 / 10%); + color: var(--warning); + padding: 0 0.45rem; + font-size: 0.68rem; + font-weight: 780; + text-transform: capitalize; + white-space: nowrap; +} + +.dashboard-target-authorization.is-authorized { + border-color: rgb(102 216 181 / 32%); + background: rgb(102 216 181 / 11%); + color: var(--accent-strong); +} + +.dashboard-target-authorization.is-denied, +.dashboard-target-authorization.is-revoked, +.dashboard-target-authorization.is-expired { + border-color: rgb(248 113 113 / 38%); + background: rgb(248 113 113 / 10%); + color: var(--danger); +} + +.dashboard-target-facts, +.dashboard-target-scope, +.dashboard-target-relationships { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin: 0; + padding: 0; + list-style: none; +} + +.dashboard-target-facts span, +.dashboard-target-scope span, +.dashboard-target-relationships li { + overflow: hidden; + max-width: 100%; + border-radius: 0.3rem; + background: rgb(255 255 255 / 5%); + color: var(--text-soft); + padding: 0.2rem 0.38rem; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.67rem; + line-height: 1.25; +} + +.dashboard-target-facts span:first-child { + color: var(--accent-strong); + text-transform: capitalize; +} + +.dashboard-target-scope span { + background: rgb(102 216 181 / 7%); +} + +.dashboard-target-relationships li { + background: rgb(120 162 255 / 8%); + color: #b8ccff; +} + .dashboard-attack-path-list { display: grid; gap: 0.62rem; diff --git a/tests/integration/target-inventory-api.test.ts b/tests/integration/target-inventory-api.test.ts index 7c39b1b41..af8337f12 100644 --- a/tests/integration/target-inventory-api.test.ts +++ b/tests/integration/target-inventory-api.test.ts @@ -37,6 +37,16 @@ describe("canonical target inventory API", () => { const thread = await store.createThread(project.id, { title: "Passive map", }); + const otherThread = await store.createThread(project.id, { + title: "Unrelated research", + }); + await upsertProjectTarget(project.id, { + id: "project-target", + kind: "host", + label: "Project-wide lab host", + locator: "lab.internal.example.test", + scope: { boundary: "project" }, + }); await upsertProjectTarget(project.id, { id: "repo-target", threadId: thread.id, @@ -53,6 +63,22 @@ describe("canonical target inventory API", () => { locator: "https://staging.example.test", scope: { boundary: "passive" }, }); + await upsertProjectTarget(project.id, { + id: "expired-target", + threadId: thread.id, + kind: "api", + label: "Expired API scope", + locator: "https://api.example.test", + scope: { boundary: "passive" }, + }); + await upsertProjectTarget(project.id, { + id: "other-thread-target", + threadId: otherThread.id, + kind: "web", + label: "Other thread", + locator: "https://other.example.test", + scope: { boundary: "passive" }, + }); await withDatabase((db) => db.query( `INSERT INTO target_relationships @@ -81,6 +107,13 @@ describe("canonical target inventory API", () => { grantedBy: "operator", scope: { reason: "Source review is out of scope" }, }); + const expiredAuthorization = await createTargetAuthorization(project.id, { + targetId: "expired-target", + status: "approved", + grantedBy: "operator", + expiresAt: "2020-01-01T00:00:00.000Z", + scope: { activity: "passive" }, + }); const context = { params: Promise.resolve({ projectId: project.id }) }; const inventory = await responseBody<{ @@ -107,11 +140,36 @@ describe("canonical target inventory API", () => { context, ), ); + const threadCockpit = await responseBody<{ + cockpit: { targets: Array> }; + }>( + await readCockpit( + new Request( + `http://localhost:3210/api/projects/${project.id}/cockpit?threadId=${thread.id}`, + ), + context, + ), + ); expect(firstCockpit.cockpit.targets).toEqual(inventory.targets); expect(reloadedCockpit.cockpit.targets).toEqual(inventory.targets); + expect(threadCockpit.cockpit.targets.map((target) => target.id)).toEqual( + expect.arrayContaining([ + "project-target", + "repo-target", + "web-target", + "expired-target", + ]), + ); + expect( + threadCockpit.cockpit.targets.map((target) => target.id), + ).not.toContain("other-thread-target"); expect(inventory.targets).toEqual( expect.arrayContaining([ + expect.objectContaining({ + id: "project-target", + authorization: { state: "unrecorded" }, + }), expect.objectContaining({ id: "repo-target", projectId: project.id, @@ -151,6 +209,14 @@ describe("canonical target inventory API", () => { networkProfile: "approved-targets", }), }), + expect.objectContaining({ + id: "expired-target", + authorization: expect.objectContaining({ + state: "expired", + authorizationId: expiredAuthorization.id, + status: "approved", + }), + }), ]), ); }); From c3bfbad1427b221db7749d6fc096e3ed95df11af Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 01:23:38 -0400 Subject: [PATCH 3/5] Add bounded artifact preview API --- .../artifacts/[artifactId]/route.ts | 33 +++ src/server/evidence/artifact-runtime.ts | 3 + src/server/evidence/artifact-service.ts | 209 ++++++++++++++++- src/server/evidence/index.ts | 4 + .../integration/artifact-preview-api.test.ts | 217 ++++++++++++++++++ 5 files changed, 458 insertions(+), 8 deletions(-) create mode 100644 src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts create mode 100644 tests/integration/artifact-preview-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..358c723c0 --- /dev/null +++ b/src/app/api/projects/[projectId]/artifacts/[artifactId]/route.ts @@ -0,0 +1,33 @@ +import { getArtifactService } from "../../../../../../server/evidence"; +import { handleApiError, notFound, ok } from "../../../../_shared/http"; + +export const dynamic = "force-dynamic"; + +type Params = { + params: Promise<{ projectId: string; artifactId: string }>; +}; + +export async function GET(request: Request, context: Params) { + try { + const { projectId, artifactId } = await context.params; + const service = getArtifactService(); + if (!service.readArtifactDetail) { + throw new Error("Artifact detail reads are unavailable."); + } + + const previewBytes = new URL(request.url).searchParams.get("previewBytes"); + const artifact = await service.readArtifactDetail({ + projectId, + artifactId, + ...(previewBytes ? { previewBytes: Number(previewBytes) } : {}), + }); + if (!artifact) return notFound("Artifact not found."); + + return ok( + { artifact }, + { headers: { "cache-control": "private, no-store" } }, + ); + } catch (error) { + return handleApiError(error, { request, route: "artifact-detail" }); + } +} diff --git a/src/server/evidence/artifact-runtime.ts b/src/server/evidence/artifact-runtime.ts index fab290807..7195ca866 100644 --- a/src/server/evidence/artifact-runtime.ts +++ b/src/server/evidence/artifact-runtime.ts @@ -5,6 +5,8 @@ import { type CreateFindingResult, type CreateUploadedObjectArtifactInput, createArtifactService, + type ReadArtifactDetailInput, + type ReadArtifactDetailResult, type ReadArtifactTextInput, type ReadArtifactTextResult, } from "./artifact-service"; @@ -15,6 +17,7 @@ export type ArtifactServiceInstance = { input: CreateUploadedObjectArtifactInput, ): Promise; readArtifactText?(input: ReadArtifactTextInput): Promise; + readArtifactDetail?(input: ReadArtifactDetailInput): Promise; createFinding(input: CreateFindingInput): Promise; }; diff --git a/src/server/evidence/artifact-service.ts b/src/server/evidence/artifact-service.ts index e3559fc16..7d4d67fe4 100644 --- a/src/server/evidence/artifact-service.ts +++ b/src/server/evidence/artifact-service.ts @@ -17,6 +17,8 @@ const DEFAULT_MAX_INLINE_BYTES = Number.parseInt( process.env.ARTIFACT_INLINE_MAX_BYTES ?? "1500000", 10, ); +const DEFAULT_ARTIFACT_PREVIEW_BYTES = 64 * 1024; +const MAX_ARTIFACT_PREVIEW_BYTES = 256 * 1024; export type ArtifactServiceConfig = { storage?: ObjectStorageClient | null; @@ -97,6 +99,51 @@ export type ReadArtifactTextResult = { truncated: boolean; }; +export type ReadArtifactDetailInput = { + projectId: string; + artifactId: string; + previewBytes?: number; +}; + +export type ReadArtifactDetailResult = { + id: string; + projectId: string; + threadId: string | null; + taskId: string | null; + findingId: string | null; + toolRunId: string | null; + name: string; + kind: string; + contentType: string | null; + sizeBytes: number | null; + sha256: string | null; + agentGenerated: boolean; + storageMode: "inline" | "object-storage" | "unavailable"; + indexing: { + status: "indexed" | "not-indexed"; + chunkCount: number; + }; + preview: + | { + available: true; + text: string; + truncated: boolean; + limitBytes: number; + } + | { + available: false; + truncated: false; + limitBytes: number; + reason: "binary" | "missing-content" | "storage-unavailable"; + }; + actions: { + raw: { available: false; requiresExplicitAction: true }; + download: { available: false; requiresExplicitAction: true }; + }; + createdAt: string; + updatedAt: string; +}; + export type CreateFindingInput = { projectId: string; threadId?: string; @@ -311,10 +358,7 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) { const maxBytes = normalizeReadLimit(input.maxBytes); if (row.inline_content !== null) { - const redacted = redactEvidenceSecrets(row.inline_content); - const bytes = new TextEncoder().encode(redacted); - const truncated = bytes.byteLength > maxBytes; - const text = truncated ? new TextDecoder().decode(bytes.slice(0, maxBytes)) : redacted; + const { text, truncated } = redactAndTruncateText(row.inline_content, maxBytes); return { artifactId: row.id, projectId: row.project_id, @@ -346,10 +390,7 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) { key: row.storage_key, ...(row.storage_bucket ? { bucket: row.storage_bucket } : {}), }); - const truncated = bytes.byteLength > maxBytes; - const text = redactEvidenceSecrets( - new TextDecoder().decode(truncated ? bytes.slice(0, maxBytes) : bytes), - ); + const { text, truncated } = redactAndTruncateText(new TextDecoder().decode(bytes), maxBytes); return { artifactId: row.id, projectId: row.project_id, @@ -363,6 +404,85 @@ export function createArtifactService(config: ArtifactServiceConfig = {}) { }; }, + async readArtifactDetail( + input: ReadArtifactDetailInput, + ): Promise { + const row = await withDatabase(async (db: Queryable) => { + const result = await db.query<{ + id: string; + project_id: string; + thread_id: string | null; + task_id: string | null; + finding_id: string | null; + tool_run_id: string | null; + name: string; + kind: string; + content_type: string | null; + storage_bucket: string | null; + storage_key: string | null; + size_bytes: number | null; + sha256: string | null; + inline_content: string | null; + agent_generated: boolean; + indexing_chunk_count: number | string; + created_at: string; + updated_at: string; + }>( + `SELECT artifacts.id, artifacts.project_id, artifacts.thread_id, artifacts.task_id, + artifacts.finding_id, artifacts.tool_run_id, artifacts.name, artifacts.kind, + artifacts.content_type, artifacts.storage_bucket, artifacts.storage_key, + artifacts.size_bytes, artifacts.sha256, artifacts.inline_content, + artifacts.agent_generated, artifacts.created_at, artifacts.updated_at, + (SELECT COUNT(*) FROM evidence_search_chunks + WHERE evidence_search_chunks.artifact_id = artifacts.id) AS indexing_chunk_count + FROM artifacts + WHERE artifacts.id = $1 AND artifacts.project_id = $2`, + [input.artifactId, input.projectId], + ); + return result.rows[0] ?? null; + }); + + if (!row) return null; + + const previewLimit = normalizeArtifactPreviewLimit(input.previewBytes); + const preview = await readArtifactPreview({ + row, + storage, + previewLimit, + }); + const chunkCount = Number(row.indexing_chunk_count) || 0; + return { + id: row.id, + projectId: row.project_id, + threadId: row.thread_id, + taskId: row.task_id, + findingId: row.finding_id, + toolRunId: row.tool_run_id, + name: row.name, + kind: row.kind, + contentType: row.content_type, + sizeBytes: row.size_bytes, + sha256: row.sha256, + agentGenerated: row.agent_generated, + storageMode: row.storage_key + ? "object-storage" + : row.inline_content !== null + ? "inline" + : "unavailable", + indexing: { + status: chunkCount > 0 ? "indexed" : "not-indexed", + chunkCount, + }, + preview, + actions: { + raw: { available: false, requiresExplicitAction: true }, + download: { available: false, requiresExplicitAction: true }, + }, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + }, + async createUploadedObjectArtifact( input: CreateUploadedObjectArtifactInput, ): Promise { @@ -546,6 +666,79 @@ function normalizeReadLimit(value: number | undefined) { return Math.max(1, Math.min(Math.floor(value), 5_000_000)); } +function normalizeArtifactPreviewLimit(value: number | undefined) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_ARTIFACT_PREVIEW_BYTES; + } + return Math.max(1, Math.min(Math.floor(value), MAX_ARTIFACT_PREVIEW_BYTES)); +} + +function redactAndTruncateText(value: string, maxBytes: number) { + const redacted = redactEvidenceSecrets(value); + const bytes = new TextEncoder().encode(redacted); + const truncated = bytes.byteLength > maxBytes; + return { + text: truncated ? new TextDecoder().decode(bytes.slice(0, maxBytes)) : redacted, + truncated, + }; +} + +async function readArtifactPreview(input: { + row: { + name: string; + content_type: string | null; + inline_content: string | null; + storage_bucket: string | null; + storage_key: string | null; + }; + storage: ObjectStorageClient | null; + previewLimit: number; +}): Promise { + const { row, storage, previewLimit } = input; + if (!isLikelyTextArtifact(row.name, row.content_type)) { + return { available: false, truncated: false, limitBytes: previewLimit, reason: "binary" }; + } + + if (row.inline_content !== null) { + const preview = redactAndTruncateText(row.inline_content, previewLimit); + return { available: true, ...preview, limitBytes: previewLimit }; + } + + if (!row.storage_key) { + return { + available: false, + truncated: false, + limitBytes: previewLimit, + reason: "missing-content", + }; + } + + if (!storage) { + return { + available: false, + truncated: false, + limitBytes: previewLimit, + reason: "storage-unavailable", + }; + } + + try { + const bytes = await storage.readObjectBytes({ + key: row.storage_key, + ...(row.storage_bucket ? { bucket: row.storage_bucket } : {}), + }); + const preview = redactAndTruncateText(new TextDecoder().decode(bytes), previewLimit); + return { available: true, ...preview, limitBytes: previewLimit }; + } catch { + return { + available: false, + truncated: false, + limitBytes: previewLimit, + reason: "storage-unavailable", + }; + } +} + function isLikelyTextArtifact(name: string, contentType: string | null) { const normalizedContentType = contentType?.toLowerCase().split(";")[0]?.trim(); if ( diff --git a/src/server/evidence/index.ts b/src/server/evidence/index.ts index e3c526c98..cd6a9613c 100644 --- a/src/server/evidence/index.ts +++ b/src/server/evidence/index.ts @@ -11,6 +11,10 @@ export { type CreateFindingResult, type CreateUploadedObjectArtifactInput, createArtifactService, + type ReadArtifactDetailInput, + type ReadArtifactDetailResult, + type ReadArtifactTextInput, + type ReadArtifactTextResult, } from "./artifact-service"; export { type ChainApprovalRecord, diff --git a/tests/integration/artifact-preview-api.test.ts b/tests/integration/artifact-preview-api.test.ts new file mode 100644 index 000000000..2942a7585 --- /dev/null +++ b/tests/integration/artifact-preview-api.test.ts @@ -0,0 +1,217 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { GET as readArtifact } from "../../src/app/api/projects/[projectId]/artifacts/[artifactId]/route"; +import { getProjectStore } from "../../src/server/chat/projectAdapter"; +import { withDatabase } from "../../src/server/db/client"; +import { + createArtifactService, + setArtifactService, +} from "../../src/server/evidence"; +import type { ObjectStorageClient } from "../../src/server/storage"; + +describe("artifact detail API", () => { + let databaseRoot: string; + let previousDatabaseUrl: string | undefined; + + beforeEach(async () => { + previousDatabaseUrl = process.env.EH_APP_DB_URL; + databaseRoot = await mkdtemp( + join(tmpdir(), "exploit-hunter-artifact-preview-"), + ); + process.env.EH_APP_DB_URL = `sqlite://${join(databaseRoot, "app.sqlite")}`; + }); + + afterEach(async () => { + setArtifactService(undefined); + if (previousDatabaseUrl === undefined) delete process.env.EH_APP_DB_URL; + else process.env.EH_APP_DB_URL = previousDatabaseUrl; + await rm(databaseRoot, { recursive: true, force: true }); + }); + + it("returns durable custody metadata and a bounded redacted preview scoped to the project", async () => { + const objects = new Map(); + const storage = createMemoryStorage(objects); + const service = createArtifactService({ storage }); + setArtifactService(service); + + const store = await getProjectStore(); + const project = await store.createProject({ name: "Artifact preview" }); + const otherProject = await store.createProject({ name: "Other project" }); + const thread = await store.createThread(project.id, { + title: "Evidence review", + }); + const rawText = `${"A".repeat(32)}\nAuthorization: Bearer sk-testabcdefghijklmnopqrstuvwxyz1234567890\nend`; + const bytes = new TextEncoder().encode(rawText); + objects.set("evidence/secret.log", bytes); + + const created = await service.createUploadedObjectArtifact({ + projectId: project.id, + threadId: thread.id, + projectScoped: true, + name: "secret.log", + kind: "log", + contentType: "text/plain", + storageBucket: "artifacts", + storageKey: "evidence/secret.log", + sizeBytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + source: "terminal-note", + indexForRag: false, + }); + await withDatabase((db) => + db.query( + `INSERT INTO evidence_search_chunks + (id, project_id, thread_id, artifact_id, text, metadata, updated_at) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7)`, + [ + "chunk-artifact-preview", + project.id, + thread.id, + created.id, + "redacted evidence", + JSON.stringify({}), + "2026-08-27T00:00:00.000Z", + ], + ), + ); + + const response = await readArtifact( + new Request( + `http://localhost:3210/api/projects/${project.id}/artifacts/${created.id}?previewBytes=64`, + ), + { + params: Promise.resolve({ + projectId: project.id, + artifactId: created.id, + }), + }, + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(body.artifact).toMatchObject({ + id: created.id, + projectId: project.id, + threadId: thread.id, + storageMode: "object-storage", + sizeBytes: bytes.byteLength, + sha256: createHash("sha256").update(bytes).digest("hex"), + indexing: { status: "indexed", chunkCount: 1 }, + preview: { available: true, limitBytes: 64 }, + actions: { + raw: { available: false, requiresExplicitAction: true }, + download: { available: false, requiresExplicitAction: true }, + }, + }); + expect(body.artifact.preview.text).toContain("Authorization: [redacted]"); + expect(body.artifact.preview.text).not.toContain("sk-test"); + expect( + new TextEncoder().encode(body.artifact.preview.text).byteLength, + ).toBeLessThanOrEqual(64); + + const reloaded = await readArtifact( + new Request( + `http://localhost:3210/api/projects/${project.id}/artifacts/${created.id}?previewBytes=64`, + ), + { + params: Promise.resolve({ + projectId: project.id, + artifactId: created.id, + }), + }, + ); + expect(await reloaded.json()).toEqual(body); + + const crossProject = await readArtifact( + new Request( + `http://localhost:3210/api/projects/${otherProject.id}/artifacts/${created.id}`, + ), + { + params: Promise.resolve({ + projectId: otherProject.id, + artifactId: created.id, + }), + }, + ); + expect(crossProject.status).toBe(404); + }); + + it("describes binary evidence without attempting to render it", async () => { + const objects = new Map(); + objects.set("evidence/capture.bin", new Uint8Array([0, 1, 2, 3])); + const service = createArtifactService({ + storage: createMemoryStorage(objects), + }); + setArtifactService(service); + const store = await getProjectStore(); + const project = await store.createProject({ name: "Binary evidence" }); + const created = await service.createUploadedObjectArtifact({ + projectId: project.id, + projectScoped: true, + name: "capture.bin", + kind: "file", + contentType: "application/octet-stream", + storageBucket: "artifacts", + storageKey: "evidence/capture.bin", + sizeBytes: 4, + source: "upload", + indexForRag: false, + }); + + const response = await readArtifact( + new Request( + `http://localhost:3210/api/projects/${project.id}/artifacts/${created.id}`, + ), + { + params: Promise.resolve({ + projectId: project.id, + artifactId: created.id, + }), + }, + ); + + expect(response.status).toBe(200); + expect((await response.json()).artifact.preview).toEqual({ + available: false, + truncated: false, + limitBytes: 64 * 1024, + reason: "binary", + }); + }); +}); + +function createMemoryStorage( + objects: Map, +): ObjectStorageClient { + return { + bucket: "artifacts", + async ensureBucket() {}, + async putObject(input) { + if (typeof input.body === "string") + objects.set(input.key, new TextEncoder().encode(input.body)); + else if (input.body instanceof Uint8Array) + objects.set(input.key, input.body); + else throw new Error("Streams are not supported by this test storage."); + }, + async getObject() { + throw new Error("Not implemented by this test storage."); + }, + async readObjectBytes(input) { + const bytes = objects.get(input.key); + if (!bytes) throw new Error("Object not found."); + return bytes; + }, + async createPresignedPutObjectUrl() { + throw new Error("Not implemented by this test storage."); + }, + async deleteObject(input) { + objects.delete(input.key); + }, + }; +} From 565a3b2cb7aedfdc5f386da2db6a5f9f22fd8b2b Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 02:19:20 -0400 Subject: [PATCH 4/5] Render canonical cockpit queues --- src/components/chat/WorkspaceDashboard.tsx | 171 +++++++++++++++++++++ src/components/chat/messageUtils.ts | 60 ++++++++ src/styles/chat.css | 104 +++++++++++++ 3 files changed, 335 insertions(+) diff --git a/src/components/chat/WorkspaceDashboard.tsx b/src/components/chat/WorkspaceDashboard.tsx index 806122e80..b53681ba4 100644 --- a/src/components/chat/WorkspaceDashboard.tsx +++ b/src/components/chat/WorkspaceDashboard.tsx @@ -71,6 +71,22 @@ function targetRelationshipLabel( return `${relationship.kind.replaceAll("_", " ")} · ${relatedTargetId}`; } +function cockpitRecordTime(value: string) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +function cockpitQueueTotal(cockpit: ResearchCockpitView | null) { + if (!cockpit) return 0; + return ( + cockpit.activeBlockers.length + + cockpit.pendingApprovals.length + + cockpit.recentValidationPlans.length + + cockpit.recentToolFailures.length + + cockpit.recentFindings.length + ); +} + function getPlanIcon(status: DashboardPlanItem["status"]) { switch (status) { case "completed": @@ -167,6 +183,157 @@ function getCockpitSignal(cockpit: ResearchCockpitView | null) { return null; } +function CanonicalCockpitQueues({ cockpit }: { cockpit: ResearchCockpitView }) { + const recordCount = cockpitQueueTotal(cockpit); + if (recordCount === 0) return null; + + return ( +
+
+
+ + + Canonical project record + +

+ Canonical project queues +

+
+ + {recordCount} record{recordCount === 1 ? "" : "s"} + +
+ +
+ {cockpit.activeBlockers.length > 0 ? ( +
+

Active blockers

+
    + {cockpit.activeBlockers.map((blocker) => ( +
  • +
    + {blocker.title} + {blocker.severity} +
    +

    {blocker.detail}

    + + {blocker.reason} · {blocker.id} · {cockpitRecordTime(blocker.createdAt)} + + {blocker.nextActions.length > 0 ? ( +
    + {blocker.nextActions.map((action) => ( + {action.label} + ))} +
    + ) : null} +
  • + ))} +
+
+ ) : null} + + {cockpit.pendingApprovals.length > 0 ? ( +
+

Pending approvals

+
    + {cockpit.pendingApprovals.map((approval) => ( +
  • +
    + {approval.reason} + {approval.status} +
    + + {approval.id} · {cockpitRecordTime(approval.updatedAt)} + +
  • + ))} +
+
+ ) : null} + + {cockpit.recentValidationPlans.length > 0 ? ( +
+

Validation plans

+
    + {cockpit.recentValidationPlans.map((plan) => ( +
  • +
    + {plan.title} + {plan.status} +
    +

    + {plan.payloadCount} payload{plan.payloadCount === 1 ? "" : "s"} · {plan.resultCount}{" "} + result{plan.resultCount === 1 ? "" : "s"} · {plan.artifactCount} artifact + {plan.artifactCount === 1 ? "" : "s"} +

    + + {plan.confidence ? `${plan.confidence} confidence · ` : ""} + {plan.id} · {cockpitRecordTime(plan.updatedAt)} + +
  • + ))} +
+
+ ) : null} + + {cockpit.recentFindings.length > 0 ? ( +
+

Recent findings

+
    + {cockpit.recentFindings.map((finding) => ( +
  • +
    + {finding.title} + {finding.status} +
    + {finding.summary ?

    {finding.summary}

    : null} + + {finding.severity} · {finding.id} · {cockpitRecordTime(finding.updatedAt)} + +
  • + ))} +
+
+ ) : null} + + {cockpit.recentToolFailures.length > 0 ? ( +
+

Tool failures

+
    + {cockpit.recentToolFailures.map((failure) => ( +
  • +
    + {failure.toolName} + {failure.status} +
    + + {failure.id} · {cockpitRecordTime(failure.updatedAt)} + + {failure.error ? ( +
    + Raw failure detail +
    +                        {failure.error}
    +                      
    +
    + ) : null} +
  • + ))} +
+
+ ) : null} +
+
+ ); +} + export function WorkspaceDashboard({ stats, messages, @@ -211,6 +378,7 @@ export function WorkspaceDashboard({ }); const attackPaths = cockpit?.attackPaths ?? []; const targets = cockpit?.targets ?? []; + const canonicalRecordCount = cockpitQueueTotal(cockpit); const completeSignals = planItems.filter((item) => item.status === "completed").length + evidenceItems.filter((item) => item.done).length; @@ -229,6 +397,7 @@ export function WorkspaceDashboard({ evidenceItems.length > 0 || attackPaths.length > 0 || targets.length > 0 || + canonicalRecordCount > 0 || Boolean(latestFinding) || Boolean(latestApproval); const shouldUseCompactDashboard = !hasDashboardContent; @@ -543,6 +712,8 @@ export function WorkspaceDashboard({
) : null} + {cockpit ? : null} + {attackPaths.length > 0 ? (
}; attackPaths: { total: number; byStatus: Record }; schedulerTasks: { total: number; byStatus: Record }; + validationPlans: { total: number; byStatus: Record }; codeReviewRuns?: { total: number; byStatus: Record }; artifacts: { total: number; @@ -333,6 +334,65 @@ export type ResearchCockpitView = { blockerCount: number; updatedAt: string; }>; + recentFindings: Array<{ + id: string; + threadId?: string; + title: string; + severity: string; + status: string; + summary?: string; + updatedAt: string; + }>; + activeBlockers: Array<{ + id: string; + threadId?: string; + targetId?: string; + taskId?: string; + reason: string; + title: string; + detail: string; + severity: string; + source: string; + sourceRefId?: string; + nextActions: Array<{ + label: string; + action: string; + params?: Record; + requiresApproval?: boolean; + }>; + createdAt: string; + }>; + recentValidationPlans: Array<{ + id: string; + threadId?: string; + findingId?: string; + targetId?: string; + taskId?: string; + title: string; + status: string; + confidence?: string; + executedAt?: string; + confirmedAt?: string; + updatedAt: string; + payloadCount: number; + resultCount: number; + artifactCount: number; + }>; + pendingApprovals: Array<{ + id: string; + threadId?: string; + status: string; + reason: string; + updatedAt: string; + }>; + recentToolFailures: Array<{ + id: string; + threadId?: string; + toolName: string; + status: string; + error?: string; + updatedAt: string; + }>; recentCodeReviewRuns?: Array<{ id: string; threadId?: string; diff --git a/src/styles/chat.css b/src/styles/chat.css index ea9345f9b..d69a8724f 100644 --- a/src/styles/chat.css +++ b/src/styles/chat.css @@ -3058,6 +3058,110 @@ color: #b8ccff; } +.dashboard-cockpit-groups { + display: grid; + gap: 0.85rem; + overflow-y: auto; + min-height: 0; + max-height: 52vh; + overscroll-behavior: contain; +} + +.dashboard-cockpit-groups > section { + display: grid; + gap: 0.45rem; +} + +.dashboard-cockpit-groups h4 { + margin: 0; + color: var(--text-soft); + font-size: 0.7rem; + font-weight: 780; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.dashboard-cockpit-groups ul { + display: grid; + gap: 0.5rem; + margin: 0; + padding: 0; + list-style: none; +} + +.dashboard-cockpit-groups li { + display: grid; + gap: 0.35rem; + min-width: 0; + border: 1px solid rgb(255 255 255 / 8%); + border-radius: 0.42rem; + background: rgb(255 255 255 / 3%); + padding: 0.62rem; +} + +.dashboard-cockpit-record-heading { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.55rem; +} + +.dashboard-cockpit-record-heading strong { + overflow: hidden; + color: var(--text); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.8rem; +} + +.dashboard-cockpit-groups p, +.dashboard-cockpit-groups small { + margin: 0; + overflow-wrap: anywhere; + color: var(--text-muted); + font-size: 0.7rem; + line-height: 1.45; +} + +.dashboard-cockpit-groups small { + color: var(--text-soft); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.65rem; +} + +.dashboard-cockpit-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.dashboard-cockpit-actions span { + border-radius: 0.3rem; + background: rgb(244 199 106 / 8%); + color: var(--warning); + padding: 0.2rem 0.38rem; + font-size: 0.67rem; +} + +.dashboard-cockpit-diagnostics summary { + cursor: pointer; + color: var(--danger); + font-size: 0.7rem; +} + +.dashboard-cockpit-diagnostics pre { + overflow: auto; + max-height: 14rem; + margin: 0.45rem 0 0; + border-radius: 0.35rem; + background: rgb(5 7 10 / 78%); + padding: 0.55rem; + color: var(--text-soft); + font-size: 0.67rem; + line-height: 1.45; + white-space: pre-wrap; +} + .dashboard-attack-path-list { display: grid; gap: 0.62rem; From 93e08fc2beca59e0b5fe6d92b7240f553071abfc Mon Sep 17 00:00:00 2001 From: Dan Levy Date: Thu, 27 Aug 2026 02:32:02 -0400 Subject: [PATCH 5/5] 112: tighten canonical cockpit ownership --- src/components/chat/WorkspaceDashboard.tsx | 41 +++-- src/components/chat/messageUtils.ts | 179 +-------------------- 2 files changed, 31 insertions(+), 189 deletions(-) diff --git a/src/components/chat/WorkspaceDashboard.tsx b/src/components/chat/WorkspaceDashboard.tsx index b53681ba4..8d3e6be97 100644 --- a/src/components/chat/WorkspaceDashboard.tsx +++ b/src/components/chat/WorkspaceDashboard.tsx @@ -76,7 +76,7 @@ function cockpitRecordTime(value: string) { return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); } -function cockpitQueueTotal(cockpit: ResearchCockpitView | null) { +function cockpitVisibleRecordCount(cockpit: ResearchCockpitView | null) { if (!cockpit) return 0; return ( cockpit.activeBlockers.length + @@ -87,6 +87,17 @@ function cockpitQueueTotal(cockpit: ResearchCockpitView | null) { ); } +function cockpitCanonicalRecordTotal(cockpit: ResearchCockpitView | null) { + if (!cockpit) return 0; + const total = + cockpit.summary.blockers.total + + (cockpit.summary.approvals.byStatus.pending ?? 0) + + cockpit.summary.validationPlans.total + + (cockpit.summary.toolRuns.byStatus.failed ?? 0) + + cockpit.summary.findings.total; + return Math.max(total, cockpitVisibleRecordCount(cockpit)); +} + function getPlanIcon(status: DashboardPlanItem["status"]) { switch (status) { case "completed": @@ -184,8 +195,9 @@ function getCockpitSignal(cockpit: ResearchCockpitView | null) { } function CanonicalCockpitQueues({ cockpit }: { cockpit: ResearchCockpitView }) { - const recordCount = cockpitQueueTotal(cockpit); - if (recordCount === 0) return null; + const visibleRecordCount = cockpitVisibleRecordCount(cockpit); + const totalRecordCount = cockpitCanonicalRecordTotal(cockpit); + if (visibleRecordCount === 0) return null; return (
- {recordCount} record{recordCount === 1 ? "" : "s"} + {visibleRecordCount < totalRecordCount + ? `${visibleRecordCount} of ${totalRecordCount}` + : totalRecordCount}{" "} + record{totalRecordCount === 1 ? "" : "s"} @@ -215,7 +230,7 @@ function CanonicalCockpitQueues({ cockpit }: { cockpit: ResearchCockpitView }) { {cockpit.activeBlockers.map((blocker) => (
  • - {blocker.title} + {blocker.title} {blocker.severity}

    {blocker.detail}

    @@ -246,7 +261,7 @@ function CanonicalCockpitQueues({ cockpit }: { cockpit: ResearchCockpitView }) { {cockpit.pendingApprovals.map((approval) => (
  • - {approval.reason} + {approval.reason} {approval.status}
    @@ -265,7 +280,7 @@ function CanonicalCockpitQueues({ cockpit }: { cockpit: ResearchCockpitView }) { {cockpit.recentValidationPlans.map((plan) => (
  • - {plan.title} + {plan.title} {plan.status}

    @@ -290,7 +305,7 @@ function CanonicalCockpitQueues({ cockpit }: { cockpit: ResearchCockpitView }) { {cockpit.recentFindings.map((finding) => (

  • - {finding.title} + {finding.title} {finding.status}
    {finding.summary ?

    {finding.summary}

    : null} @@ -310,7 +325,7 @@ function CanonicalCockpitQueues({ cockpit }: { cockpit: ResearchCockpitView }) { {cockpit.recentToolFailures.map((failure) => (
  • - {failure.toolName} + {failure.toolName} {failure.status}
    @@ -318,7 +333,7 @@ function CanonicalCockpitQueues({ cockpit }: { cockpit: ResearchCockpitView }) { {failure.error ? (
    - Raw failure detail + Failure summary
                             {failure.error}
                           
    @@ -363,7 +378,9 @@ export function WorkspaceDashboard({ onSurfaceAction(action: SecurityResearchSurfaceAction): void | Promise; }) { const latestFinding = getLatestDashboardFinding(messages); - const latestApproval = getLatestDashboardApproval(messages); + // Once the durable cockpit is available, historical chat cards remain + // transcript evidence rather than a second owner of approval actions. + const latestApproval = cockpit ? null : getLatestDashboardApproval(messages); const approvedTargets = targetAuthorizationDecisions.filter( (decision) => decision.status === "approved", ).length; @@ -378,7 +395,7 @@ export function WorkspaceDashboard({ }); const attackPaths = cockpit?.attackPaths ?? []; const targets = cockpit?.targets ?? []; - const canonicalRecordCount = cockpitQueueTotal(cockpit); + const canonicalRecordCount = cockpitVisibleRecordCount(cockpit); const completeSignals = planItems.filter((item) => item.status === "completed").length + evidenceItems.filter((item) => item.done).length; diff --git a/src/components/chat/messageUtils.ts b/src/components/chat/messageUtils.ts index e371288f4..261bf79eb 100644 --- a/src/components/chat/messageUtils.ts +++ b/src/components/chat/messageUtils.ts @@ -18,6 +18,7 @@ import { looksLikeCommandRequest } from "../../lib/security-chat/surface-action- import type { SecurityTargetType } from "../../lib/security-chat/target-types"; import { isHiddenRuntimeToolId } from "../../lib/tools/catalog"; import type { ApprovalDuration, DurableCommandIntentInput } from "../../server/approvals/types"; +import type { ProjectResearchCockpit } from "../../server/chat/research-cockpit"; import type { ProjectSettings } from "../../server/chat/types"; import { getMessageSurfaces, @@ -231,183 +232,7 @@ export type RunMonitorView = { tasks: RunMonitorTaskView[]; }; -export type ResearchCockpitView = { - projectId: string; - threadId?: string; - summary: { - tasks: { total: number; byStatus: Record }; - findings: { total: number; byStatus: Record }; - approvals: { total: number; byStatus: Record }; - toolRuns: { total: number; byStatus: Record }; - attackPaths: { total: number; byStatus: Record }; - schedulerTasks: { total: number; byStatus: Record }; - validationPlans: { total: number; byStatus: Record }; - codeReviewRuns?: { total: number; byStatus: Record }; - artifacts: { - total: number; - agentGenerated: number; - }; - usage: { - tokens: number; - costUsd: number; - }; - budgetRouting: { - total: number; - byStatus: Record; - latest?: { - status: "healthy" | "thin" | "exhausted"; - reason: string; - runtimeModelUri?: string; - remainingCostUsd?: number; - updatedAt: string; - }; - }; - workspaces: { - total: number; - byStatus: Record; - locked: number; - errored: number; - latest?: { - id: string; - threadId: string; - status: string; - volumeName: string; - mountPath: string; - activeContainerId?: string; - lockOwner?: string; - lockReason?: string; - lastError?: string; - updatedAt: string; - }; - }; - }; - targets: Array<{ - id: string; - projectId: string; - threadId?: string; - kind: string; - label: string; - locator: string; - scope: Record; - relationships: Array<{ - fromTargetId: string; - toTargetId: string; - kind: string; - evidence: string[]; - metadata: Record; - }>; - authorization: { - state: - | "authorized" - | "pending" - | "denied" - | "expired" - | "revoked" - | "consumed" - | "unrecorded"; - authorizationId?: string; - status?: string; - networkProfile?: string; - expiresAt?: string; - updatedAt?: string; - }; - createdAt: string; - updatedAt: string; - }>; - signals: Array<{ - key: string; - status: "clear" | "info" | "warning" | "blocked"; - label: string; - detail: string; - }>; - attackPaths: Array<{ - id: string; - threadId?: string; - title: string; - status: string; - objectiveLabel?: string; - crownJewelTargetId?: string; - riskPriorityScore?: number; - nodeCount: number; - edgeCount: number; - proofCount: number; - blockerCount: number; - updatedAt: string; - }>; - recentFindings: Array<{ - id: string; - threadId?: string; - title: string; - severity: string; - status: string; - summary?: string; - updatedAt: string; - }>; - activeBlockers: Array<{ - id: string; - threadId?: string; - targetId?: string; - taskId?: string; - reason: string; - title: string; - detail: string; - severity: string; - source: string; - sourceRefId?: string; - nextActions: Array<{ - label: string; - action: string; - params?: Record; - requiresApproval?: boolean; - }>; - createdAt: string; - }>; - recentValidationPlans: Array<{ - id: string; - threadId?: string; - findingId?: string; - targetId?: string; - taskId?: string; - title: string; - status: string; - confidence?: string; - executedAt?: string; - confirmedAt?: string; - updatedAt: string; - payloadCount: number; - resultCount: number; - artifactCount: number; - }>; - pendingApprovals: Array<{ - id: string; - threadId?: string; - status: string; - reason: string; - updatedAt: string; - }>; - recentToolFailures: Array<{ - id: string; - threadId?: string; - toolName: string; - status: string; - error?: string; - updatedAt: string; - }>; - recentCodeReviewRuns?: Array<{ - id: string; - threadId?: string; - targetId?: string; - source?: string; - phase: "running" | "done" | "error"; - filesScanned: number; - candidatesFound: number; - filesWithCandidates: number; - artifactId?: string; - updatedAt: string; - }>; - generatedAt: string; -}; - +export type ResearchCockpitView = ProjectResearchCockpit; export type ArtifactMetadataView = { id: string; projectId: string;