From 5e524f380f63e471df287b8004cb056def29c64d Mon Sep 17 00:00:00 2001 From: GautamTalksDev Date: Sat, 29 Aug 2026 19:57:18 -0400 Subject: [PATCH 1/2] feat(demo): show parallel multi-system fan-out Co-authored-by: Cursor --- apps/web/src/api/client.ts | 17 +- apps/web/src/api/types.ts | 2 +- apps/web/src/components/AgentActivity.tsx | 36 +- apps/web/src/hooks/useScanSession.ts | 105 ++-- fixtures/recordings/ada-lovelace.json | 509 ++++++++++++------ packages/server/src/agent/scan.ts | 27 +- packages/server/src/api/progress.ts | 17 +- .../recording/recording.integration.test.ts | 21 +- packages/server/src/services/scan-runner.ts | 277 +++++----- scripts/record-scan.ts | 34 +- 10 files changed, 625 insertions(+), 420 deletions(-) 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..327c995 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" | "done"; found: number; startedAt: string; } diff --git a/apps/web/src/components/AgentActivity.tsx b/apps/web/src/components/AgentActivity.tsx index 5c05cd7..3c2ff67 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,23 @@ export function AgentActivity({
    - {s.status === "running" ? "Scanning" : "Done"} + {s.status === "queued" + ? "Queued" + : s.status === "scanning" + ? "Scanning" + : s.status === "reconciling" + ? "Reconciling" + : "Done"}
    - {s.found} + {s.found} found
  • @@ -193,8 +193,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.

