diff --git a/docs/internals/secret-scanning.md b/docs/internals/secret-scanning.md index ba29206c..b0579ea1 100644 --- a/docs/internals/secret-scanning.md +++ b/docs/internals/secret-scanning.md @@ -92,8 +92,28 @@ which of a user's sessions contained a credential. ## Surfacing -- **Session detail**: a warning banner listing each finding (kind, hint, prompt vs response) with a - Dismiss action; a dismissed banner collapses to a muted line with "Show again". +- **Session detail**: a warning banner listing each finding (interaction, kind, hint, prompt vs + response) with a Dismiss action; a dismissed banner collapses to a muted line with "Show again". + Each listed finding is a link into the Timeline at the interaction it came from (#336). +- **Timeline**: the turn a credential appeared in carries a shield marker naming the category and + the redacted hint, on the prompt or the response half it matched (`SessionTimeline` takes the + session's findings as a prop — `/api/session/:id` already returns them, so there's no new + endpoint). Three things worth knowing about the marker: + - It doesn't need retained text. With `retainText` off the timeline shows no prompt or response + body, and the marker still says which turn and which kind of credential — which is the part the + user acts on. + - It marks the **first** place a credential appeared, not every place. The scanner dedupes across + the whole session, so a key pasted once and echoed in three later replies is one finding at its + first location. + - It ignores dismissal. Dismissing silences the banner ("I know about this"); the marker is an + annotation on a turn the user navigated to on purpose, so it stays. + + The banner and the timeline are two separate fetches (`/api/session/:id` and + `…/interactions`), so a re-index under an open tab can leave a finding pointing at an interaction + the timeline no longer has. In the store the two can't disagree — findings and the interaction + spine are written from the same array in one transaction — so this is a client-side staleness + window only. A link that lands nowhere scrolls nowhere and says so + (`unresolvedFocusNote`), rather than switching tabs and silently highlighting nothing. - **Session list**: a red count badge on rows with undismissed findings, plus a `flagged` filter (`GET /api/sessions?flagged=1`) that narrows to them. It is the one filter that shows hidden sessions, marked as hidden on the row: the count below includes them, so leaving them out would diff --git a/web/src/components/SecretFindingsBanner.tsx b/web/src/components/SecretFindingsBanner.tsx index d88340df..6db62a24 100644 --- a/web/src/components/SecretFindingsBanner.tsx +++ b/web/src/components/SecretFindingsBanner.tsx @@ -1,6 +1,8 @@ // The session-detail warning for secret-scan findings (#327). Renders when a session's scan found // likely exposed credentials: what kind, where (prompt/response), and a redacted hint so the user // recognizes which credential it was. The store never holds the secret itself. +// Each finding is a link into the Timeline at the interaction it was found in (#336), so the user +// can see the turn that leaked it. // Dismissal is anchored to the current finding set server-side, so it lapses if a re-scan finds // something different; a dismissed banner collapses to a muted line with a way back. import { useMutation, useQueryClient } from "@tanstack/react-query"; @@ -9,48 +11,26 @@ import type { SecretFinding } from "../types"; import { dismissSecretFindings, undismissSecretFindings } from "../lib/sessions"; import { pluralize } from "../lib/format"; import { useReadOnly } from "../lib/read-only"; +import { + interactionNumber, + orderSecretFindings, + secretFindingKey, + secretFindingLine, +} from "../lib/secret-findings"; import { VIEW_QUERY_KEY } from "../lib/views"; -/** User-facing labels for the scanner's categories (plain words, not rule ids). */ -const CATEGORY_LABELS: Record = { - aws_access_key: "AWS access key", - github_token: "GitHub token", - anthropic_api_key: "Anthropic API key", - openai_api_key: "OpenAI API key", - stripe_key: "Stripe key", - slack_token: "Slack token", - private_key: "Private key", - jwt: "JWT", - generic_secret: "Possible secret", -}; - -function findingLine(f: SecretFinding): string { - const where = f.chunkType === "prompt" ? "in your prompt" : "in the agent's reply"; - return `${CATEGORY_LABELS[f.category] ?? f.category}${f.hint ? ` (${f.hint})` : ""} ${where}`; -} - -/** Finding display order (documented, per the repo's ordered-list rule): chronological by - * interaction, the prompt before the response within an interaction, then category and hint to - * break ties. The scanner emits rule-order within a chunk; the user cares about where in the - * session a credential appeared, so we sort here rather than trust arrival order. */ -function orderFindings(findings: SecretFinding[]): SecretFinding[] { - return [...findings].sort( - (a, b) => - a.interactionSeq - b.interactionSeq || - (a.chunkType === b.chunkType ? 0 : a.chunkType === "prompt" ? -1 : 1) || - a.category.localeCompare(b.category) || - a.hint.localeCompare(b.hint), - ); -} - export function SecretFindingsBanner({ sessionId, findings, dismissed, + onFindingClick, }: { sessionId: string; findings: SecretFinding[]; dismissed: boolean; + /** Open the Timeline at the interaction a finding was found in. Omitted when there's nowhere to + * go, in which case the findings render as plain text. */ + onFindingClick?: (interactionSeq: number) => void; }) { const qc = useQueryClient(); // The warning itself is worth showing everywhere; dismissing is a write, and a read-only server @@ -96,7 +76,7 @@ export function SecretFindingsBanner({ ); } - const ordered = orderFindings(findings); + const ordered = orderSecretFindings(findings); return (
@@ -119,9 +99,29 @@ export function SecretFindingsBanner({
    {ordered.slice(0, 5).map((f) => ( -
  1. {findingLine(f)}
  2. +
  3. + {onFindingClick ? ( + + ) : ( + secretFindingLine(f) + )} +
  4. ))} - {ordered.length > 5 &&
  5. …and {ordered.length - 5} more
  6. } + {/* The list stops at 5, but every finding is marked on its turn in the timeline, so say where + the rest are rather than leaving them unreachable. */} + {ordered.length > 5 && ( +
  7. + …and {ordered.length - 5} more + {onFindingClick ? ", marked on their turns in the timeline" : ""} +
  8. + )}

If any of these are real, rotate them. Only the redacted hint is stored, never the diff --git a/web/src/components/SessionTimeline.tsx b/web/src/components/SessionTimeline.tsx index 41deab47..c6f78702 100644 --- a/web/src/components/SessionTimeline.tsx +++ b/web/src/components/SessionTimeline.tsx @@ -1,12 +1,31 @@ -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronDown, ChevronRight, ShieldAlert } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ClampText } from "./ClampText"; import { CopyButton } from "./CopyButton"; import { InteractionCount } from "./pills"; import { OutcomeBadge } from "./TaskDetails"; import { dtAmPm, fmt, pluralize } from "../lib/format"; +import { + groupSecretFindingsByInteraction, + interactionNumber, + secretFindingKey, + secretFindingLabel, + type InteractionSecretFindings, +} from "../lib/secret-findings"; import { useSessionInteractionsQuery } from "../lib/sessions"; -import type { TimelineInteraction, TimelineTask } from "../types"; +import { + chapterKey, + resolveTimelineFocus, + toChapters, + unresolvedFocusNote, + type TimelineFocus, +} from "../lib/timeline"; +import type { SecretFinding, TimelineInteraction } from "../types"; + +export type { TimelineFocus } from "../lib/timeline"; + +/** Nothing found in this turn — the shape the map returns for an interaction with no findings. */ +const NO_FINDINGS: InteractionSecretFindings = { prompt: [], response: [] }; function dispositionNote(disposition: TimelineInteraction["disposition"]): string { if (disposition === "interrupted") return "Interrupted — no response."; @@ -15,12 +34,32 @@ function dispositionNote(disposition: TimelineInteraction["disposition"]): strin return "(response not retained)"; } -/** The details rail for one interaction: when it ran, its token/tool totals, and the per-tool - * breakdown. */ +/** The credentials the scanner found in one half of an interaction (#336). Marks the turn even when + * the conversation text wasn't retained — knowing which turn and which kind of credential is the + * point, and the text isn't needed to say it. */ +function TurnSecrets({ findings }: { findings: SecretFinding[] }) { + if (findings.length === 0) return null; + return ( +

    + {findings.map((f) => ( +
  1. + + {secretFindingLabel(f)} +
  2. + ))} +
+ ); +} + +/** The details rail for one interaction: which interaction it is, when it ran, its token/tool + * totals, and the per-tool breakdown. */ function Details({ it }: { it: TimelineInteraction }) { return (