diff --git a/apps/web/src/components/ApprovalCardView.tsx b/apps/web/src/components/ApprovalCardView.tsx index 6e71261..c3606ea 100644 --- a/apps/web/src/components/ApprovalCardView.tsx +++ b/apps/web/src/components/ApprovalCardView.tsx @@ -5,6 +5,7 @@ import { actionVerb, confidenceLabel, formatWhen, + isUnregisteredAgent, principalLabel, staleness, systemLabel, @@ -38,6 +39,7 @@ export function ApprovalCardView({ const stale = staleness(card.grant.lastUsedAt); const pending = card.status === "pending"; const who = principalLabel(card); + const unregisteredAgent = isUnregisteredAgent(card); return (
-

- {card.attribution.reasoning} -

+
+ + {inferenceConclusion(card, who)} + ▸ show inference chain + +

+ {card.attribution.reasoning} +

+
- +
+ + + Top risk factor: {topRiskReason(card.risk.reasons)} + + ▸ show risk breakdown + + +
{pending && !actionsDisabled ? (
@@ -207,6 +230,29 @@ export function ApprovalCardView({ ); } +function inferenceConclusion(card: ApiCard, who: string): string { + const chain = card.attribution.reasoning.split("Inference chain:")[1]?.trim(); + const firstSignal = chain + ?.split(" → ")[0] + ?.replace(/^\([^)]+\)\s*/, "") + .split(":")[0] + ?.trim(); + return `${card.attribution.resolvedTo ? `Attributed to ${who}` : "Unattributed"} · ${ + card.attribution.confidence + } · ${firstSignal || "no matching inference"}`; +} + +function topRiskReason(reasons: string[]): string { + return ( + reasons.reduce((top, reason) => { + if (!top) return reason; + const score = Number(reason.match(/\(\+(\d+)\)$/)?.[1] ?? 0); + const topScore = Number(top.match(/\(\+(\d+)\)$/)?.[1] ?? 0); + return score > topScore ? reason : top; + }, null) ?? "no risk factors recorded" + ); +} + function ConfidenceBadge({ confidence }: { confidence: string }) { return ( diff --git a/apps/web/src/components/ApprovalQueue.tsx b/apps/web/src/components/ApprovalQueue.tsx index c9c230e..96bee77 100644 --- a/apps/web/src/components/ApprovalQueue.tsx +++ b/apps/web/src/components/ApprovalQueue.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { postDecision } from "../api/client.js"; import type { ApiCard } from "../api/types.js"; -import { countScanSummary, isUnattributed, scanSummaryText, sortCards } from "../lib/format.js"; +import { countScanSummary, queueSections, scanSummaryText } from "../lib/format.js"; import { ApprovalCardView } from "./ApprovalCardView.js"; import { ExecutePanel } from "./ExecutePanel.js"; import { HoldDialog } from "./HoldDialog.js"; @@ -46,9 +46,12 @@ export function ApprovalQueue({ guidedMode?: boolean; guidedCardId?: string | null; }) { - const ordered = useMemo(() => sortCards(cards), [cards]); - const unattributed = ordered.filter(isUnattributed); - const attributed = ordered.filter((c) => !isUnattributed(c)); + const { + unattributed, + agents, + attributed, + visualOrder: ordered, + } = useMemo(() => queueSections(cards), [cards]); const [focusIndex, setFocusIndex] = useState(0); const [checked, setChecked] = useState>(new Set()); @@ -335,6 +338,42 @@ export function ApprovalQueue({
) : null} + {agents.length > 0 ? ( +
+ +
+ {agents.map((card) => ( + + setFocusIndex( + Math.max( + 0, + focusable.findIndex((c) => c.id === card.id), + ), + ) + } + onToggleCheck={() => toggleCheck(card.id)} + onApprove={() => void decide(card, "approve")} + onHold={() => setHoldTarget(card)} + onReject={() => void decide(card, "reject")} + actionsDisabled={guidedMode} + guidedFocus={guidedCardId === card.id} + /> + ))} +
+
+ ) : null} + {attributed.length > 0 ? (
@@ -416,7 +455,7 @@ function SectionHeading({

{title}

diff --git a/apps/web/src/lib/format.test.ts b/apps/web/src/lib/format.test.ts index 9d35588..63caa76 100644 --- a/apps/web/src/lib/format.test.ts +++ b/apps/web/src/lib/format.test.ts @@ -4,6 +4,8 @@ import type { ApiCard } from "../api/types.js"; import { countScanSummary, isUnattributed, + isUnregisteredAgent, + queueSections, scanSummaryText, sortCards, staleness, @@ -91,6 +93,7 @@ describe("format helpers", () => { systems: 3, humanIdentities: 2, agentIdentities: 0, + unregisteredAgents: 0, unattributed: 1, overYearIdle: 1, irreversible: 1, @@ -135,6 +138,151 @@ describe("format helpers", () => { const counts = countScanSummary([card({ id: "human" }), agent], ["github", "agent_identity"]); expect(counts.humanIdentities).toBe(1); expect(counts.agentIdentities).toBe(1); + expect(counts.unregisteredAgents).toBe(0); expect(scanSummaryText(counts)).toContain("1 human identity and 1 AI agent identity"); }); + + it("does not label a declared agent as unregistered when attribution is unresolved", () => { + const declaredUnresolved = card({ + id: "declared-unresolved", + attribution: { + confidence: "speculative", + reasoning: "Reconciliation could not resolve this declared agent.", + }, + grant: { + ...card({ id: "declared-unresolved-base" }).grant, + principal: { + kind: "ai_agent", + agentName: "Declared Deployment Agent", + declarationStatus: "declared", + identifiers: [{ kind: "agent_id", value: "declared-1", source: "trueforge" }], + }, + }, + }); + + expect(isUnregisteredAgent(declaredUnresolved)).toBe(false); + expect(isUnattributed(declaredUnresolved)).toBe(true); + + const counts = countScanSummary([declaredUnresolved], ["agent_identity"]); + expect(counts.agentIdentities).toBe(1); + expect(counts.unregisteredAgents).toBe(0); + expect(scanSummaryText(counts)).not.toContain("unregistered agent"); + }); + + it("counts agents whose declarationStatus is unregistered", () => { + const rogue = card({ + id: "rogue", + attribution: { + confidence: "certain", + reasoning: "Agent discovered without a policy match.", + resolvedTo: "owner-1", + }, + grant: { + ...card({ id: "rogue-base" }).grant, + principal: { + kind: "ai_agent", + agentName: "Unregistered Deployment Agent", + declarationStatus: "unregistered", + identifiers: [{ kind: "agent_id", value: "rogue", source: "fixture" }], + }, + }, + }); + + expect(isUnregisteredAgent(rogue)).toBe(true); + + const counts = countScanSummary([rogue], ["agent_identity"]); + expect(counts.agentIdentities).toBe(1); + expect(counts.unregisteredAgents).toBe(1); + expect(scanSummaryText(counts)).toContain("1 unregistered agent."); + }); + + it("flattens queue sections in visual order for navigation", () => { + const unattributedHuman = card({ + id: "unattributed-human", + risk: { score: 10, reasons: [] }, + attribution: { confidence: "speculative", reasoning: "unknown bucket" }, + grant: { + ...card({ id: "unattributed-human-base" }).grant, + principal: { kind: "unknown", identifiers: [] }, + }, + }); + const agent = card({ + id: "agent", + risk: { score: 5, reasons: [] }, + attribution: { + confidence: "certain", + reasoning: "TrueForge registration", + resolvedTo: "agent-1", + }, + grant: { + ...card({ id: "agent-base" }).grant, + principal: { + kind: "ai_agent", + agentName: "Keyring", + declarationStatus: "declared", + identifiers: [{ kind: "agent_id", value: "keyring-self", source: "trueforge" }], + }, + }, + }); + const attributedHuman = card({ + id: "attributed-human", + risk: { score: 90, reasons: [] }, + }); + + const sorted = sortCards([attributedHuman, agent, unattributedHuman]).map((c) => c.id); + expect(sorted).toEqual(["unattributed-human", "attributed-human", "agent"]); + + const sections = queueSections([attributedHuman, agent, unattributedHuman]); + expect(sections.visualOrder.map((c) => c.id)).toEqual([ + "unattributed-human", + "agent", + "attributed-human", + ]); + expect(sections.agents).toHaveLength(1); + expect(sections.agents.map((c) => c.id)).toEqual(["agent"]); + }); + + it("counts one queue row per agent grant, not per unique identity", () => { + const first = card({ + id: "agent-grant-1", + attribution: { + confidence: "certain", + reasoning: "TrueForge registration", + resolvedTo: "agent-1", + }, + grant: { + ...card({ id: "agent-grant-1-base" }).grant, + principal: { + kind: "ai_agent", + agentName: "Keyring", + declarationStatus: "declared", + identifiers: [{ kind: "agent_id", value: "keyring-self", source: "trueforge" }], + }, + }, + }); + const second = card({ + id: "agent-grant-2", + attribution: { + confidence: "certain", + reasoning: "TrueForge registration", + resolvedTo: "agent-1", + }, + grant: { + ...card({ id: "agent-grant-2-base" }).grant, + system: "github", + principal: { + kind: "ai_agent", + agentName: "Keyring", + declarationStatus: "declared", + identifiers: [{ kind: "agent_id", value: "keyring-self", source: "trueforge" }], + }, + }, + }); + + const sections = queueSections([first, second]); + const counts = countScanSummary([first, second], ["agent_identity", "github"]); + + expect(counts.agentIdentities).toBe(1); + expect(sections.agents).toHaveLength(2); + }); }); diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index f2b1a2c..fa3e389 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -7,6 +7,7 @@ export interface ScanSummaryCounts { systems: number; humanIdentities: number; agentIdentities: number; + unregisteredAgents: number; unattributed: number; overYearIdle: number; irreversible: number; @@ -31,11 +32,21 @@ export function countScanSummary( ?.value ?? principalLabel(card), ), ).size; + const unregisteredAgents = new Set( + cards + .filter(isUnregisteredAgent) + .map( + (card) => + card.grant.principal.identifiers.find((identifier) => identifier.kind === "agent_id") + ?.value ?? principalLabel(card), + ), + ).size; return { grants: cards.length, systems: new Set(systemIds).size, humanIdentities, agentIdentities, + unregisteredAgents, unattributed: cards.filter(isUnattributed).length, overYearIdle: cards.filter((card) => staleness(card.grant.lastUsedAt, now).level === "critical") .length, @@ -47,6 +58,9 @@ export function scanSummaryText(counts: ScanSummaryCounts): string { const clauses = [ `${counts.grants} grant${counts.grants === 1 ? "" : "s"} across ${counts.systems} system${counts.systems === 1 ? "" : "s"}.`, `${counts.humanIdentities} human identit${counts.humanIdentities === 1 ? "y" : "ies"} and ${counts.agentIdentities} AI agent identit${counts.agentIdentities === 1 ? "y" : "ies"}.`, + counts.unregisteredAgents > 0 + ? `${counts.unregisteredAgents} unregistered agent${counts.unregisteredAgents === 1 ? "" : "s"}.` + : null, counts.unattributed > 0 ? `${counts.unattributed} we cannot attribute to anyone.` : null, counts.overYearIdle > 0 ? `${counts.overYearIdle} not used in over a year.` : null, counts.irreversible > 0 @@ -76,6 +90,13 @@ export function isUnattributed(card: ApiCard): boolean { return card.attribution.resolvedTo === undefined; } +export function isUnregisteredAgent(card: ApiCard): boolean { + return ( + card.grant.principal.kind === "ai_agent" && + card.grant.principal.declarationStatus === "unregistered" + ); +} + export function formatWhen(iso: string | null | undefined): string { if (!iso) return "unknown"; const d = new Date(iso); @@ -150,3 +171,28 @@ export function sortCards(cards: ApiCard[]): ApiCard[] { return b.risk.score - a.risk.score; }); } + +export interface QueueSections { + unattributed: ApiCard[]; + agents: ApiCard[]; + attributed: ApiCard[]; + visualOrder: ApiCard[]; +} + +/** Section order the queue renders: unattributed, agents, attributed. */ +export function queueSections(cards: ApiCard[]): QueueSections { + const ordered = sortCards(cards); + const unattributed = ordered.filter( + (card) => card.grant.principal.kind !== "ai_agent" && isUnattributed(card), + ); + const agents = ordered.filter((card) => card.grant.principal.kind === "ai_agent"); + const attributed = ordered.filter( + (card) => card.grant.principal.kind !== "ai_agent" && !isUnattributed(card), + ); + return { + unattributed, + agents, + attributed, + visualOrder: [...unattributed, ...agents, ...attributed], + }; +}