diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index fc3b3f3..d7a80c4 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,14 +1,8 @@ import type { ApiCard, ExecuteResult, ScanCostSnapshot, ScanProgressEvent } from "./types.js"; -const base = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace( - /\/$/, - "", -) ?? ""; +const base = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? ""; -async function request( - path: string, - init?: RequestInit, -): Promise { +async function request(path: string, init?: RequestInit): Promise { const res = await fetch(`${base}${path}`, { ...init, headers: { @@ -36,11 +30,7 @@ export async function startScan(input: { }> { const driver = (import.meta.env.VITE_SCAN_DRIVER as - | "fixture" - | "trueforge" - | "record" - | "replay" - | undefined) ?? + "fixture" | "trueforge" | "record" | "replay" | undefined) ?? input.driver ?? "fixture"; return request("/scans", { @@ -127,6 +117,7 @@ export function subscribeScanStream( es.addEventListener("snapshot", (e) => forward("snapshot", (e as MessageEvent).data)); const types = [ "scan.started", + "subagent.queued", "subagent.started", "subagent.progress", "subagent.done", diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index aab69db..374b1a6 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -47,7 +47,7 @@ export type ScanProgressEvent = { export interface SubagentState { systemId: string; displayName: string; - status: "running" | "done"; + status: "queued" | "scanning" | "reconciling" | "failed" | "done"; found: number; startedAt: string; } diff --git a/apps/web/src/components/AgentActivity.tsx b/apps/web/src/components/AgentActivity.tsx index 5c05cd7..152e003 100644 --- a/apps/web/src/components/AgentActivity.tsx +++ b/apps/web/src/components/AgentActivity.tsx @@ -26,9 +26,7 @@ export function AgentActivity({ {activity.person ? ( -

- {activity.person} -

+

{activity.person}

) : null} @@ -47,9 +45,7 @@ export function AgentActivity({
Sandbox @@ -69,9 +65,7 @@ export function AgentActivity({
    {subagents.length === 0 ? ( -
  • - Waiting for systems… -
  • +
  • Waiting for systems…
  • ) : ( subagents.map((s) => (
  • -
    - {s.displayName} -
    +
    {s.displayName}
    {s.systemId}
    @@ -89,15 +81,27 @@ export function AgentActivity({
    - {s.status === "running" ? "Scanning" : "Done"} + {s.status === "queued" + ? "Queued" + : s.status === "scanning" + ? "Scanning" + : s.status === "reconciling" + ? "Reconciling" + : s.status === "failed" + ? "Failed" + : "Done"}
    - {s.found} + {s.found} found
  • @@ -193,8 +197,8 @@ function StartForm({ {!compact ? ( <>

    - Start an access audit. The agent fans out one subagent per connected - system, reconciles identities in the sandbox, then fills the queue. + Start an access audit. The agent fans out one subagent per connected system, reconciles + identities in the sandbox, then fills the queue.

@@ -153,9 +145,7 @@ export function ApprovalQueue({ className="border border-[var(--color-line-strong)] bg-white px-2.5 py-1.5 text-[12px] font-medium text-[var(--color-mute)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]" onClick={() => setChecked( - new Set( - ordered.filter((c) => c.status === "pending").map((c) => c.id), - ), + new Set(ordered.filter((c) => c.status === "pending").map((c) => c.id)), ) } > @@ -324,9 +314,7 @@ function SectionHeading({ > {title} -

- {subtitle} -

+

{subtitle}

{count} diff --git a/apps/web/src/hooks/useScanSession.test.ts b/apps/web/src/hooks/useScanSession.test.ts new file mode 100644 index 0000000..881c4dc --- /dev/null +++ b/apps/web/src/hooks/useScanSession.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { applyEvent, emptyActivity } from "./useScanSession.js"; + +const event = (type: string, systemId?: string) => ({ + type, + scanId: "scan-1", + at: "2026-08-29T00:00:00.000Z", + ...(systemId ? { systemId, displayName: systemId } : {}), +}); + +describe("scan session activity reducer", () => { + it("keeps failed subagents failed while successful siblings reconcile", () => { + let activity = emptyActivity(); + activity = applyEvent(activity, event("subagent.queued", "failed-system")); + activity = applyEvent(activity, event("subagent.queued", "healthy-system")); + activity = applyEvent(activity, event("subagent.started", "failed-system")); + activity = applyEvent(activity, event("subagent.started", "healthy-system")); + activity = applyEvent(activity, { + ...event("subagent.failed", "failed-system"), + error: "Connector unavailable", + }); + activity = applyEvent(activity, event("subagent.done", "healthy-system")); + + const duringReconcile = applyEvent(activity, event("reconcile.started")); + expect(duringReconcile.subagents["failed-system"]?.status).toBe("failed"); + expect(duringReconcile.subagents["healthy-system"]?.status).toBe("reconciling"); + + const afterReconcile = applyEvent(duringReconcile, { + ...event("reconcile.done"), + clusters: 1, + unknown: 0, + }); + expect(afterReconcile.subagents["failed-system"]?.status).toBe("failed"); + expect(afterReconcile.subagents["healthy-system"]?.status).toBe("done"); + }); +}); diff --git a/apps/web/src/hooks/useScanSession.ts b/apps/web/src/hooks/useScanSession.ts index 728467c..7ca2e4b 100644 --- a/apps/web/src/hooks/useScanSession.ts +++ b/apps/web/src/hooks/useScanSession.ts @@ -20,13 +20,26 @@ type Action = | { type: "reset" } | { type: "dismiss_error" } | { type: "scan_starting"; person: string } - | { type: "scan_started"; scanId: string; person: string; driver?: string | null; recordingId?: string | null } + | { + type: "scan_started"; + scanId: string; + person: string; + driver?: string | null; + recordingId?: string | null; + } | { type: "scan_error"; error: string } | { type: "event"; event: ScanProgressEvent } - | { type: "cards"; cards: ApiCard[]; status: string; costs?: AgentActivityState["costs"]; driver?: string | null; recordingId?: string | null } + | { + type: "cards"; + cards: ApiCard[]; + status: string; + costs?: AgentActivityState["costs"]; + driver?: string | null; + recordingId?: string | null; + } | { type: "card_updated"; card: ApiCard }; -const emptyActivity = (): AgentActivityState => ({ +export const emptyActivity = (): AgentActivityState => ({ scanId: null, status: "idle", person: null, @@ -140,9 +153,7 @@ function reduce(state: State, action: Action): State { case "card_updated": return { ...state, - cards: state.cards.map((c) => - c.id === action.card.id ? action.card : c, - ), + cards: state.cards.map((c) => (c.id === action.card.id ? action.card : c)), }; case "event": return { ...state, activity: applyEvent(state.activity, action.event) }; @@ -151,7 +162,7 @@ function reduce(state: State, action: Action): State { } } -function applyEvent( +export function applyEvent( activity: AgentActivityState, event: ScanProgressEvent, ): AgentActivityState { @@ -164,12 +175,26 @@ function applyEvent( "info", at, ); + case "subagent.queued": { + const systemId = String(event.systemId); + const sub: SubagentState = { + systemId, + displayName: String(event.displayName ?? systemId), + status: "queued", + found: 0, + startedAt: at, + }; + return { + ...activity, + subagents: { ...activity.subagents, [systemId]: sub }, + }; + } case "subagent.started": { const systemId = String(event.systemId); const sub: SubagentState = { systemId, displayName: String(event.displayName ?? systemId), - status: "running", + status: "scanning", found: 0, startedAt: at, }; @@ -191,7 +216,11 @@ function applyEvent( ...activity, subagents: { ...activity.subagents, - [systemId]: { ...prev, found: Number(event.found ?? 0) }, + [systemId]: { + ...prev, + status: "scanning", + found: Number(event.found ?? 0), + }, }, }; } @@ -223,11 +252,11 @@ function applyEvent( const prev = activity.subagents[systemId]; const displayName = String(event.displayName ?? prev?.displayName ?? systemId); const next = prev - ? { ...prev, status: "done" as const } + ? { ...prev, status: "failed" as const } : { systemId, displayName, - status: "done" as const, + status: "failed" as const, found: 0, startedAt: at, }; @@ -251,6 +280,15 @@ function applyEvent( return pushLog( { ...activity, + subagents: Object.fromEntries( + Object.entries(activity.subagents).map(([systemId, subagent]) => [ + systemId, + { + ...subagent, + status: subagent.status === "failed" ? "failed" : "reconciling", + }, + ]), + ), sandbox: { active: true, label: "Sandbox", @@ -265,6 +303,15 @@ function applyEvent( return pushLog( { ...activity, + subagents: Object.fromEntries( + Object.entries(activity.subagents).map(([systemId, subagent]) => [ + systemId, + { + ...subagent, + status: subagent.status === "failed" ? "failed" : "done", + }, + ]), + ), sandbox: { active: false, label: "Sandbox", @@ -276,12 +323,7 @@ function applyEvent( at, ); case "cards.persisted": - return pushLog( - activity, - `${Number(event.cardCount ?? 0)} approval cards ready`, - "info", - at, - ); + return pushLog(activity, `${Number(event.cardCount ?? 0)} approval cards ready`, "info", at); case "scan.completed": return pushLog( { @@ -295,16 +337,12 @@ function applyEvent( ...(event.costs ? { costs: { - inputTokens: Number( - (event.costs as { inputTokens?: number }).inputTokens ?? 0, - ), + inputTokens: Number((event.costs as { inputTokens?: number }).inputTokens ?? 0), outputTokens: Number( (event.costs as { outputTokens?: number }).outputTokens ?? 0, ), costUsd: Number((event.costs as { costUsd?: number }).costUsd ?? 0), - hardCapUsd: Number( - (event.costs as { hardCapUsd?: number }).hardCapUsd ?? 0, - ), + hardCapUsd: Number((event.costs as { hardCapUsd?: number }).hardCapUsd ?? 0), capped: Boolean((event.costs as { capped?: boolean }).capped), }, } @@ -329,16 +367,12 @@ function applyEvent( ...(event.costs ? { costs: { - inputTokens: Number( - (event.costs as { inputTokens?: number }).inputTokens ?? 0, - ), + inputTokens: Number((event.costs as { inputTokens?: number }).inputTokens ?? 0), outputTokens: Number( (event.costs as { outputTokens?: number }).outputTokens ?? 0, ), costUsd: Number((event.costs as { costUsd?: number }).costUsd ?? 0), - hardCapUsd: Number( - (event.costs as { hardCapUsd?: number }).hardCapUsd ?? 0, - ), + hardCapUsd: Number((event.costs as { hardCapUsd?: number }).hardCapUsd ?? 0), capped: true, }, } @@ -366,10 +400,7 @@ function applyEvent( status: "partial", error: String(event.error ?? "Partial scan"), errorKind: "partial", - recovery: recoveryFor( - "partial", - event.recovery != null ? String(event.recovery) : null, - ), + recovery: recoveryFor("partial", event.recovery != null ? String(event.recovery) : null), sandbox: { ...activity.sandbox, active: false }, grantsDiscovered: event.grantsDiscovered != null @@ -378,16 +409,12 @@ function applyEvent( ...(event.costs ? { costs: { - inputTokens: Number( - (event.costs as { inputTokens?: number }).inputTokens ?? 0, - ), + inputTokens: Number((event.costs as { inputTokens?: number }).inputTokens ?? 0), outputTokens: Number( (event.costs as { outputTokens?: number }).outputTokens ?? 0, ), costUsd: Number((event.costs as { costUsd?: number }).costUsd ?? 0), - hardCapUsd: Number( - (event.costs as { hardCapUsd?: number }).hardCapUsd ?? 0, - ), + hardCapUsd: Number((event.costs as { hardCapUsd?: number }).hardCapUsd ?? 0), capped: Boolean((event.costs as { capped?: boolean }).capped), }, } @@ -399,19 +426,14 @@ function applyEvent( ); case "scan.failed": { const msg = String(event.error ?? "Scan failed"); - const kind = String( - event.errorKind ?? classifyClientError(msg), - ); + const kind = String(event.errorKind ?? classifyClientError(msg)); return pushLog( { ...activity, status: "failed", error: msg, errorKind: kind, - recovery: recoveryFor( - kind, - event.recovery != null ? String(event.recovery) : null, - ), + recovery: recoveryFor(kind, event.recovery != null ? String(event.recovery) : null), sandbox: { ...activity.sandbox, active: false }, }, msg, diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index b9726d8..f0e2195 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,16 +2,21 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; -export default defineConfig({ - plugins: [react(), tailwindcss()], - server: { - port: 5173, - proxy: { - "/scans": "http://127.0.0.1:3001", - "/cards": "http://127.0.0.1:3001", - "/audit": "http://127.0.0.1:3001", - "/recordings": "http://127.0.0.1:3001", - "/health": "http://127.0.0.1:3001", +export default defineConfig(() => { + const apiPort = Number(process.env.VITE_API_PORT ?? 3001); + const apiTarget = `http://127.0.0.1:${apiPort}`; + + return { + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + "/scans": apiTarget, + "/cards": apiTarget, + "/audit": apiTarget, + "/recordings": apiTarget, + "/health": apiTarget, + }, }, - }, + }; }); diff --git a/fixtures/recordings/ada-lovelace.json b/fixtures/recordings/ada-lovelace.json index 14a2503..6a692ca 100644 --- a/fixtures/recordings/ada-lovelace.json +++ b/fixtures/recordings/ada-lovelace.json @@ -1,7 +1,7 @@ { "version": 1, "id": "ada-lovelace", - "recordedAt": "2026-08-25T01:07:25.009Z", + "recordedAt": "2026-08-30T04:08:07.420Z", "person": "Ada Lovelace", "scope": null, "driver": "record", @@ -12,7 +12,7 @@ "interactions": [ { "kind": "model", - "at": "2026-08-25T01:07:24.828Z", + "at": "2026-08-30T04:08:06.970Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -21,18 +21,9 @@ "inputSummary": "Summarise inventory for aws", "outputSummary": "compact grants for aws" }, - { - "kind": "tool", - "at": "2026-08-25T01:07:24.829Z", - "tool": "inventory_system", - "arguments": { - "system_id": "aws" - }, - "resultSummary": "1 grants" - }, { "kind": "model", - "at": "2026-08-25T01:07:24.829Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -41,18 +32,9 @@ "inputSummary": "Summarise inventory for github", "outputSummary": "compact grants for github" }, - { - "kind": "tool", - "at": "2026-08-25T01:07:24.830Z", - "tool": "inventory_system", - "arguments": { - "system_id": "github" - }, - "resultSummary": "4 grants" - }, { "kind": "model", - "at": "2026-08-25T01:07:24.830Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -61,18 +43,9 @@ "inputSummary": "Summarise inventory for google_workspace", "outputSummary": "compact grants for google_workspace" }, - { - "kind": "tool", - "at": "2026-08-25T01:07:24.831Z", - "tool": "inventory_system", - "arguments": { - "system_id": "google_workspace" - }, - "resultSummary": "6 grants" - }, { "kind": "model", - "at": "2026-08-25T01:07:24.831Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -81,18 +54,9 @@ "inputSummary": "Summarise inventory for notion", "outputSummary": "compact grants for notion" }, - { - "kind": "tool", - "at": "2026-08-25T01:07:24.832Z", - "tool": "inventory_system", - "arguments": { - "system_id": "notion" - }, - "resultSummary": "1 grants" - }, { "kind": "model", - "at": "2026-08-25T01:07:24.832Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -103,16 +67,52 @@ }, { "kind": "tool", - "at": "2026-08-25T01:07:24.832Z", + "at": "2026-08-30T04:08:07.053Z", + "tool": "inventory_system", + "arguments": { + "system_id": "aws" + }, + "resultSummary": "1 grants" + }, + { + "kind": "tool", + "at": "2026-08-30T04:08:07.133Z", + "tool": "inventory_system", + "arguments": { + "system_id": "notion" + }, + "resultSummary": "1 grants" + }, + { + "kind": "tool", + "at": "2026-08-30T04:08:07.213Z", "tool": "inventory_system", "arguments": { "system_id": "slack" }, "resultSummary": "3 grants" }, + { + "kind": "tool", + "at": "2026-08-30T04:08:07.292Z", + "tool": "inventory_system", + "arguments": { + "system_id": "github" + }, + "resultSummary": "4 grants" + }, + { + "kind": "tool", + "at": "2026-08-30T04:08:07.372Z", + "tool": "inventory_system", + "arguments": { + "system_id": "google_workspace" + }, + "resultSummary": "6 grants" + }, { "kind": "model", - "at": "2026-08-25T01:07:24.832Z", + "at": "2026-08-30T04:08:07.372Z", "role": "reasoning", "model": "openai/gpt-4o", "inputTokens": 2500, @@ -123,7 +123,7 @@ }, { "kind": "tool", - "at": "2026-08-25T01:07:24.835Z", + "at": "2026-08-30T04:08:07.385Z", "tool": "run_identity_reconciliation", "arguments": { "grant_ids": [ @@ -144,192 +144,308 @@ "196e91a1f6c5221a50c3809bfe28640a6616be5a420e85f0075a256e02069d6e" ] }, - "resultSummary": "3 clusters, 2 unknown" + "resultSummary": "4 clusters, 1 unknown" } ], "events": [ + { + "type": "subagent.queued", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "aws", + "displayName": "AWS", + "at": "2026-08-30T04:08:06.970Z" + }, + { + "type": "subagent.queued", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "github", + "displayName": "GitHub", + "at": "2026-08-30T04:08:06.970Z" + }, + { + "type": "subagent.queued", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "google_workspace", + "displayName": "Google Workspace", + "at": "2026-08-30T04:08:06.970Z" + }, + { + "type": "subagent.queued", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "notion", + "displayName": "Notion", + "at": "2026-08-30T04:08:06.970Z" + }, + { + "type": "subagent.queued", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "slack", + "displayName": "Slack", + "at": "2026-08-30T04:08:06.970Z" + }, { "type": "subagent.started", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "aws", "displayName": "AWS", - "at": "2026-08-25T01:07:24.827Z" + "at": "2026-08-30T04:08:06.970Z" }, { "type": "cost.update", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "at": "2026-08-25T01:07:24.828Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.970Z", "inputTokens": 800, "outputTokens": 200, "costUsd": 0.00024, "hardCapUsd": 0.5, "capped": false }, - { - "type": "subagent.progress", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "systemId": "aws", - "found": 1, - "at": "2026-08-25T01:07:24.829Z" - }, - { - "type": "subagent.done", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "systemId": "aws", - "found": 1, - "at": "2026-08-25T01:07:24.829Z" - }, { "type": "subagent.started", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "github", "displayName": "GitHub", - "at": "2026-08-25T01:07:24.829Z" + "at": "2026-08-30T04:08:06.970Z" }, { "type": "cost.update", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "at": "2026-08-25T01:07:24.829Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.971Z", "inputTokens": 1600, "outputTokens": 400, "costUsd": 0.00048, "hardCapUsd": 0.5, "capped": false }, - { - "type": "subagent.progress", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "systemId": "github", - "found": 4, - "at": "2026-08-25T01:07:24.830Z" - }, - { - "type": "subagent.done", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "systemId": "github", - "found": 4, - "at": "2026-08-25T01:07:24.830Z" - }, { "type": "subagent.started", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "displayName": "Google Workspace", - "at": "2026-08-25T01:07:24.830Z" + "at": "2026-08-30T04:08:06.971Z" }, { "type": "cost.update", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "at": "2026-08-25T01:07:24.830Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.971Z", "inputTokens": 2400, "outputTokens": 600, "costUsd": 0.00072, "hardCapUsd": 0.5, "capped": false }, - { - "type": "subagent.progress", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "systemId": "google_workspace", - "found": 6, - "at": "2026-08-25T01:07:24.831Z" - }, - { - "type": "subagent.done", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "systemId": "google_workspace", - "found": 6, - "at": "2026-08-25T01:07:24.831Z" - }, { "type": "subagent.started", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "notion", "displayName": "Notion", - "at": "2026-08-25T01:07:24.831Z" + "at": "2026-08-30T04:08:06.971Z" }, { "type": "cost.update", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "at": "2026-08-25T01:07:24.831Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.971Z", "inputTokens": 3200, "outputTokens": 800, "costUsd": 0.00096, "hardCapUsd": 0.5, "capped": false }, + { + "type": "subagent.started", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "slack", + "displayName": "Slack", + "at": "2026-08-30T04:08:06.971Z" + }, + { + "type": "cost.update", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.971Z", + "inputTokens": 4000, + "outputTokens": 1000, + "costUsd": 0.0012, + "hardCapUsd": 0.5, + "capped": false + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "aws", + "found": 1, + "at": "2026-08-30T04:08:07.053Z" + }, + { + "type": "subagent.done", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "aws", + "found": 1, + "at": "2026-08-30T04:08:07.053Z" + }, { "type": "subagent.progress", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "notion", "found": 1, - "at": "2026-08-25T01:07:24.832Z" + "at": "2026-08-30T04:08:07.132Z" }, { "type": "subagent.done", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "notion", "found": 1, - "at": "2026-08-25T01:07:24.832Z" + "at": "2026-08-30T04:08:07.133Z" }, { - "type": "subagent.started", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "slack", - "displayName": "Slack", - "at": "2026-08-25T01:07:24.832Z" + "found": 1, + "at": "2026-08-30T04:08:07.213Z" }, { - "type": "cost.update", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "at": "2026-08-25T01:07:24.832Z", - "inputTokens": 4000, - "outputTokens": 1000, - "costUsd": 0.0012, - "hardCapUsd": 0.5, - "capped": false + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "slack", + "found": 2, + "at": "2026-08-30T04:08:07.213Z" }, { "type": "subagent.progress", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "slack", "found": 3, - "at": "2026-08-25T01:07:24.832Z" + "at": "2026-08-30T04:08:07.213Z" }, { "type": "subagent.done", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "slack", "found": 3, - "at": "2026-08-25T01:07:24.832Z" + "at": "2026-08-30T04:08:07.213Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "github", + "found": 1, + "at": "2026-08-30T04:08:07.292Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "github", + "found": 2, + "at": "2026-08-30T04:08:07.292Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "github", + "found": 3, + "at": "2026-08-30T04:08:07.292Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "github", + "found": 4, + "at": "2026-08-30T04:08:07.292Z" + }, + { + "type": "subagent.done", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "github", + "found": 4, + "at": "2026-08-30T04:08:07.292Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "google_workspace", + "found": 1, + "at": "2026-08-30T04:08:07.372Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "google_workspace", + "found": 2, + "at": "2026-08-30T04:08:07.372Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "google_workspace", + "found": 3, + "at": "2026-08-30T04:08:07.372Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "google_workspace", + "found": 4, + "at": "2026-08-30T04:08:07.372Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "google_workspace", + "found": 5, + "at": "2026-08-30T04:08:07.372Z" + }, + { + "type": "subagent.progress", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "google_workspace", + "found": 6, + "at": "2026-08-30T04:08:07.372Z" + }, + { + "type": "subagent.done", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "systemId": "google_workspace", + "found": 6, + "at": "2026-08-30T04:08:07.372Z" }, { "type": "reconcile.started", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "at": "2026-08-25T01:07:24.832Z" + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:07.372Z" }, { "type": "cost.update", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "at": "2026-08-25T01:07:24.832Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:07.372Z", "inputTokens": 6500, "outputTokens": 2200, "costUsd": 0.01945, "hardCapUsd": 0.5, "capped": false }, + { + "type": "scan.diff", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:07.391Z", + "baselineScanId": null, + "added": 15, + "removed": 0, + "changed": 0, + "unchanged": 0, + "diffOnly": false + }, { "type": "reconcile.done", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", - "at": "2026-08-25T01:07:24.836Z", - "clusters": 3, - "unknown": 2 + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:07.391Z", + "clusters": 4, + "unknown": 1 }, { "type": "cards.persisted", - "scanId": "c01f104c-765a-48be-8ccb-757c0c46c29a", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "cardCount": 7, - "at": "2026-08-25T01:07:25.008Z" + "at": "2026-08-30T04:08:07.419Z" } ], "costs": { @@ -340,7 +456,7 @@ "capped": false, "lines": [ { - "at": "2026-08-25T01:07:24.828Z", + "at": "2026-08-30T04:08:06.970Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -349,7 +465,7 @@ "note": "inventory_system:aws" }, { - "at": "2026-08-25T01:07:24.829Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -358,7 +474,7 @@ "note": "inventory_system:github" }, { - "at": "2026-08-25T01:07:24.830Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -367,7 +483,7 @@ "note": "inventory_system:google_workspace" }, { - "at": "2026-08-25T01:07:24.831Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -376,7 +492,7 @@ "note": "inventory_system:notion" }, { - "at": "2026-08-25T01:07:24.832Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -385,7 +501,7 @@ "note": "inventory_system:slack" }, { - "at": "2026-08-25T01:07:24.832Z", + "at": "2026-08-30T04:08:07.372Z", "role": "reasoning", "model": "openai/gpt-4o", "inputTokens": 2500, @@ -397,7 +513,7 @@ }, "cards": [ { - "id": "a4ce24e16865f17028e4609d77739286cced8d6430a98a5da2aeff4709a119a7", + "id": "c0d7b5558b61727b2a5e83a21bf42a7c748debb12667919da2d36aa2bb6280bd", "status": "pending", "proposedAction": { "kind": "flag_only", @@ -415,12 +531,12 @@ }, "attribution": { "confidence": "speculative", - "reasoning": "2 grant(s) could not be attributed with the available signals (work email, commit email, directory personal email/username, non-ambiguous username similarity, key attribution, or unique onboarding window). Left in unknown rather than guessed into a person — these are the scariest findings." + "reasoning": "1 grant(s) could not be attributed with the available signals (work email, commit email, directory personal email/username, non-ambiguous username similarity, key attribution, or unique onboarding window). Left in unknown rather than guessed into a person — these are the scariest findings." }, "grantId": "8942440fea1f8ec0b5b7e3ae63e86aa93f8bbad2a0882efe003949ed2e1847f2" }, { - "id": "fd28c67e0c673b61c91d217625b04f00fb5bf8f75a9b9cf1be401acad406d068", + "id": "3fa529c035e9627372e823bd4cf18ac65fc1a617e39a37304aaa1a90847c2e0e", "status": "held", "proposedAction": { "kind": "flag_only", @@ -428,22 +544,23 @@ }, "irreversible": true, "risk": { - "score": 100, + "score": 95, "reasons": [ "capability admin (+55)", "lastUsedAt unknown — cannot confirm recent use (+20)", "revocation is irreversible (+15)", - "principal unresolved (kind=unknown) (+25)" + "principal resolved as service_account (probable) (+5)" ] }, "attribution": { - "confidence": "speculative", - "reasoning": "2 grant(s) could not be attributed with the available signals (work email, commit email, directory personal email/username, non-ambiguous username similarity, key attribution, or unique onboarding window). Left in unknown rather than guessed into a person — these are the scariest findings." + "resolvedTo": "816b6fbf714996a70e3b1a74b5c1516df514d8f6494599822240d74cddee309f", + "confidence": "probable", + "reasoning": "Attributed 1 grant(s) to GitHub Actions — payments CDN publish. Inference chain: (probable) key/token creation attributed to a resolved principal: Key AKIA_KEYRING_CI_ORPHAN_LOOKALIKE attributed to ci-payments-cdn via keyring.yml:service_accounts/ci-payments-cdn → (certain) key/token creation attributed to a resolved principal: Declared service account \"GitHub Actions — payments CDN publish\" (owner platform@keyring-test.example) in keyring.yml" }, "grantId": "e9b67d09a9f732d5b8bf904cc337df152f82d4c310af44cb13523c0a95d13e86" }, { - "id": "70fed690c82c6bc3c4be9df3b8b6e0f191d93b0cd62b30ab317bfc0490bdff59", + "id": "d05255b11419080417f566350ae3e7adb6640df7e91ac4a964b51b2109d68d97", "status": "pending", "proposedAction": { "kind": "revoke", @@ -451,12 +568,12 @@ }, "irreversible": false, "risk": { - "score": 75, + "score": 80, "reasons": [ "capability admin (+55)", - "last used 192 days ago — stale (+20)", + "last used 197 days ago — stale (≥90d) (+20)", "revocation possible via admin.conversations.remove (+0)", - "principal resolved as human (+0)" + "principal resolved as human (probable) (+5)" ] }, "attribution": { @@ -467,7 +584,7 @@ "grantId": "196e91a1f6c5221a50c3809bfe28640a6616be5a420e85f0075a256e02069d6e" }, { - "id": "2fdcefdc98f7683e807060330d5e2c551f4cfbf9f64fab688987eef8b50fcca7", + "id": "c5aad7944c4ac3aba896d8d9a0229ca3705474d08694614a9e7b6a1c2646acd0", "status": "pending", "proposedAction": { "kind": "downgrade", @@ -475,12 +592,12 @@ }, "irreversible": false, "risk": { - "score": 40, + "score": 45, "reasons": [ "capability read (+10)", - "last used 542 days ago — highly stale (+30)", + "last used 547 days ago — highly stale (≥365d) (+30)", "revocation possible via drive.permissions.delete (+0)", - "principal resolved as human (+0)" + "principal resolved as human (probable) (+5)" ] }, "attribution": { @@ -491,7 +608,7 @@ "grantId": "c42b99954ceb07a3d653032e168ed3d7ea390c3544028b4154b2349c4a70f9e6" }, { - "id": "941468f73b64efd68fa1cc686277b650d1ad9ceb584620061737284318e04d0f", + "id": "deb48fb41f35ecb6862301bcb809faf1e8388c81d0ab27a1b7950df8d2cbd677", "status": "pending", "proposedAction": { "kind": "downgrade", @@ -499,12 +616,12 @@ }, "irreversible": false, "risk": { - "score": 40, + "score": 45, "reasons": [ "capability write (+30)", - "last used 41 days ago — cooling (+10)", + "last used 46 days ago — cooling (≥14d) (+10)", "revocation possible via remove_collaborator (+0)", - "principal resolved as human (+0)" + "principal resolved as human (probable) (+5)" ] }, "attribution": { @@ -515,7 +632,7 @@ "grantId": "efe2612c13923b03d4641346ecac260168123aeb6c499428a40b613e7362889c" }, { - "id": "9f7fa8a7929c33f27104bb1798e9066ba49b08b381f96550194569d21569a589", + "id": "ace5381d3f0e31dfda01909f10571d2856501a59087762d2f15d57ae48809a70", "status": "pending", "proposedAction": { "kind": "downgrade", @@ -523,12 +640,12 @@ }, "irreversible": false, "risk": { - "score": 30, + "score": 35, "reasons": [ "capability write (+30)", - "last used 5 days ago — recent (+0)", + "last used 10 days ago — recent (+0)", "revocation possible via drive.permissions.delete (+0)", - "principal resolved as human (+0)" + "principal resolved as human (probable) (+5)" ] }, "attribution": { @@ -539,7 +656,7 @@ "grantId": "474b8dc484730147e9016bd5176f2e7bd918c0c614a1f58d96f58cda5ddfb344" }, { - "id": "ec10165cab137035be4034fd29c647e5ca4a6a3bc2ca0476b867ce77c07f219c", + "id": "a86f2b2c33628533c07c645e1fd02060e88997f0e68bf8d0e213e287db0e0def", "status": "pending", "proposedAction": { "kind": "downgrade", @@ -547,12 +664,12 @@ }, "irreversible": false, "risk": { - "score": 30, + "score": 35, "reasons": [ "capability write (+30)", - "last used 3 days ago — recent (+0)", + "last used 8 days ago — recent (+0)", "revocation possible via conversations.kick (+0)", - "principal resolved as human (+0)" + "principal resolved as human (probable) (+5)" ] }, "attribution": { @@ -633,6 +750,22 @@ "confidence": "certain", "reasoning": "Attributed 4 grant(s) to Alan Turing. Inference chain: (certain) exact work-email match: Both grants carry work email alan@keyring-test.example → (certain) exact work-email match: Grant work email alan@keyring-test.example matches directory for Alan Turing → (certain) personal email present on the org directory record: Personal email enigmamachine88@gmail.com is listed on the directory record for Alan Turing → (certain) username listed on the org directory record: Username bombe-ops is listed on the directory record for Alan Turing" }, + { + "id": "f0e94a3325940b70c72a5884b4aef6ce8dd30d6ba1ab2731fe8e0040ff352922", + "kind": "service_account", + "displayName": "GitHub Actions — payments CDN publish", + "personId": "816b6fbf714996a70e3b1a74b5c1516df514d8f6494599822240d74cddee309f", + "identifiers": [ + { + "kind": "key_id", + "value": "AKIA_KEYRING_CI_ORPHAN_LOOKALIKE", + "source": "github_deploy_keys" + } + ], + "grantIds": ["e9b67d09a9f732d5b8bf904cc337df152f82d4c310af44cb13523c0a95d13e86"], + "confidence": "probable", + "reasoning": "Attributed 1 grant(s) to GitHub Actions — payments CDN publish. Inference chain: (probable) key/token creation attributed to a resolved principal: Key AKIA_KEYRING_CI_ORPHAN_LOOKALIKE attributed to ci-payments-cdn via keyring.yml:service_accounts/ci-payments-cdn → (certain) key/token creation attributed to a resolved principal: Declared service account \"GitHub Actions — payments CDN publish\" (owner platform@keyring-test.example) in keyring.yml" + }, { "id": "edcf420a53cf8e754f10e1f31e118925e4769ee7694b142c8387fa004cbbe538", "kind": "human", @@ -666,11 +799,8 @@ } ], "unknown": { - "grantIds": [ - "8942440fea1f8ec0b5b7e3ae63e86aa93f8bbad2a0882efe003949ed2e1847f2", - "e9b67d09a9f732d5b8bf904cc337df152f82d4c310af44cb13523c0a95d13e86" - ], - "reasoning": "2 grant(s) could not be attributed with the available signals (work email, commit email, directory personal email/username, non-ambiguous username similarity, key attribution, or unique onboarding window). Left in unknown rather than guessed into a person — these are the scariest findings." + "grantIds": ["8942440fea1f8ec0b5b7e3ae63e86aa93f8bbad2a0882efe003949ed2e1847f2"], + "reasoning": "1 grant(s) could not be attributed with the available signals (work email, commit email, directory personal email/username, non-ambiguous username similarity, key attribution, or unique onboarding window). Left in unknown rather than guessed into a person — these are the scariest findings." } }, "grantIds": [ diff --git a/packages/core/src/approval-build.ts b/packages/core/src/approval-build.ts index b8ffc18..863e6a1 100644 --- a/packages/core/src/approval-build.ts +++ b/packages/core/src/approval-build.ts @@ -4,17 +4,9 @@ import type { Grant } from "./grant.js"; import { computeRiskScore } from "./risk.js"; import type { ReconciliationResult } from "./identity/types.js"; import { CI_TRAP_MARKER } from "./identity/trap.js"; -import type { - ApprovalCard, - Attribution, - ProposedAction, -} from "./approval.js"; +import type { ApprovalCard, Attribution, ProposedAction } from "./approval.js"; import type { KeyringPolicy } from "./policy/types.js"; -import { - findAutoApproveRule, - findProtectedRule, - resolveStaleness, -} from "./policy/apply.js"; +import { findAutoApproveRule, findProtectedRule, resolveStaleness } from "./policy/apply.js"; export interface BuildApprovalCardsInput { grants: Grant[]; @@ -34,6 +26,10 @@ export function buildApprovalCards(input: BuildApprovalCardsInput): ApprovalCard const { grants, reconciliation, now, policy } = input; const attributionByGrant = new Map(); + const riskAttributionByGrant = new Map< + string, + { kind: "human" | "service_account"; confidence: "certain" | "probable" | "speculative" } + >(); for (const cluster of reconciliation.clusters) { for (const gid of cluster.grantIds) { attributionByGrant.set(gid, { @@ -41,6 +37,10 @@ export function buildApprovalCards(input: BuildApprovalCardsInput): ApprovalCard confidence: cluster.confidence, reasoning: cluster.reasoning, }); + riskAttributionByGrant.set(gid, { + kind: cluster.kind, + confidence: cluster.confidence, + }); } } for (const gid of reconciliation.unknown.grantIds) { @@ -57,7 +57,11 @@ export function buildApprovalCards(input: BuildApprovalCardsInput): ApprovalCard reasoning: "No reconciliation attribution available for this grant.", }; const staleness = resolveStaleness(policy, grant.system); - const risk = computeRiskScore(grant, { now, staleness }); + const risk = computeRiskScore(grant, { + now, + staleness, + attribution: riskAttributionByGrant.get(grant.id), + }); const protectedRule = findProtectedRule(grant, policy); const proposedAction = proposeAction(grant, attribution, protectedRule?.reason); const autoRule = @@ -85,9 +89,7 @@ export function buildApprovalCards(input: BuildApprovalCardsInput): ApprovalCard risk, attribution, status, - ...(protectedRule - ? { protected: true, protectedReason: protectedRule.reason } - : {}), + ...(protectedRule ? { protected: true, protectedReason: protectedRule.reason } : {}), ...(autoRule && status === "approved" ? { autoApprovedBy: autoRule.id, @@ -107,9 +109,7 @@ export function buildApprovalCards(input: BuildApprovalCardsInput): ApprovalCard function isCiTrap(grant: Grant): boolean { return grant.evidence.some( - (e) => - e.claim.includes("KEYRING_DO_NOT_REVOKE_CI_INFRA") || - e.claim.includes(CI_TRAP_MARKER), + (e) => e.claim.includes("KEYRING_DO_NOT_REVOKE_CI_INFRA") || e.claim.includes(CI_TRAP_MARKER), ); } @@ -139,8 +139,7 @@ function proposeAction( if (grant.principal.kind === "unknown" && attribution.resolvedTo === undefined) { return { kind: "flag_only", - description: - "Principal unresolved — flag for human investigation before any revoke.", + description: "Principal unresolved — flag for human investigation before any revoke.", }; } @@ -171,9 +170,6 @@ function proposeAction( } /** Filter cards that propose revoke for a specific person id. */ -export function cardsForPerson( - cards: ApprovalCard[], - personId: PersonId, -): ApprovalCard[] { +export function cardsForPerson(cards: ApprovalCard[], personId: PersonId): ApprovalCard[] { return cards.filter((c) => c.attribution.resolvedTo === personId); } diff --git a/packages/core/src/identity/reconcile.test.ts b/packages/core/src/identity/reconcile.test.ts index 35350d5..1df67f5 100644 --- a/packages/core/src/identity/reconcile.test.ts +++ b/packages/core/src/identity/reconcile.test.ts @@ -9,10 +9,7 @@ import { usernameNameSimilarity } from "./similarity.js"; import { reconcileIdentities } from "./reconcile.js"; import type { DirectoryEntry } from "./types.js"; -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../../../..", -); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); async function loadFixtureInput() { const grantsDoc = JSON.parse( @@ -41,15 +38,11 @@ async function loadFixtureInput() { describe("usernameNameSimilarity", () => { it("matches schen-dev to Sarah Chen (probable-tier score)", () => { - expect(usernameNameSimilarity("schen-dev", "Sarah Chen")).toBeGreaterThanOrEqual( - 0.72, - ); + expect(usernameNameSimilarity("schen-dev", "Sarah Chen")).toBeGreaterThanOrEqual(0.72); }); it("does not match opaque handles to unrelated names", () => { - expect(usernameNameSimilarity("analyticalengine", "Ada Lovelace")).toBeLessThan( - 0.72, - ); + expect(usernameNameSimilarity("analyticalengine", "Ada Lovelace")).toBeLessThan(0.72); expect(usernameNameSimilarity("cobol-compiler", "Grace Hopper")).toBeLessThan(0.72); }); }); @@ -78,15 +71,11 @@ describe("reconcileIdentities — fixture test org", () => { ).toBe(true); expect( ada.identifiers.some( - (i) => - i.kind === "personal_email" && - i.value === "ada.numbers.personal@gmail.com", + (i) => i.kind === "personal_email" && i.value === "ada.numbers.personal@gmail.com", ), ).toBe(true); expect( - ada.identifiers.some( - (i) => i.kind === "username" && i.value === "analyticalengine", - ), + ada.identifiers.some((i) => i.kind === "username" && i.value === "analyticalengine"), ).toBe(true); // Genuinely unattributable: AWS unlabeled key + CI trap deploy key (and any other orphans) @@ -100,10 +89,33 @@ describe("reconcileIdentities — fixture test org", () => { // Every grant is either clustered or unknown — no silent drops const total = - result.clusters.reduce((n, c) => n + c.grantIds.length, 0) + - result.unknown.grantIds.length; + result.clusters.reduce((n, c) => n + c.grantIds.length, 0) + result.unknown.grantIds.length; expect(total).toBe(input.grants.length); }); + + it("does not duplicate human grants into a service-account resource cluster", async () => { + const input = await loadFixtureInput(); + const result = runReconciliationFromJson({ + ...input, + serviceAccounts: [ + { + id: "ci-payments-cdn", + displayName: "GitHub Actions — payments CDN publish", + owner: "platform@keyring-test.example", + keyIds: ["AKIA_KEYRING_CI_ORPHAN_LOOKALIKE"], + resourceIds: ["keyring-test/payments"], + }, + ], + }); + + const ada = result.clusters.find((c) => c.displayName === "Ada Lovelace")!; + const ci = result.clusters.find( + (c) => c.displayName === "GitHub Actions — payments CDN publish", + )!; + expect(ada.grantIds).toHaveLength(5); + expect(ci.grantIds).toHaveLength(1); + expect(ada.grantIds.filter((id) => ci.grantIds.includes(id))).toHaveLength(0); + }); }); describe("reconcileIdentities — adversarial table", () => { @@ -172,9 +184,7 @@ describe("reconcileIdentities — adversarial table", () => { reversible: true, method: "kick", }, - evidence: [ - { claim: "member", source: "test", confidence: "certain" }, - ], + evidence: [{ claim: "member", source: "test", confidence: "certain" }], }), createGrant({ system: "slack", @@ -196,9 +206,7 @@ describe("reconcileIdentities — adversarial table", () => { reversible: true, method: "kick", }, - evidence: [ - { claim: "member", source: "test", confidence: "certain" }, - ], + evidence: [{ claim: "member", source: "test", confidence: "certain" }], }), ]; @@ -210,9 +218,7 @@ describe("reconcileIdentities — adversarial table", () => { expect(connor.grantIds).toHaveLength(1); expect( result.unknown.grantIds.includes( - grants.find((g) => - g.principal.identifiers.some((i) => i.value === "sarah"), - )!.id, + grants.find((g) => g.principal.identifiers.some((i) => i.value === "sarah"))!.id, ), ).toBe(true); }, @@ -252,9 +258,7 @@ describe("reconcileIdentities — adversarial table", () => { reversible: true, method: "drive.permissions.delete", }, - evidence: [ - { claim: "acl", source: "test", confidence: "certain" }, - ], + evidence: [{ claim: "acl", source: "test", confidence: "certain" }], }), createGrant({ system: "google_workspace", @@ -280,9 +284,7 @@ describe("reconcileIdentities — adversarial table", () => { reversible: true, method: "drive.permissions.delete", }, - evidence: [ - { claim: "external share", source: "test", confidence: "certain" }, - ], + evidence: [{ claim: "external share", source: "test", confidence: "certain" }], }), createGrant({ system: "google_workspace", @@ -308,9 +310,7 @@ describe("reconcileIdentities — adversarial table", () => { reversible: true, method: "drive.permissions.delete", }, - evidence: [ - { claim: "external share", source: "test", confidence: "certain" }, - ], + evidence: [{ claim: "external share", source: "test", confidence: "certain" }], }), createGrant({ system: "github", @@ -337,9 +337,7 @@ describe("reconcileIdentities — adversarial table", () => { reversible: true, method: "remove_collaborator", }, - evidence: [ - { claim: "collab", source: "test", confidence: "certain" }, - ], + evidence: [{ claim: "collab", source: "test", confidence: "certain" }], }), ]; @@ -417,9 +415,7 @@ describe("reconcileIdentities — adversarial table", () => { reversible: true, method: "drive.permissions.delete", }, - evidence: [ - { claim: "acl", source: "test", confidence: "certain" }, - ], + evidence: [{ claim: "acl", source: "test", confidence: "certain" }], }), ]; @@ -464,17 +460,13 @@ describe("reconcileIdentities — adversarial table", () => { reversible: true, method: "drive.permissions.delete", }, - evidence: [ - { claim: "acl", source: "test", confidence: "certain" }, - ], + evidence: [{ claim: "acl", source: "test", confidence: "certain" }], }), createGrant({ system: "aws", principal: { kind: "unknown", - identifiers: [ - { kind: "key_id", value: "AKIA_ADA_LAPTOP", source: "aws" }, - ], + identifiers: [{ kind: "key_id", value: "AKIA_ADA_LAPTOP", source: "aws" }], }, resource: { id: "arn:aws:iam::1:user/ada", @@ -488,9 +480,7 @@ describe("reconcileIdentities — adversarial table", () => { reversible: false, method: "iam:DeleteAccessKey", }, - evidence: [ - { claim: "key", source: "test", confidence: "certain" }, - ], + evidence: [{ claim: "key", source: "test", confidence: "certain" }], }), ]; diff --git a/packages/core/src/identity/reconcile.ts b/packages/core/src/identity/reconcile.ts index 953e623..5cb4726 100644 --- a/packages/core/src/identity/reconcile.ts +++ b/packages/core/src/identity/reconcile.ts @@ -4,10 +4,7 @@ import type { Grant } from "../grant.js"; import { sha256Hex } from "../hash.js"; import type { Identifier } from "../identifier.js"; import { normalizeEmailValue } from "../person.js"; -import { - USERNAME_SIMILARITY_THRESHOLD, - usernameNameSimilarity, -} from "./similarity.js"; +import { USERNAME_SIMILARITY_THRESHOLD, usernameNameSimilarity } from "./similarity.js"; import type { DirectoryEntry, IdentityCluster, @@ -345,11 +342,12 @@ export function reconcileIdentities(input: ReconciliationInput): ReconciliationR const seedKey = `sa:${sa.id}`; for (const g of grants) { const keyHit = (sa.keyIds ?? []).some((kid) => - g.principal.identifiers.some( - (i) => i.kind === "key_id" && i.value === kid, - ), + g.principal.identifiers.some((i) => i.kind === "key_id" && i.value === kid), ); - const resHit = (sa.resourceIds ?? []).includes(g.resource.id); + // A shared resource can have both a human collaborator and a service + // account deploy key. Resource ownership alone must not override an + // explicit human principal; use resource IDs only for non-human grants. + const resHit = g.principal.kind !== "human" && (sa.resourceIds ?? []).includes(g.resource.id); if (!keyHit && !resHit) continue; link( grantNode(g.id), @@ -373,9 +371,7 @@ export function reconcileIdentities(input: ReconciliationInput): ReconciliationR // Only use temporal for grants not yet linked to any seed for (const g of grants) { if (!g.createdAt) continue; - const already = seeds.some( - (s) => uf.find(grantNode(g.id)) === uf.find(seedNode(s.key)), - ); + const already = seeds.some((s) => uf.find(grantNode(g.id)) === uf.find(seedNode(s.key))); if (already) continue; const delta = Math.abs(g.createdAt.getTime() - center); if (delta > windowDays * MS_PER_DAY) continue; @@ -410,15 +406,11 @@ export function reconcileIdentities(input: ReconciliationInput): ReconciliationR // Directory-anchored clusters for (const seed of seeds) { const root = uf.find(seedNode(seed.key)); - const grantIds = grants - .map((g) => g.id) - .filter((gid) => uf.find(grantNode(gid)) === root); + const grantIds = grants.map((g) => g.id).filter((gid) => uf.find(grantNode(gid)) === root); if (grantIds.length === 0) continue; for (const gid of grantIds) assigned.add(gid); - const clusterEdges = edges.filter( - (e) => uf.find(e.a) === root && uf.find(e.b) === root, - ); + const clusterEdges = edges.filter((e) => uf.find(e.a) === root && uf.find(e.b) === root); const confidence = clusterEdges.reduce( (acc, e) => weaker(acc, e.confidence), "certain", @@ -460,15 +452,17 @@ export function reconcileIdentities(input: ReconciliationInput): ReconciliationR const sample = byId.get(grantIds[0]!)!; const work = collectIdentifiers(sample).find((i) => i.kind === "work_email"); - const kind = - sample.principal.kind === "service_account" ? "service_account" : "human"; + const kind = sample.principal.kind === "service_account" ? "service_account" : "human"; const displayName = work?.value ?? `Unresolved ${kind} cluster`; for (const gid of grantIds) assigned.add(gid); clusters.push({ id: sha256Hex(`orphan-cluster:${grantIds.slice().sort().join(",")}`), kind, displayName, - identifiers: mergeIdentifiers([], grantIds.map((gid) => byId.get(gid)!)), + identifiers: mergeIdentifiers( + [], + grantIds.map((gid) => byId.get(gid)!), + ), grantIds: grantIds as GrantId[], confidence: "certain", reasoning: buildReasoning(displayName, groupEdges, grantIds.length), @@ -525,11 +519,7 @@ function mergeIdentifiers(base: Identifier[], grants: Grant[]): Identifier[] { ); } -function buildReasoning( - displayName: string, - clusterEdges: Edge[], - grantCount: number, -): string { +function buildReasoning(displayName: string, clusterEdges: Edge[], grantCount: number): string { if (clusterEdges.length === 0) { return `Cluster for ${displayName} with ${grantCount} grant(s); no cross-link edges recorded.`; } @@ -552,19 +542,14 @@ function buildReasoning( const key = `${e.signal}:${e.detail}`; if (seen.has(key)) continue; seen.add(key); - steps.push( - `(${e.confidence}) ${signalLabel(e.signal)}: ${e.detail}`, - ); + steps.push(`(${e.confidence}) ${signalLabel(e.signal)}: ${e.detail}`); } return ( - `Attributed ${grantCount} grant(s) to ${displayName}. Inference chain: ` + - steps.join(" → ") + `Attributed ${grantCount} grant(s) to ${displayName}. Inference chain: ` + steps.join(" → ") ); } /** JSON-friendly serialize (dates as ISO). */ -export function serializeReconciliationResult( - result: ReconciliationResult, -): unknown { +export function serializeReconciliationResult(result: ReconciliationResult): unknown { return result; } diff --git a/packages/core/src/risk.test.ts b/packages/core/src/risk.test.ts index 5e70f91..82e0860 100644 --- a/packages/core/src/risk.test.ts +++ b/packages/core/src/risk.test.ts @@ -120,6 +120,50 @@ describe("risk scoring", () => { expect(noIds.reasons.some((r) => r.includes("no identifiers"))).toBe(true); }); + it("uses reconciled ownership and confidence instead of raw principal kind", () => { + const unattributed = computeRiskScore( + baseGrant({ + principal: { + kind: "unknown", + identifiers: [{ kind: "key_id", value: "AKIA...", source: "aws" }], + }, + }), + { now }, + ); + const probableHuman = computeRiskScore( + baseGrant({ + principal: { + kind: "unknown", + identifiers: [{ kind: "username", value: "ada", source: "github" }], + }, + }), + { + now, + attribution: { kind: "human", confidence: "probable" }, + }, + ); + const certainServiceAccount = computeRiskScore( + baseGrant({ + principal: { + kind: "unknown", + identifiers: [{ kind: "key_id", value: "CI_KEY", source: "github" }], + }, + }), + { + now, + attribution: { kind: "service_account", confidence: "certain" }, + }, + ); + + expect(unattributed.score).toBeGreaterThan(probableHuman.score); + expect(probableHuman.score).toBeGreaterThan(certainServiceAccount.score); + expect(unattributed.reasons.some((r) => r.includes("unresolved"))).toBe(true); + expect(probableHuman.reasons).toContain("principal resolved as human (probable) (+5)"); + expect(certainServiceAccount.reasons).toContain( + "principal resolved as service_account (certain) (+0)", + ); + }); + it("caps score at 100 and always returns reasons", () => { const risk = computeRiskScore( baseGrant({ diff --git a/packages/core/src/risk.ts b/packages/core/src/risk.ts index 9d52859..0599a7d 100644 --- a/packages/core/src/risk.ts +++ b/packages/core/src/risk.ts @@ -1,4 +1,5 @@ import type { Grant } from "./grant.js"; +import type { Confidence } from "./evidence.js"; import type { PolicyStalenessThresholds } from "./policy/types.js"; import { DEFAULT_STALENESS } from "./policy/types.js"; @@ -25,15 +26,17 @@ export interface RiskScoreOptions { now?: Date; /** Per-system thresholds from keyring.yml (falls back to defaults). */ staleness?: PolicyStalenessThresholds; + /** Reconciled identity, when available; otherwise fall back to the raw grant. */ + attribution?: { + kind: Grant["principal"]["kind"]; + confidence: Confidence; + }; } /** * Risk from staleness, capability, reversibility, and principal resolution. */ -export function computeRiskScore( - grant: Grant, - options: RiskScoreOptions = {}, -): RiskScore { +export function computeRiskScore(grant: Grant, options: RiskScoreOptions = {}): RiskScore { const now = options.now ?? new Date(); const thresholds = options.staleness ?? DEFAULT_STALENESS; const reasons: string[] = []; @@ -49,10 +52,7 @@ export function computeRiskScore( score += 20; reasons.push("lastUsedAt unknown — cannot confirm recent use (+20)"); } else { - const days = Math.max( - 0, - Math.floor((now.getTime() - grant.lastUsedAt.getTime()) / MS_PER_DAY), - ); + const days = Math.max(0, Math.floor((now.getTime() - grant.lastUsedAt.getTime()) / MS_PER_DAY)); if (days >= thresholds.critical_days) { score += 30; reasons.push( @@ -60,14 +60,10 @@ export function computeRiskScore( ); } else if (days >= thresholds.stale_days) { score += 20; - reasons.push( - `last used ${days} days ago — stale (≥${thresholds.stale_days}d) (+20)`, - ); + reasons.push(`last used ${days} days ago — stale (≥${thresholds.stale_days}d) (+20)`); } else if (days >= thresholds.cooling_days) { score += 10; - reasons.push( - `last used ${days} days ago — cooling (≥${thresholds.cooling_days}d) (+10)`, - ); + reasons.push(`last used ${days} days ago — cooling (≥${thresholds.cooling_days}d) (+10)`); } else { reasons.push(`last used ${days} days ago — recent (+0)`); } @@ -84,15 +80,29 @@ export function computeRiskScore( reasons.push(`revocation possible via ${grant.revocable.method} (+0)`); } - // Principal resolution - if (grant.principal.kind === "unknown") { + // Principal resolution. Reconciliation is authoritative when supplied; a + // raw unknown principal is only penalized when no resolved identity exists. + const principal = options.attribution ?? { + kind: grant.principal.kind, + confidence: "certain" as const, + }; + if (!options.attribution && principal.kind === "unknown") { score += 25; reasons.push("principal unresolved (kind=unknown) (+25)"); - } else if (grant.principal.identifiers.length === 0) { + } else if ( + !options.attribution && + principal.kind !== "unknown" && + grant.principal.identifiers.length === 0 + ) { score += 15; reasons.push("principal has no identifiers (+15)"); } else { - reasons.push(`principal resolved as ${grant.principal.kind} (+0)`); + const confidencePenalty = + principal.confidence === "certain" ? 0 : principal.confidence === "probable" ? 5 : 10; + score += confidencePenalty; + reasons.push( + `principal resolved as ${principal.kind} (${principal.confidence}) (+${confidencePenalty})`, + ); } return { diff --git a/packages/server/src/agent/scan.ts b/packages/server/src/agent/scan.ts index 9ed0cad..74d538d 100644 --- a/packages/server/src/agent/scan.ts +++ b/packages/server/src/agent/scan.ts @@ -18,10 +18,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { loadPolicy } from "../policy/load.js"; -const repoRoot = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - "../../../..", -); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); /** Display names for fixture systems (5 in test-org; fan-out uses one subagent each). */ const SYSTEM_LABELS: Record = { @@ -75,9 +72,7 @@ export function grantToCompact(grant: Grant): CompactGrant { }, principal: grant.principal, evidenceSources: grant.evidence.map((e) => e.source), - ...(grant.lastUsedAt - ? { lastUsedAt: grant.lastUsedAt.toISOString() } - : {}), + ...(grant.lastUsedAt ? { lastUsedAt: grant.lastUsedAt.toISOString() } : {}), }; } @@ -88,7 +83,11 @@ export function grantToCompact(grant: Grant): CompactGrant { */ export async function inventorySystem( systemId: string, - opts: { delayMsPerGrant?: number; signal?: AbortSignal } = {}, + opts: { + delayMsPerGrant?: number; + signal?: AbortSignal; + onGrant?: (found: number) => void; + } = {}, ): Promise<{ systemId: string; grants: CompactGrant[]; count: number }> { const connector = createFixtureConnector({ fixturesDir: path.join(repoRoot, "fixtures/test-org"), @@ -105,6 +104,7 @@ export async function inventorySystem( await sleep(opts.delayMsPerGrant, opts.signal); } grants.push(grant); + opts.onGrant?.(grants.length); } return { @@ -163,9 +163,7 @@ export function buildReconcileInputJson( ...(g.lastUsedAt ? { lastUsedAt: g.lastUsedAt.toISOString() } : {}), })), directory, - ...(opts.keyAttributions?.length - ? { keyAttributions: opts.keyAttributions } - : {}), + ...(opts.keyAttributions?.length ? { keyAttributions: opts.keyAttributions } : {}), ...(opts.serviceAccounts?.length ? { serviceAccounts: opts.serviceAccounts.map((sa) => ({ @@ -234,17 +232,14 @@ export async function runFixtureScanPipeline( for (const g of result.grants) mergedIds.push(g.id); } - const { reconciliation, grantCount, policy } = - await runIdentityReconciliation(mergedIds); + const { reconciliation, grantCount, policy } = await runIdentityReconciliation(mergedIds); void grantCount; const grants = await loadFullFixtureGrants(); let cards = buildApprovalCards({ grants, reconciliation, policy }); if (opts.personHint) { const hint = opts.personHint.toLowerCase(); cards = cards.filter((c) => { - const ids = c.grant.principal.identifiers - .map((i) => i.value.toLowerCase()) - .join(" "); + const ids = c.grant.principal.identifiers.map((i) => i.value.toLowerCase()).join(" "); const name = c.attribution.reasoning.toLowerCase(); return ids.includes(hint) || name.includes(hint); }); diff --git a/packages/server/src/api/progress.ts b/packages/server/src/api/progress.ts index acf3e3e..4f7a242 100644 --- a/packages/server/src/api/progress.ts +++ b/packages/server/src/api/progress.ts @@ -4,10 +4,7 @@ import { EventEmitter } from "node:events"; import type { FastifyBaseLogger } from "fastify"; /** Child logger that always includes scanId when present. */ -export function scanLog( - log: FastifyBaseLogger, - scanId: string, -): FastifyBaseLogger { +export function scanLog(log: FastifyBaseLogger, scanId: string): FastifyBaseLogger { return log.child({ scanId }); } @@ -20,6 +17,13 @@ export type ScanProgressEvent = driver: string; at: string; } + | { + type: "subagent.queued"; + scanId: string; + systemId: string; + displayName: string; + at: string; + } | { type: "subagent.started"; scanId: string; @@ -162,10 +166,7 @@ class ScanBus { return [...(this.buffers.get(scanId) ?? [])]; } - subscribe( - scanId: string, - handler: (event: ScanProgressEvent) => void, - ): () => void { + subscribe(scanId: string, handler: (event: ScanProgressEvent) => void): () => void { const ee = this.ensure(scanId); ee.on("event", handler); return () => { diff --git a/packages/server/src/recording/recording.fixture.test.ts b/packages/server/src/recording/recording.fixture.test.ts new file mode 100644 index 0000000..d069810 --- /dev/null +++ b/packages/server/src/recording/recording.fixture.test.ts @@ -0,0 +1,45 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import type { ScanRecording } from "./types.js"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); + +describe("checked-in recordings", () => { + it("keeps Ada and service-account grant ownership disjoint", async () => { + const recording = JSON.parse( + await readFile(path.join(repoRoot, "fixtures/recordings/ada-lovelace.json"), "utf8"), + ) as ScanRecording; + const reconciliation = recording.reconciliation; + + expect(reconciliation).not.toBeNull(); + if (!reconciliation) return; + + const clusterIds = reconciliation.clusters.flatMap((cluster) => cluster.grantIds); + const unknownIds = reconciliation.unknown.grantIds; + expect(new Set(clusterIds).size).toBe(clusterIds.length); + expect(clusterIds.some((id) => unknownIds.includes(id))).toBe(false); + expect(new Set([...clusterIds, ...unknownIds])).toEqual(new Set(recording.grantIds)); + + const cards = recording.cards as Array<{ + grantId: string; + attribution?: { resolvedTo?: string }; + }>; + const ownerByGrant = new Map( + reconciliation.clusters.flatMap((cluster) => + cluster.grantIds.map((grantId) => [grantId, cluster.personId] as const), + ), + ); + for (const card of cards) { + const owner = ownerByGrant.get(card.grantId); + if (owner) { + expect(card.attribution?.resolvedTo).toBe(owner); + } else { + expect(unknownIds).toContain(card.grantId); + expect(card.attribution?.resolvedTo).toBeUndefined(); + } + } + }); +}); diff --git a/packages/server/src/recording/recording.integration.test.ts b/packages/server/src/recording/recording.integration.test.ts index 4fe788c..dfc1c96 100644 --- a/packages/server/src/recording/recording.integration.test.ts +++ b/packages/server/src/recording/recording.integration.test.ts @@ -70,14 +70,25 @@ describe("record → replay (offline)", () => { const recording = await loadRecording(recordingId); expect(recording.id).toBe(recordingId); expect(recording.cards.length).toBeGreaterThan(0); + expect(recording.cards.length).toBeLessThanOrEqual(8); + const systemIds = ["aws", "github", "google_workspace", "notion", "slack"]; + const subagentEvents = recording.events.filter((event) => event.type.startsWith("subagent.")); + expect(new Set(subagentEvents.map((event) => String(event.systemId)))).toEqual( + new Set(systemIds), + ); + expect(subagentEvents.filter((event) => event.type === "subagent.queued")).toHaveLength(5); + expect(subagentEvents.filter((event) => event.type === "subagent.done")).toHaveLength(5); + expect( + new Set( + subagentEvents.filter((event) => event.type === "subagent.done").map((event) => event.at), + ).size, + ).toBeGreaterThan(1); const cardsAfterRecord = await app.inject({ method: "GET", url: `/scans/${scanId}/cards`, }); - const recordedCount = ( - cardsAfterRecord.json() as { cards: unknown[] } - ).cards.length; + const recordedCount = (cardsAfterRecord.json() as { cards: unknown[] }).cards.length; const replay = await app.inject({ method: "POST", @@ -110,8 +121,6 @@ describe("record → replay (offline)", () => { method: "GET", url: `/scans/${replayId}/cards`, }); - expect((replayCards.json() as { cards: unknown[] }).cards.length).toBe( - recordedCount, - ); + expect((replayCards.json() as { cards: unknown[] }).cards.length).toBe(recordedCount); }, 60_000); }); diff --git a/packages/server/src/services/scan-runner.ts b/packages/server/src/services/scan-runner.ts index c140eb4..0b8c2d2 100644 --- a/packages/server/src/services/scan-runner.ts +++ b/packages/server/src/services/scan-runner.ts @@ -11,11 +11,7 @@ import { import { newScanId, scanBus, scanLog, type ScanProgressEvent } from "../api/progress.js"; import type { CreateScanBody } from "../api/schemas.js"; import { loadCostConfig, modelForRole } from "../costs/config.js"; -import { - CostCapExceededError, - ScanCostLedger, - type ScanCostSnapshot, -} from "../costs/ledger.js"; +import { CostCapExceededError, ScanCostLedger, type ScanCostSnapshot } from "../costs/ledger.js"; import type { Database } from "../db/client.js"; import { createScanRun, @@ -26,21 +22,11 @@ import { upsertApprovalCard, upsertGrant, } from "../db/store.js"; -import { - classifyProductError, - recoveryFor, -} from "../errors/classify.js"; +import { classifyProductError, recoveryFor } from "../errors/classify.js"; import { ScanRecorder } from "../recording/recorder.js"; import { loadRecording, saveRecording } from "../recording/store.js"; -import { - recordingIdFromPerson, - type ScanRecording, -} from "../recording/types.js"; -import { - diffGrantSnapshots, - filterCardsToDiff, - type ScanDiff, -} from "./scan-diff.js"; +import { recordingIdFromPerson, type ScanRecording } from "../recording/types.js"; +import { diffGrantSnapshots, filterCardsToDiff, type ScanDiff } from "./scan-diff.js"; export type ScanDriver = "fixture" | "trueforge" | "record" | "replay"; export interface StartScanResult { @@ -50,6 +36,14 @@ export interface StartScanResult { recordingId?: string; } +const DEMO_SYSTEM_STAGGER_MS: Record = { + aws: 80, + notion: 160, + slack: 240, + github: 320, + google_workspace: 400, +}; + function resolveDriver(override?: ScanDriver): ScanDriver { if (override) return override; const env = process.env.KEYRING_SCAN_DRIVER; @@ -96,8 +90,7 @@ export async function startScan( const driver = resolveDriver(body.driver); const logger = scanLog(log, scanId); const recordingId = - body.recordingId ?? - (body.person ? recordingIdFromPerson(body.person) : "scan"); + body.recordingId ?? (body.person ? recordingIdFromPerson(body.person) : "scan"); await createScanRun(db, { id: scanId, @@ -130,6 +123,7 @@ export async function startScan( }); logger.info({ driver, person: body.person, recordingId }, "scan started"); + let scanLedger: ScanCostLedger | undefined; void (async () => { try { if (driver === "replay") { @@ -139,6 +133,7 @@ export async function startScan( if (driver === "trueforge" || body.recordWith === "trueforge") { const ledger = new ScanCostLedger(); + scanLedger = ledger; const recorder = driver === "record" ? new ScanRecorder() : undefined; await driveTrueForgeAgent(db, scanId, body, logger, ledger, recorder); await runFixtureFanOutAndPersist(db, scanId, body, logger, { @@ -153,6 +148,7 @@ export async function startScan( // fixture or record (fixture backend) const ledger = new ScanCostLedger(); + scanLedger = ledger; const recorder = driver === "record" ? new ScanRecorder() : undefined; await runFixtureFanOutAndPersist(db, scanId, body, logger, { emitSubagents: true, @@ -164,13 +160,13 @@ export async function startScan( } catch (err) { if (err instanceof CostCapExceededError) { logger.warn({ costUsd: err.costUsd }, "scan cost capped"); - const costs = { + const costs = scanLedger?.snapshot() ?? { inputTokens: 0, outputTokens: 0, costUsd: err.costUsd, hardCapUsd: err.hardCapUsd, capped: true, - lines: [] as [], + lines: [], }; const classified = classifyProductError(err, "cost_capped"); await updateScanMetadata(db, scanId, { @@ -249,18 +245,36 @@ async function runFixtureFanOutAndPersist( }, ): Promise { const cfg = loadCostConfig(); - const delay = - body.delayMsPerGrant ?? Number(process.env.KEYRING_SCAN_DELAY_MS ?? 0); + const delay = body.delayMsPerGrant ?? Number(process.env.KEYRING_SCAN_DELAY_MS ?? 0); const systems = await listConnectedSystems(); const mergedIds: string[] = []; - const failedSystems: Array<{ + type FailedSystem = { systemId: string; error: string; errorKind: string; - }> = []; + }; + const failedSystemsById = new Map(); + const successfulSystems = new Set(); const { ledger, recorder } = opts; + const fanOutController = new AbortController(); - for (const system of systems) { + if (opts.emitSubagents) { + for (const system of systems) { + publish( + { + type: "subagent.queued", + scanId, + systemId: system.id, + displayName: system.displayName, + at: new Date().toISOString(), + }, + recorder, + ); + } + } + + const grantsBySystem = new Map(); + const inventoryTasks = systems.map(async (system) => { if (opts.emitSubagents) { publish( { @@ -307,10 +321,32 @@ async function runFixtureFanOutAndPersist( throw err; } + if (opts.emitSubagents && opts.record) { + await pause(DEMO_SYSTEM_STAGGER_MS[system.id] ?? 200, fanOutController.signal); + } const result = await inventorySystem(system.id, { delayMsPerGrant: opts.emitSubagents ? delay : 0, + signal: fanOutController.signal, + onGrant: opts.emitSubagents + ? (found) => { + publish( + { + type: "subagent.progress", + scanId, + systemId: system.id, + found, + at: new Date().toISOString(), + }, + recorder, + ); + } + : undefined, }); - for (const g of result.grants) mergedIds.push(g.id); + grantsBySystem.set( + system.id, + result.grants.map((grant) => grant.id), + ); + successfulSystems.add(system.id); recorder?.addTool({ at: new Date().toISOString(), @@ -320,16 +356,6 @@ async function runFixtureFanOutAndPersist( }); if (opts.emitSubagents) { - publish( - { - type: "subagent.progress", - scanId, - systemId: system.id, - found: result.count, - at: new Date().toISOString(), - }, - recorder, - ); publish( { type: "subagent.done", @@ -344,8 +370,9 @@ async function runFixtureFanOutAndPersist( } } catch (err) { if (err instanceof CostCapExceededError) throw err; + if (fanOutController.signal.aborted) throw err; const classified = classifyProductError(err); - failedSystems.push({ + failedSystemsById.set(system.id, { systemId: system.id, error: classified.message, errorKind: classified.kind, @@ -368,13 +395,26 @@ async function runFixtureFanOutAndPersist( "subagent failed — continuing partial scan", ); } + }); + try { + await Promise.all(inventoryTasks); + } catch (err) { + fanOutController.abort(err); + await Promise.allSettled(inventoryTasks); + throw err; + } + const failedSystems = systems + .map((system) => failedSystemsById.get(system.id)) + .filter((failure): failure is NonNullable => Boolean(failure)); + for (const system of systems) { + for (const grantId of grantsBySystem.get(system.id) ?? []) { + mergedIds.push(grantId); + } } - if (failedSystems.length > 0 && mergedIds.length === 0) { + if (failedSystems.length > 0 && successfulSystems.size === 0) { const first = failedSystems[0]!; - const err = new Error( - `All connectors failed (first: ${first.systemId}: ${first.error})`, - ); + const err = new Error(`All connectors failed (first: ${first.systemId}: ${first.error})`); Object.assign(err, { status: first.errorKind === "rate_limit" ? 429 : 401 }); throw err; } @@ -427,9 +467,7 @@ async function runFixtureFanOutAndPersist( resultSummary: `${reconciliation.clusters.length} clusters, ${reconciliation.unknown.grantIds.length} unknown`, }); - const grants = (await loadFullFixtureGrants()).filter((g) => - mergedIds.includes(g.id), - ); + const grants = (await loadFullFixtureGrants()).filter((g) => mergedIds.includes(g.id)); let cards = buildApprovalCards({ grants, reconciliation, policy }); if (body.person) { @@ -437,9 +475,7 @@ async function runFixtureFanOutAndPersist( cards = cards.filter( (c) => c.attribution.reasoning.toLowerCase().includes(hint) || - c.grant.principal.identifiers.some((i) => - i.value.toLowerCase().includes(hint), - ) || + c.grant.principal.identifiers.some((i) => i.value.toLowerCase().includes(hint)) || c.status === "held" || c.attribution.resolvedTo === undefined || c.protected === true, @@ -449,9 +485,7 @@ async function runFixtureFanOutAndPersist( // Re-audit diff vs previous completed scan let diff: ScanDiff | null = null; const wantDiff = - body.reaudit === true || - body.diffOnly === true || - policy.reaudit?.diff_only === true; + body.reaudit === true || body.diffOnly === true || policy.reaudit?.diff_only === true; const prev = await getPreviousCompletedScan(db, scanId); const prevMeta = (prev?.metadata ?? null) as { grantIds?: string[]; @@ -483,8 +517,7 @@ async function runFixtureFanOutAndPersist( ); const diffOnly = body.diffOnly === true || - (body.reaudit === true && - (body.diffOnly !== false && (policy.reaudit?.diff_only ?? true))); + (body.reaudit === true && body.diffOnly !== false && (policy.reaudit?.diff_only ?? true)); publish( { @@ -628,17 +661,28 @@ async function runReplay( log: FastifyBaseLogger, ): Promise { const recording = await loadRecording(recordingId); + const replaySpeed = positiveNumberEnv("KEYRING_REPLAY_SPEED", 1); + const replayMaxGapMs = positiveNumberEnv("KEYRING_REPLAY_MAX_GAP_MS", 180); log.info( - { recordingId, interactions: recording.interactions.length }, + { + recordingId, + interactions: recording.interactions.length, + replaySpeed, + }, "replaying recording (zero API calls)", ); // Re-emit events with this scanId so SSE clients see live progress + let previousEventAt: number | null = null; for (const event of recording.events) { + const eventAt = Date.parse(event.at); + if (previousEventAt !== null && Number.isFinite(eventAt)) { + const recordedGap = Math.max(0, eventAt - previousEventAt); + await pause(Math.min(Math.max(recordedGap * replaySpeed, 8), replayMaxGapMs)); + } const rewritten = { ...event, scanId } as ScanProgressEvent; scanBus.publish(rewritten); - // Tiny yield so UI can paint - await new Promise((r) => setTimeout(r, 5)); + previousEventAt = Number.isFinite(eventAt) ? eventAt : previousEventAt; } const grants = await loadFullFixtureGrants(); @@ -669,9 +713,7 @@ async function runReplay( const selectedGrants = recording.grantIds .map((id) => byId.get(id)) .filter((g): g is NonNullable => Boolean(g)); - const { reconciliation, policy } = await runIdentityReconciliation( - recording.grantIds, - ); + const { reconciliation, policy } = await runIdentityReconciliation(recording.grantIds); const recon = recording.reconciliation ?? reconciliation; let domainCards = buildApprovalCards({ grants: selectedGrants, @@ -683,9 +725,7 @@ async function runReplay( domainCards = domainCards.filter( (c) => c.attribution.reasoning.toLowerCase().includes(hint) || - c.grant.principal.identifiers.some((i) => - i.value.toLowerCase().includes(hint), - ) || + c.grant.principal.identifiers.some((i) => i.value.toLowerCase().includes(hint)) || c.status === "held" || c.attribution.resolvedTo === undefined, ); @@ -745,9 +785,7 @@ async function driveTrueForgeAgent( const client = new TrueForge({ baseUrl, timeoutInSeconds: 600, - ...(process.env.TRUEFORGE_TOKEN - ? { token: process.env.TRUEFORGE_TOKEN } - : {}), + ...(process.env.TRUEFORGE_TOKEN ? { token: process.env.TRUEFORGE_TOKEN } : {}), }); const prompt = body.person @@ -777,9 +815,11 @@ async function driveTrueForgeAgent( const threadId = (event as { threadId?: string | null }).threadId ?? null; if (eventType === "model.message") { - const usage = (event as { - usage?: { input_tokens?: number; output_tokens?: number }; - }).usage; + const usage = ( + event as { + usage?: { input_tokens?: number; output_tokens?: number }; + } + ).usage; if (usage) { const role = threadId && threadId !== "main" ? "inventory" : "reasoning"; try { @@ -864,6 +904,29 @@ async function driveTrueForgeAgent( } } +function pause(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("aborted")); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(signal.reason ?? new Error("aborted")); + }, + { once: true }, + ); + }); +} + +function positiveNumberEnv(name: string, fallback: number): number { + const value = Number(process.env[name] ?? fallback); + return Number.isFinite(value) && value > 0 ? value : fallback; +} + function serializeCardForRecording(card: { id: string; status: string; diff --git a/scripts/demo.ts b/scripts/demo.ts index fc8ad97..a03002c 100644 --- a/scripts/demo.ts +++ b/scripts/demo.ts @@ -7,6 +7,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import http from "node:http"; +import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -52,6 +53,29 @@ function waitHealth(url: string, timeoutMs = 60_000): Promise { }); } +async function findAvailablePort(startPort: number): Promise { + for (let port = startPort; port < startPort + 20; port++) { + const available = await new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE") { + resolve(false); + return; + } + reject(err); + }); + probe.listen(port, "0.0.0.0", () => { + probe.close((err) => { + if (err) reject(err); + else resolve(true); + }); + }); + }); + if (available) return port; + } + throw new Error(`no available port in range ${startPort}-${startPort + 19}`); +} + async function main(): Promise { if (!fs.existsSync(recording)) { die( @@ -78,43 +102,60 @@ async function main(): Promise { process.env.KEYRING_PGLITE_PATH = pglitePath; process.env.KEYRING_SCAN_DRIVER = "replay"; process.env.KEYRING_EXECUTE_DRY_RUN = "1"; - process.env.PORT = process.env.PORT ?? "3001"; // Clear DATABASE_URL so demo never requires Docker Postgres delete process.env.DATABASE_URL; + const requestedApiPort = Number(process.env.PORT ?? 3001); + const apiPort = await findAvailablePort(requestedApiPort); + const requestedUiPort = Number(process.env.VITE_PORT ?? 5173); + const uiPort = await findAvailablePort(requestedUiPort); + if (apiPort !== requestedApiPort) { + console.log(`[demo] API port ${requestedApiPort} is busy; using ${apiPort}.`); + } + if (uiPort !== requestedUiPort) { + console.log(`[demo] UI port ${requestedUiPort} is busy; using ${uiPort}.`); + } + process.env.PORT = String(apiPort); + console.log("[demo] Migrating embedded PGlite…"); await runMigrations(); console.log("[demo] Starting API (replay, dry-run, no API keys)…"); - const server = spawn( - "pnpm", - ["--filter", "@keyring/server", "exec", "tsx", "src/index.ts"], - { - cwd: root, - env: { ...process.env }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); + const server = spawn("pnpm", ["--filter", "@keyring/server", "exec", "tsx", "src/index.ts"], { + cwd: root, + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); children.push(server); server.stdout?.on("data", (b) => process.stdout.write(b)); - server.stderr?.on("data", (b) => process.stderr.write(b)); + server.stderr?.on("data", (b) => { + const text = String(b); + if (/EADDRINUSE|address already in use/i.test(text)) { + die( + `API port ${apiPort} is already in use. Stop the process using it or set PORT to another free port.`, + ); + } + process.stderr.write(text); + }); server.on("exit", (code) => { if (code && code !== 0) die(`API exited with code ${code}`); }); - await waitHealth(`http://127.0.0.1:${process.env.PORT}/health`).catch((e) => - die(String(e)), - ); + await waitHealth(`http://127.0.0.1:${process.env.PORT}/health`).catch((e) => die(String(e))); console.log("[demo] Starting UI…"); const web = spawn( "pnpm", - ["--filter", "@keyring/web", "exec", "vite", "--host", "127.0.0.1", "--port", "5173"], + ["--filter", "@keyring/web", "exec", "vite", "--host", "127.0.0.1", "--port", String(uiPort)], { cwd: root, env: { ...process.env, VITE_SCAN_DRIVER: "replay", + VITE_API_BASE_URL: "", + VITE_API_PORT: String(apiPort), + KEYRING_REPLAY_SPEED: process.env.KEYRING_REPLAY_SPEED ?? "20", + KEYRING_REPLAY_MAX_GAP_MS: process.env.KEYRING_REPLAY_MAX_GAP_MS ?? "5000", }, stdio: ["ignore", "pipe", "pipe"], }, @@ -126,14 +167,14 @@ async function main(): Promise { if (code && code !== 0) die(`UI exited with code ${code}`); }); - await waitHealth("http://127.0.0.1:5173/").catch((e) => die(String(e))); + await waitHealth(`http://127.0.0.1:${uiPort}/`).catch((e) => die(String(e))); console.log(` ┌────────────────────────────────────────────────────────────┐ │ Keyring demo ready (offline replay — no credentials) │ │ │ -│ UI: http://127.0.0.1:5173 │ -│ API: http://127.0.0.1:${process.env.PORT} │ +│ UI: http://127.0.0.1:${uiPort} │ +│ API: http://127.0.0.1:${apiPort} │ │ │ │ Start a scan for "Ada Lovelace" — uses fixtures/recordings│ │ Execution stays dry-run by default. Ctrl+C to stop. │ @@ -153,7 +194,8 @@ async function main(): Promise { } main().catch((err) => { - console.error(err); + const message = err instanceof Error ? err.message : String(err); + console.error(`[demo] ${message}`); for (const c of children) c.kill("SIGTERM"); process.exit(1); }); diff --git a/scripts/record-scan.ts b/scripts/record-scan.ts index b1aa7f3..1956557 100644 --- a/scripts/record-scan.ts +++ b/scripts/record-scan.ts @@ -12,14 +12,14 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { createDb } from "../packages/server/src/db/client.js"; +import { createDb, type Database } from "../packages/server/src/db/client.js"; import { runMigrations } from "../packages/server/src/db/migrate.js"; +import { openTestDatabase } from "../packages/server/src/db/test-db.js"; import { loadRecording, recordingsDir } from "../packages/server/src/recording/store.js"; import { createStandaloneApp } from "../packages/server/src/standalone.js"; const databaseUrl = - process.env.DATABASE_URL ?? - "postgresql://keyring:keyring@localhost:5432/keyring"; + process.env.DATABASE_URL ?? "postgresql://keyring:keyring@localhost:5432/keyring"; const replayOnly = process.argv.includes("--replay-only"); const person = process.env.KEYRING_RECORD_PERSON ?? "Ada Lovelace"; @@ -41,11 +41,7 @@ async function waitForScan( costs: unknown; recordingId: unknown; }; - if ( - body.status === "completed" || - body.status === "failed" || - body.status === "cost_capped" - ) { + if (body.status === "completed" || body.status === "failed" || body.status === "cost_capped") { return body; } await new Promise((r) => setTimeout(r, 100)); @@ -54,8 +50,24 @@ async function waitForScan( } async function main() { - await runMigrations(databaseUrl); - const { db, client } = createDb(databaseUrl); + const usePglite = + process.env.KEYRING_DEMO === "1" || + process.env.KEYRING_PGLITE === "1" || + !process.env.DATABASE_URL; + let db: Database["db"]; + let closeDb: () => Promise; + if (usePglite) { + const database = await openTestDatabase("recording-script"); + db = database.db; + closeDb = database.close; + } else { + await runMigrations(databaseUrl); + const postgresDb = createDb(databaseUrl); + db = postgresDb.db; + closeDb = async () => { + await postgresDb.client.end({ timeout: 5 }); + }; + } const app = createStandaloneApp({ db }); try { @@ -114,7 +126,7 @@ async function main() { console.log("OK — record + replay identical offline path."); } finally { await app.close(); - await client.end({ timeout: 5 }); + await closeDb(); } }