{s.found} found diff --git a/apps/web/src/components/ApprovalQueue.tsx b/apps/web/src/components/ApprovalQueue.tsx index 7f11538..de61238 100644 --- a/apps/web/src/components/ApprovalQueue.tsx +++ b/apps/web/src/components/ApprovalQueue.tsx @@ -69,17 +69,10 @@ export function ApprovalQueue({ }); async function bulk(decision: "approve" | "reject") { - const targets = ordered.filter( - (c) => checked.has(c.id) && c.status === "pending", - ); + const targets = ordered.filter((c) => checked.has(c.id) && c.status === "pending"); const protectedSkipped = - decision === "approve" - ? targets.filter((c) => c.protected === true) - : []; - const runnable = - decision === "approve" - ? targets.filter((c) => c.protected !== true) - : targets; + decision === "approve" ? targets.filter((c) => c.protected === true) : []; + const runnable = decision === "approve" ? targets.filter((c) => c.protected !== true) : targets; for (const card of runnable) { await decide(card, decision, undefined, decision === "approve"); } @@ -127,6 +120,7 @@ export function ApprovalQueue({ const approved = ordered.filter((c) => c.status === "approved"); const pendingCount = ordered.filter((c) => c.status === "pending").length; + const heldCount = ordered.filter((c) => c.status === "held").length; return (
@@ -136,14 +130,12 @@ export function ApprovalQueue({
Approval queue
-

- Review access -

+

Review access

- {pendingCount} pending · {approved.length} approved ·{" "} - j/k move ·{" "} - a/h/r decide ·{" "} - x select + {pendingCount} pending · {heldCount} held · {approved.length} approved ·{" "} + {ordered.length} total · j/k move ·{" "} + a/h/r decide · x{" "} + select

@@ -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 98ef21f..7ca2e4b 100644 --- a/apps/web/src/hooks/useScanSession.ts +++ b/apps/web/src/hooks/useScanSession.ts @@ -39,7 +39,7 @@ type Action = } | { type: "card_updated"; card: ApiCard }; -const emptyActivity = (): AgentActivityState => ({ +export const emptyActivity = (): AgentActivityState => ({ scanId: null, status: "idle", person: null, @@ -162,7 +162,10 @@ function reduce(state: State, action: Action): State { } } -function applyEvent(activity: AgentActivityState, event: ScanProgressEvent): AgentActivityState { +export function applyEvent( + activity: AgentActivityState, + event: ScanProgressEvent, +): AgentActivityState { const at = event.at ?? new Date().toISOString(); switch (event.type) { case "scan.started": @@ -249,11 +252,11 @@ function applyEvent(activity: AgentActivityState, event: ScanProgressEvent): Age 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, }; @@ -280,7 +283,10 @@ function applyEvent(activity: AgentActivityState, event: ScanProgressEvent): Age subagents: Object.fromEntries( Object.entries(activity.subagents).map(([systemId, subagent]) => [ systemId, - { ...subagent, status: "reconciling" as const }, + { + ...subagent, + status: subagent.status === "failed" ? "failed" : "reconciling", + }, ]), ), sandbox: { @@ -300,7 +306,10 @@ function applyEvent(activity: AgentActivityState, event: ScanProgressEvent): Age subagents: Object.fromEntries( Object.entries(activity.subagents).map(([systemId, subagent]) => [ systemId, - { ...subagent, status: "done" as const }, + { + ...subagent, + status: subagent.status === "failed" ? "failed" : "done", + }, ]), ), sandbox: { 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 8e7dd66..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-29T23:54:32.740Z", + "recordedAt": "2026-08-30T04:08:07.420Z", "person": "Ada Lovelace", "scope": null, "driver": "record", @@ -12,7 +12,7 @@ "interactions": [ { "kind": "model", - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.970Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -23,7 +23,7 @@ }, { "kind": "model", - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -34,7 +34,7 @@ }, { "kind": "model", - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -45,7 +45,7 @@ }, { "kind": "model", - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -56,7 +56,7 @@ }, { "kind": "model", - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -67,7 +67,7 @@ }, { "kind": "tool", - "at": "2026-08-29T23:54:32.388Z", + "at": "2026-08-30T04:08:07.053Z", "tool": "inventory_system", "arguments": { "system_id": "aws" @@ -76,7 +76,7 @@ }, { "kind": "tool", - "at": "2026-08-29T23:54:32.467Z", + "at": "2026-08-30T04:08:07.133Z", "tool": "inventory_system", "arguments": { "system_id": "notion" @@ -85,7 +85,7 @@ }, { "kind": "tool", - "at": "2026-08-29T23:54:32.546Z", + "at": "2026-08-30T04:08:07.213Z", "tool": "inventory_system", "arguments": { "system_id": "slack" @@ -94,7 +94,7 @@ }, { "kind": "tool", - "at": "2026-08-29T23:54:32.626Z", + "at": "2026-08-30T04:08:07.292Z", "tool": "inventory_system", "arguments": { "system_id": "github" @@ -103,7 +103,7 @@ }, { "kind": "tool", - "at": "2026-08-29T23:54:32.706Z", + "at": "2026-08-30T04:08:07.372Z", "tool": "inventory_system", "arguments": { "system_id": "google_workspace" @@ -112,7 +112,7 @@ }, { "kind": "model", - "at": "2026-08-29T23:54:32.706Z", + "at": "2026-08-30T04:08:07.372Z", "role": "reasoning", "model": "openai/gpt-4o", "inputTokens": 2500, @@ -123,7 +123,7 @@ }, { "kind": "tool", - "at": "2026-08-29T23:54:32.716Z", + "at": "2026-08-30T04:08:07.385Z", "tool": "run_identity_reconciliation", "arguments": { "grant_ids": [ @@ -150,50 +150,50 @@ "events": [ { "type": "subagent.queued", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "aws", "displayName": "AWS", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.970Z" }, { "type": "subagent.queued", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "github", "displayName": "GitHub", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.970Z" }, { "type": "subagent.queued", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "displayName": "Google Workspace", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.970Z" }, { "type": "subagent.queued", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "notion", "displayName": "Notion", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.970Z" }, { "type": "subagent.queued", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "slack", "displayName": "Slack", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.970Z" }, { "type": "subagent.started", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "aws", "displayName": "AWS", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.970Z" }, { "type": "cost.update", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", - "at": "2026-08-29T23:54:32.305Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.970Z", "inputTokens": 800, "outputTokens": 200, "costUsd": 0.00024, @@ -202,15 +202,15 @@ }, { "type": "subagent.started", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "github", "displayName": "GitHub", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.970Z" }, { "type": "cost.update", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", - "at": "2026-08-29T23:54:32.305Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.971Z", "inputTokens": 1600, "outputTokens": 400, "costUsd": 0.00048, @@ -219,15 +219,15 @@ }, { "type": "subagent.started", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "displayName": "Google Workspace", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.971Z" }, { "type": "cost.update", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", - "at": "2026-08-29T23:54:32.305Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.971Z", "inputTokens": 2400, "outputTokens": 600, "costUsd": 0.00072, @@ -236,15 +236,15 @@ }, { "type": "subagent.started", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "notion", "displayName": "Notion", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.971Z" }, { "type": "cost.update", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", - "at": "2026-08-29T23:54:32.305Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.971Z", "inputTokens": 3200, "outputTokens": 800, "costUsd": 0.00096, @@ -253,15 +253,15 @@ }, { "type": "subagent.started", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "slack", "displayName": "Slack", - "at": "2026-08-29T23:54:32.305Z" + "at": "2026-08-30T04:08:06.971Z" }, { "type": "cost.update", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", - "at": "2026-08-29T23:54:32.305Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:06.971Z", "inputTokens": 4000, "outputTokens": 1000, "costUsd": 0.0012, @@ -270,153 +270,153 @@ }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "aws", "found": 1, - "at": "2026-08-29T23:54:32.387Z" + "at": "2026-08-30T04:08:07.053Z" }, { "type": "subagent.done", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "aws", "found": 1, - "at": "2026-08-29T23:54:32.388Z" + "at": "2026-08-30T04:08:07.053Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "notion", "found": 1, - "at": "2026-08-29T23:54:32.467Z" + "at": "2026-08-30T04:08:07.132Z" }, { "type": "subagent.done", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "notion", "found": 1, - "at": "2026-08-29T23:54:32.467Z" + "at": "2026-08-30T04:08:07.133Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "slack", "found": 1, - "at": "2026-08-29T23:54:32.546Z" + "at": "2026-08-30T04:08:07.213Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "slack", "found": 2, - "at": "2026-08-29T23:54:32.546Z" + "at": "2026-08-30T04:08:07.213Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "slack", "found": 3, - "at": "2026-08-29T23:54:32.546Z" + "at": "2026-08-30T04:08:07.213Z" }, { "type": "subagent.done", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "slack", "found": 3, - "at": "2026-08-29T23:54:32.546Z" + "at": "2026-08-30T04:08:07.213Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "github", "found": 1, - "at": "2026-08-29T23:54:32.626Z" + "at": "2026-08-30T04:08:07.292Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "github", "found": 2, - "at": "2026-08-29T23:54:32.626Z" + "at": "2026-08-30T04:08:07.292Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "github", "found": 3, - "at": "2026-08-29T23:54:32.626Z" + "at": "2026-08-30T04:08:07.292Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "github", "found": 4, - "at": "2026-08-29T23:54:32.626Z" + "at": "2026-08-30T04:08:07.292Z" }, { "type": "subagent.done", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "github", "found": 4, - "at": "2026-08-29T23:54:32.626Z" + "at": "2026-08-30T04:08:07.292Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "found": 1, - "at": "2026-08-29T23:54:32.706Z" + "at": "2026-08-30T04:08:07.372Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "found": 2, - "at": "2026-08-29T23:54:32.706Z" + "at": "2026-08-30T04:08:07.372Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "found": 3, - "at": "2026-08-29T23:54:32.706Z" + "at": "2026-08-30T04:08:07.372Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "found": 4, - "at": "2026-08-29T23:54:32.706Z" + "at": "2026-08-30T04:08:07.372Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "found": 5, - "at": "2026-08-29T23:54:32.706Z" + "at": "2026-08-30T04:08:07.372Z" }, { "type": "subagent.progress", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "found": 6, - "at": "2026-08-29T23:54:32.706Z" + "at": "2026-08-30T04:08:07.372Z" }, { "type": "subagent.done", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "systemId": "google_workspace", "found": 6, - "at": "2026-08-29T23:54:32.706Z" + "at": "2026-08-30T04:08:07.372Z" }, { "type": "reconcile.started", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", - "at": "2026-08-29T23:54:32.706Z" + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:07.372Z" }, { "type": "cost.update", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", - "at": "2026-08-29T23:54:32.706Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:07.372Z", "inputTokens": 6500, "outputTokens": 2200, "costUsd": 0.01945, @@ -425,8 +425,8 @@ }, { "type": "scan.diff", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", - "at": "2026-08-29T23:54:32.721Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:07.391Z", "baselineScanId": null, "added": 15, "removed": 0, @@ -436,16 +436,16 @@ }, { "type": "reconcile.done", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", - "at": "2026-08-29T23:54:32.721Z", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", + "at": "2026-08-30T04:08:07.391Z", "clusters": 4, "unknown": 1 }, { "type": "cards.persisted", - "scanId": "8e390a91-b62a-4feb-87c3-94cf0eba45be", + "scanId": "9f560f0e-3203-422c-bb02-be79d408541e", "cardCount": 7, - "at": "2026-08-29T23:54:32.740Z" + "at": "2026-08-30T04:08:07.419Z" } ], "costs": { @@ -456,7 +456,7 @@ "capped": false, "lines": [ { - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.970Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -465,7 +465,7 @@ "note": "inventory_system:aws" }, { - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -474,7 +474,7 @@ "note": "inventory_system:github" }, { - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -483,7 +483,7 @@ "note": "inventory_system:google_workspace" }, { - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -492,7 +492,7 @@ "note": "inventory_system:notion" }, { - "at": "2026-08-29T23:54:32.305Z", + "at": "2026-08-30T04:08:06.971Z", "role": "inventory", "model": "openai/gpt-4o-mini", "inputTokens": 800, @@ -501,7 +501,7 @@ "note": "inventory_system:slack" }, { - "at": "2026-08-29T23:54:32.706Z", + "at": "2026-08-30T04:08:07.372Z", "role": "reasoning", "model": "openai/gpt-4o", "inputTokens": 2500, @@ -544,18 +544,18 @@ }, "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": { "resolvedTo": "816b6fbf714996a70e3b1a74b5c1516df514d8f6494599822240d74cddee309f", "confidence": "probable", - "reasoning": "Attributed 6 grant(s) to GitHub Actions — payments CDN publish. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain → (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" + "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" }, @@ -568,18 +568,18 @@ }, "irreversible": false, "risk": { - "score": 75, + "score": 80, "reasons": [ "capability admin (+55)", - "last used 196 days ago — stale (≥90d) (+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": { - "resolvedTo": "816b6fbf714996a70e3b1a74b5c1516df514d8f6494599822240d74cddee309f", + "resolvedTo": "346d6f9c2dd4bfc951e5b142ade3a52f28d9920d6faaabbf5dddab4063acea43", "confidence": "probable", - "reasoning": "Attributed 6 grant(s) to GitHub Actions — payments CDN publish. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain → (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" + "reasoning": "Attributed 5 grant(s) to Ada Lovelace. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain" }, "grantId": "196e91a1f6c5221a50c3809bfe28640a6616be5a420e85f0075a256e02069d6e" }, @@ -592,18 +592,18 @@ }, "irreversible": false, "risk": { - "score": 40, + "score": 45, "reasons": [ "capability read (+10)", - "last used 546 days ago — highly stale (≥365d) (+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": { - "resolvedTo": "816b6fbf714996a70e3b1a74b5c1516df514d8f6494599822240d74cddee309f", + "resolvedTo": "346d6f9c2dd4bfc951e5b142ade3a52f28d9920d6faaabbf5dddab4063acea43", "confidence": "probable", - "reasoning": "Attributed 6 grant(s) to GitHub Actions — payments CDN publish. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain → (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" + "reasoning": "Attributed 5 grant(s) to Ada Lovelace. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain" }, "grantId": "c42b99954ceb07a3d653032e168ed3d7ea390c3544028b4154b2349c4a70f9e6" }, @@ -616,18 +616,18 @@ }, "irreversible": false, "risk": { - "score": 40, + "score": 45, "reasons": [ "capability write (+30)", - "last used 45 days ago — cooling (≥14d) (+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": { - "resolvedTo": "816b6fbf714996a70e3b1a74b5c1516df514d8f6494599822240d74cddee309f", + "resolvedTo": "346d6f9c2dd4bfc951e5b142ade3a52f28d9920d6faaabbf5dddab4063acea43", "confidence": "probable", - "reasoning": "Attributed 6 grant(s) to GitHub Actions — payments CDN publish. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain → (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" + "reasoning": "Attributed 5 grant(s) to Ada Lovelace. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain" }, "grantId": "efe2612c13923b03d4641346ecac260168123aeb6c499428a40b613e7362889c" }, @@ -640,18 +640,18 @@ }, "irreversible": false, "risk": { - "score": 30, + "score": 35, "reasons": [ "capability write (+30)", - "last used 9 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": { - "resolvedTo": "816b6fbf714996a70e3b1a74b5c1516df514d8f6494599822240d74cddee309f", + "resolvedTo": "346d6f9c2dd4bfc951e5b142ade3a52f28d9920d6faaabbf5dddab4063acea43", "confidence": "probable", - "reasoning": "Attributed 6 grant(s) to GitHub Actions — payments CDN publish. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain → (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" + "reasoning": "Attributed 5 grant(s) to Ada Lovelace. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain" }, "grantId": "474b8dc484730147e9016bd5176f2e7bd918c0c614a1f58d96f58cda5ddfb344" }, @@ -664,18 +664,18 @@ }, "irreversible": false, "risk": { - "score": 30, + "score": 35, "reasons": [ "capability write (+30)", - "last used 7 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": { - "resolvedTo": "816b6fbf714996a70e3b1a74b5c1516df514d8f6494599822240d74cddee309f", + "resolvedTo": "346d6f9c2dd4bfc951e5b142ade3a52f28d9920d6faaabbf5dddab4063acea43", "confidence": "probable", - "reasoning": "Attributed 6 grant(s) to GitHub Actions — payments CDN publish. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain → (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" + "reasoning": "Attributed 5 grant(s) to Ada Lovelace. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain" }, "grantId": "f7029468068e6bbca30d6c833c78613e2e856c2e3d85487662e5f28e89848885" } @@ -683,16 +683,11 @@ "reconciliation": { "clusters": [ { - "id": "c845f1f37800425e0c8fb36b87ac640786c281f62e300bc9247bf50a85bded42", + "id": "5a438941cddfb6b451ae4130feb3bf46a43270e588e0ba7faf8aa8195ec4bdbe", "kind": "human", "displayName": "Ada Lovelace", "personId": "346d6f9c2dd4bfc951e5b142ade3a52f28d9920d6faaabbf5dddab4063acea43", "identifiers": [ - { - "kind": "key_id", - "value": "AKIA_KEYRING_CI_ORPHAN_LOOKALIKE", - "source": "github_deploy_keys" - }, { "kind": "personal_email", "value": "ada.numbers.personal@gmail.com", @@ -719,11 +714,10 @@ "f7029468068e6bbca30d6c833c78613e2e856c2e3d85487662e5f28e89848885", "c42b99954ceb07a3d653032e168ed3d7ea390c3544028b4154b2349c4a70f9e6", "efe2612c13923b03d4641346ecac260168123aeb6c499428a40b613e7362889c", - "196e91a1f6c5221a50c3809bfe28640a6616be5a420e85f0075a256e02069d6e", - "e9b67d09a9f732d5b8bf904cc337df152f82d4c310af44cb13523c0a95d13e86" + "196e91a1f6c5221a50c3809bfe28640a6616be5a420e85f0075a256e02069d6e" ], "confidence": "probable", - "reasoning": "Attributed 6 grant(s) to Ada Lovelace. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain → (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" + "reasoning": "Attributed 5 grant(s) to Ada Lovelace. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain" }, { "id": "1399fb65abb03312666c8f4286fefe7509f1439504ad4eae6c15f784d620e11d", @@ -757,7 +751,7 @@ "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": "50addc4cef1c832d275bb808fb857449c9be805dcd05558c16de80fed256e0a5", + "id": "f0e94a3325940b70c72a5884b4aef6ce8dd30d6ba1ab2731fe8e0040ff352922", "kind": "service_account", "displayName": "GitHub Actions — payments CDN publish", "personId": "816b6fbf714996a70e3b1a74b5c1516df514d8f6494599822240d74cddee309f", @@ -766,38 +760,11 @@ "kind": "key_id", "value": "AKIA_KEYRING_CI_ORPHAN_LOOKALIKE", "source": "github_deploy_keys" - }, - { - "kind": "personal_email", - "value": "ada.numbers.personal@gmail.com", - "source": "google_workspace" - }, - { - "kind": "username", - "value": "ada.l", - "source": "slack" - }, - { - "kind": "username", - "value": "analyticalengine", - "source": "github" - }, - { - "kind": "work_email", - "value": "ada@keyring-test.example", - "source": "slack" } ], - "grantIds": [ - "474b8dc484730147e9016bd5176f2e7bd918c0c614a1f58d96f58cda5ddfb344", - "f7029468068e6bbca30d6c833c78613e2e856c2e3d85487662e5f28e89848885", - "c42b99954ceb07a3d653032e168ed3d7ea390c3544028b4154b2349c4a70f9e6", - "efe2612c13923b03d4641346ecac260168123aeb6c499428a40b613e7362889c", - "196e91a1f6c5221a50c3809bfe28640a6616be5a420e85f0075a256e02069d6e", - "e9b67d09a9f732d5b8bf904cc337df152f82d4c310af44cb13523c0a95d13e86" - ], + "grantIds": ["e9b67d09a9f732d5b8bf904cc337df152f82d4c310af44cb13523c0a95d13e86"], "confidence": "probable", - "reasoning": "Attributed 6 grant(s) to GitHub Actions — payments CDN publish. Inference chain: (certain) exact work-email match: Both grants carry work email ada@keyring-test.example → (certain) exact work-email match: Grant work email ada@keyring-test.example matches directory for Ada Lovelace → (certain) personal email present on the org directory record: Personal email ada.numbers.personal@gmail.com is listed on the directory record for Ada Lovelace → (certain) username listed on the org directory record: Username analyticalengine is listed on the directory record for Ada Lovelace → (probable) username similarity to directory display name (probable only): Username \"ada.l\" resembles directory name \"Ada Lovelace\" (score 1.00); never treated as certain → (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" + "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", 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/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/services/scan-runner.ts b/packages/server/src/services/scan-runner.ts index 1cd6567..0b8c2d2 100644 --- a/packages/server/src/services/scan-runner.ts +++ b/packages/server/src/services/scan-runner.ts @@ -123,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") { @@ -132,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, { @@ -146,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, @@ -157,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, { @@ -245,12 +248,15 @@ async function runFixtureFanOutAndPersist( 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(); if (opts.emitSubagents) { for (const system of systems) { @@ -268,134 +274,145 @@ async function runFixtureFanOutAndPersist( } const grantsBySystem = new Map(); - await Promise.all( - systems.map(async (system) => { - if (opts.emitSubagents) { - publish( - { - type: "subagent.started", - scanId, - systemId: system.id, - displayName: system.displayName, - at: new Date().toISOString(), - }, - recorder, - ); - log.info({ systemId: system.id }, "subagent started"); - } + const inventoryTasks = systems.map(async (system) => { + if (opts.emitSubagents) { + publish( + { + type: "subagent.started", + scanId, + systemId: system.id, + displayName: system.displayName, + at: new Date().toISOString(), + }, + recorder, + ); + log.info({ systemId: system.id }, "subagent started"); + } + try { + // Mechanical inventory summarisation — cheap model role + const invModel = modelForRole("inventory"); + const invIn = 800; + const invOut = 200; try { - // Mechanical inventory summarisation — cheap model role - const invModel = modelForRole("inventory"); - const invIn = 800; - const invOut = 200; - try { - const snap = ledger.recordModelCall({ - role: "inventory", - model: invModel, - inputTokens: invIn, - outputTokens: invOut, - note: `inventory_system:${system.id}`, - }); - publishCost(scanId, snap, recorder); - recorder?.addModel({ - at: new Date().toISOString(), - role: "inventory", - model: invModel, - inputTokens: invIn, - outputTokens: invOut, - costUsd: snap.lines.at(-1)?.costUsd ?? 0, - inputSummary: `Summarise inventory for ${system.id}`, - outputSummary: `compact grants for ${system.id}`, - }); - } catch (err) { - if (err instanceof CostCapExceededError) { - publishCost(scanId, ledger.snapshot(), recorder); - throw err; - } + const snap = ledger.recordModelCall({ + role: "inventory", + model: invModel, + inputTokens: invIn, + outputTokens: invOut, + note: `inventory_system:${system.id}`, + }); + publishCost(scanId, snap, recorder); + recorder?.addModel({ + at: new Date().toISOString(), + role: "inventory", + model: invModel, + inputTokens: invIn, + outputTokens: invOut, + costUsd: snap.lines.at(-1)?.costUsd ?? 0, + inputSummary: `Summarise inventory for ${system.id}`, + outputSummary: `compact grants for ${system.id}`, + }); + } catch (err) { + if (err instanceof CostCapExceededError) { + publishCost(scanId, ledger.snapshot(), recorder); throw err; } + throw err; + } - if (opts.emitSubagents && opts.record) { - await pause(DEMO_SYSTEM_STAGGER_MS[system.id] ?? 200); - } - const result = await inventorySystem(system.id, { - delayMsPerGrant: opts.emitSubagents ? delay : 0, - onGrant: opts.emitSubagents - ? (found) => { - publish( - { - type: "subagent.progress", - scanId, - systemId: system.id, - found, - at: new Date().toISOString(), - }, - recorder, - ); - } - : undefined, - }); - grantsBySystem.set( - system.id, - result.grants.map((grant) => grant.id), - ); + 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, + }); + grantsBySystem.set( + system.id, + result.grants.map((grant) => grant.id), + ); + successfulSystems.add(system.id); - recorder?.addTool({ - at: new Date().toISOString(), - tool: "inventory_system", - arguments: { system_id: system.id }, - resultSummary: `${result.count} grants`, - }); + recorder?.addTool({ + at: new Date().toISOString(), + tool: "inventory_system", + arguments: { system_id: system.id }, + resultSummary: `${result.count} grants`, + }); - if (opts.emitSubagents) { - publish( - { - type: "subagent.done", - scanId, - systemId: system.id, - found: result.count, - at: new Date().toISOString(), - }, - recorder, - ); - log.info({ systemId: system.id, found: result.count }, "subagent done"); - } - } catch (err) { - if (err instanceof CostCapExceededError) throw err; - const classified = classifyProductError(err); - failedSystems.push({ - systemId: system.id, - error: classified.message, - errorKind: classified.kind, - }); + if (opts.emitSubagents) { publish( { - type: "subagent.failed", + type: "subagent.done", scanId, systemId: system.id, - displayName: system.displayName, + found: result.count, at: new Date().toISOString(), - error: classified.message, - errorKind: classified.kind, - recovery: classified.recovery, }, recorder, ); - log.warn( - { systemId: system.id, err, errorKind: classified.kind }, - "subagent failed — continuing partial scan", - ); + log.info({ systemId: system.id, found: result.count }, "subagent done"); } - }), - ); + } catch (err) { + if (err instanceof CostCapExceededError) throw err; + if (fanOutController.signal.aborted) throw err; + const classified = classifyProductError(err); + failedSystemsById.set(system.id, { + systemId: system.id, + error: classified.message, + errorKind: classified.kind, + }); + publish( + { + type: "subagent.failed", + scanId, + systemId: system.id, + displayName: system.displayName, + at: new Date().toISOString(), + error: classified.message, + errorKind: classified.kind, + recovery: classified.recovery, + }, + recorder, + ); + log.warn( + { systemId: system.id, err, errorKind: classified.kind }, + "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})`); Object.assign(err, { status: first.errorKind === "rate_limit" ? 429 : 401 }); @@ -644,8 +661,14 @@ 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)", ); @@ -655,7 +678,7 @@ async function runReplay( 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, 8), 180)); + await pause(Math.min(Math.max(recordedGap * replaySpeed, 8), replayMaxGapMs)); } const rewritten = { ...event, scanId } as ScanProgressEvent; scanBus.publish(rewritten); @@ -881,8 +904,27 @@ async function driveTrueForgeAgent( } } -function pause(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); +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: { 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); });