From fbf2dbe17310feca21fee797e95070df229d2cca Mon Sep 17 00:00:00 2001 From: Mando Escamilla Date: Fri, 14 Aug 2026 15:27:41 -0500 Subject: [PATCH 1/2] Mark the leaking turn in the timeline A credential warning said what leaked but not where, so the user had to scroll the whole session to find it. The findings already carry the interaction and the half they matched; this uses them. The timeline now marks the turn a credential was found in with a shield chip naming the kind and the redacted hint, on the prompt or the response half it matched, and each finding in the warning banner is a link that opens the timeline on that interaction and outlines it. The details rail numbers each interaction so the banner's "Interaction 4" points somewhere the user can see. No new endpoint: /api/session/:id already returns the findings, so they ride in as a prop. Markers don't need retained text (with retention off the turn still says which kind of credential it was), they mark the first place a credential appeared (the scanner dedupes across the session), and they survive dismissal, which silences the banner rather than the annotation. Two pure modules come out of the components so this is testable without a DOM: lib/secret-findings.ts (labels, order, per-interaction grouping) and lib/timeline.ts (chapter grouping, moved out of SessionTimeline, plus focus resolution, now that a focus request can name a task or a single interaction). Closes #336 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Gs6LqsJLsH1dg71YFQXp8r --- docs/internals/secret-scanning.md | 17 ++- web/src/components/SecretFindingsBanner.tsx | 70 ++++----- web/src/components/SessionTimeline.tsx | 157 +++++++++++++------- web/src/lib/secret-findings.test.ts | 110 ++++++++++++++ web/src/lib/secret-findings.ts | 85 +++++++++++ web/src/lib/timeline.test.ts | 87 +++++++++++ web/src/lib/timeline.ts | 77 ++++++++++ web/src/routes/SessionDetail.tsx | 21 ++- web/src/styles.css | 15 ++ 9 files changed, 540 insertions(+), 99 deletions(-) create mode 100644 web/src/lib/secret-findings.test.ts create mode 100644 web/src/lib/secret-findings.ts create mode 100644 web/src/lib/timeline.test.ts create mode 100644 web/src/lib/timeline.ts diff --git a/docs/internals/secret-scanning.md b/docs/internals/secret-scanning.md index ba29206c..77195582 100644 --- a/docs/internals/secret-scanning.md +++ b/docs/internals/secret-scanning.md @@ -92,8 +92,21 @@ 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. - **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..21ffe65f 100644 --- a/web/src/components/SessionTimeline.tsx +++ b/web/src/components/SessionTimeline.tsx @@ -1,12 +1,30 @@ -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, + 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 +33,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 (