Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 61 additions & 15 deletions apps/web/src/components/ApprovalCardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
actionVerb,
confidenceLabel,
formatWhen,
isUnregisteredAgent,
principalLabel,
staleness,
systemLabel,
Expand Down Expand Up @@ -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 (
<article
Expand All @@ -52,7 +54,7 @@ export function ApprovalCardView({
: "border-[var(--color-line)]"
} ${selected ? "bg-[var(--color-surface-2)]" : ""} ${
guidedFocus ? "ring-2 ring-[var(--color-ink)] ring-offset-2" : ""
}`}
} ${unregisteredAgent ? "border-[var(--color-irrev)] bg-[var(--color-irrev-soft)]" : ""}`}
>
<div className="flex items-start gap-3 px-3.5 py-3">
<label className="mt-0.5 shrink-0 cursor-pointer" onClick={(e) => e.stopPropagation()}>
Expand All @@ -75,14 +77,21 @@ export function ApprovalCardView({
{card.grant.principal.kind === "ai_agent" ? (
<span
className={`border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] ${
card.grant.principal.declarationStatus === "unregistered"
unregisteredAgent
? "border-[var(--color-irrev)] bg-[var(--color-irrev-soft)] text-[var(--color-irrev)]"
: "border-[var(--color-hold)] text-[var(--color-hold)]"
}`}
>
{card.grant.principal.declarationStatus === "unregistered"
? "Unregistered agent"
: "AI agent"}
{unregisteredAgent ? "Unregistered agent" : "AI agent"}
</span>
) : null}
{card.grant.principal.kind === "ai_agent" &&
(card.grant.principal.agentName === "Keyring" ||
card.grant.evidence.some(
(evidence) => evidence.source === "keyring:self-inventory",
)) ? (
<span className="border border-[var(--color-ink)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.06em] text-[var(--color-ink)]">
Self-inventory
</span>
) : null}
{card.irreversible ? (
Expand Down Expand Up @@ -176,17 +185,31 @@ export function ApprovalCardView({
/>
</div>

<p className="mt-2.5 text-[12px] leading-relaxed text-[var(--color-ink-2)]">
{card.attribution.reasoning}
</p>
<details className="mt-2.5 border-t border-[var(--color-line)] pt-2 text-[12px]">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-[var(--color-ink-2)] [&::-webkit-details-marker]:hidden">
<span className="min-w-0 truncate">{inferenceConclusion(card, who)}</span>
<span className="shrink-0 text-[var(--color-faint)]">▸ show inference chain</span>
</summary>
<p className="mt-2 leading-relaxed text-[var(--color-mute)]">
{card.attribution.reasoning}
</p>
</details>

<ul className="mt-2 space-y-0.5 border-t border-[var(--color-line)] pt-2">
{card.risk.reasons.map((r) => (
<li key={r} className="font-mono text-[11px] leading-snug text-[var(--color-mute)]">
{r}
</li>
))}
</ul>
<details className="mt-2 border-t border-[var(--color-line)] pt-2 text-[12px]">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-[var(--color-mute)] [&::-webkit-details-marker]:hidden">
<span className="min-w-0 truncate">
Top risk factor: {topRiskReason(card.risk.reasons)}
</span>
<span className="shrink-0 text-[var(--color-faint)]">▸ show risk breakdown</span>
</summary>
<ul className="mt-2 space-y-0.5">
{card.risk.reasons.map((r) => (
<li key={r} className="font-mono text-[11px] leading-snug text-[var(--color-mute)]">
{r}
</li>
))}
</ul>
</details>

{pending && !actionsDisabled ? (
<div className="mt-3 flex flex-wrap gap-1.5">
Expand All @@ -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<string | null>((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 (
<span className="border border-[var(--color-line-strong)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.06em] text-[var(--color-mute)]">
Expand Down
51 changes: 45 additions & 6 deletions apps/web/src/components/ApprovalQueue.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Set<string>>(new Set());
Expand Down Expand Up @@ -335,6 +338,42 @@ export function ApprovalQueue({
</div>
) : null}

{agents.length > 0 ? (
<div>
<SectionHeading
title="AI agents"
subtitle="Non-human identities found in connected systems, including Keyring self-inventory."
tone="agent"
count={agents.length}
/>
<div className="mt-3 space-y-2" role="list">
{agents.map((card) => (
<ApprovalCardView
key={card.id}
card={card}
selected={checked.has(card.id)}
focused={focused?.id === card.id}
checked={checked.has(card.id)}
onFocus={() =>
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}
/>
))}
</div>
</div>
) : null}

{attributed.length > 0 ? (
<div>
<SectionHeading
Expand Down Expand Up @@ -408,15 +447,15 @@ function SectionHeading({
title: string;
subtitle: string;
count: number;
tone?: "warn";
tone?: "warn" | "agent";
}) {
return (
<div className="flex items-end justify-between gap-4 border-b border-[var(--color-line)] pb-2">
<div>
<h3
className={`text-[13px] font-semibold tracking-tight ${
tone === "warn" ? "text-[var(--color-irrev)]" : "text-[var(--color-ink)]"
}`}
} ${tone === "agent" ? "text-[var(--color-hold)]" : ""}`}
>
{title}
</h3>
Expand Down
148 changes: 148 additions & 0 deletions apps/web/src/lib/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { ApiCard } from "../api/types.js";
import {
countScanSummary,
isUnattributed,
isUnregisteredAgent,
queueSections,
scanSummaryText,
sortCards,
staleness,
Expand Down Expand Up @@ -91,6 +93,7 @@ describe("format helpers", () => {
systems: 3,
humanIdentities: 2,
agentIdentities: 0,
unregisteredAgents: 0,
unattributed: 1,
overYearIdle: 1,
irreversible: 1,
Expand Down Expand Up @@ -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);
});
});
Loading
Loading