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
24 changes: 22 additions & 2 deletions docs/internals/secret-scanning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 35 additions & 35 deletions web/src/components/SecretFindingsBanner.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<SecretFinding["category"], string> = {
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
Expand Down Expand Up @@ -96,7 +76,7 @@ export function SecretFindingsBanner({
);
}

const ordered = orderFindings(findings);
const ordered = orderSecretFindings(findings);

return (
<div className="secret-banner" role="alert">
Expand All @@ -119,9 +99,29 @@ export function SecretFindingsBanner({
</div>
<ol className="secret-banner-list">
{ordered.slice(0, 5).map((f) => (
<li key={`${f.category}-${f.interactionSeq}-${f.chunkType}-${f.hint}`}>{findingLine(f)}</li>
<li key={secretFindingKey(f)}>
{onFindingClick ? (
<button
type="button"
className="secret-banner-link"
onClick={() => onFindingClick(f.interactionSeq)}
title={`Show interaction ${interactionNumber(f.interactionSeq)} in the timeline`}
>
{secretFindingLine(f)}
</button>
) : (
secretFindingLine(f)
)}
</li>
))}
{ordered.length > 5 && <li>…and {ordered.length - 5} more</li>}
{/* 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 && (
<li>
…and {ordered.length - 5} more
{onFindingClick ? ", marked on their turns in the timeline" : ""}
</li>
)}
</ol>
<p className="secret-banner-detail">
If any of these are real, rotate them. Only the redacted hint is stored, never the
Expand Down
Loading