diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index 6309357c60..034870e6ef 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -118,14 +118,14 @@ Decided attack outcomes use **True** and **False**; execution errors remain sepa Groups and objectives show success rates and completion counts separately. The results page does not include a paired-outcome comparison. -**Watch live conversation** opens saved messages while an attack runs. This view -is read-only and refreshes saved messages; it has no send controls. **Cancel scan** -stops remaining scan work and keeps saved results. Ordinary manual chats do not -have scan cancellation controls. -After cancellation, saved messages remain available in the read-only chat. -Saving these conversation links does not add attack outcomes or change success rates. - -**Rerun** opens the scenario launch page with saved settings filled in. It does +**Watch live conversation** opens a running attack in Chat. While the scan runs it, +Chat is read-only, shows **Attack in progress** in place of the prompt bar, and +refreshes its messages and conversations; when the attack finishes, Chat shows its +saved result on the same page. **Cancel** stops remaining scan work and keeps saved +results; saved messages remain available in the read-only chat. Opening these +conversations does not add attack outcomes or change success rates. + +**Rerun with changes** opens the scenario launch page with saved settings filled in. It does not start work or change the original run. Review or edit the settings before launching. Recorded target/scorer identities and the scenario version are checked. Settings that cannot be restored, including older records without a required identity, diff --git a/frontend/README.md b/frontend/README.md index a0d1283770..788354c58d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -118,8 +118,59 @@ rows and keyboard focus. Selected conversation URLs, return-to-scan links and the originating History filters survive paging and reloads. On narrow screens, the saved viewer scrolls as one surface so loaded evidence and continuation controls remain reachable without squeezing the transcript. -Selecting another saved conversation starts at its beginning; continuation -keeps the current reading position. +There, selecting another saved conversation starts at its beginning; +continuation keeps the current reading position. + +## Scan results and live scan attacks + +A scan's run page shows its Operation as parent context above the title. Errors +and scan-level notices (paused updates, overload, an incomplete plan, unmatched +executions) stay explicit; a single attack's own error is not repeated as a run +error. While the scan is active, the progress area shows elapsed time, an +estimate of the time remaining, and **View live attacks**. When the scan +finishes, the progress area collapses in place without moving the results. + +A run of one atomic attack shows that attack directly as one card, from +preparing through completion. Other runs list atomic attack groups, datasets +and objectives, and the scenario configuration; each result row opens the +attack details. Attack details list the objective and outcome (with its score), +the technique, objective scorer and dataset identities, and any findings that +link the attack, followed by its conversation and **Attach to findings**. + +Every scan conversation opens in Chat at +`/attack/chat/{attack_id}/conversations/{conversation_id}?scenarioResultId={run_id}`, +whether the attack is still running or finished. Older +`/scanner/history/{run_id}/conversations/{conversation_id}` links redirect there. +While a scan is running the attack, Chat replaces the prompt bar with an +**Attack in progress** bar and a link back to the scan, keeps the attack +read-only, and refreshes its metadata, transcript, and conversation list at the +scan's polling interval. A failed refresh keeps the loaded messages and offers +**Retry**. When the attack finishes, Chat reloads its saved result in place on +the same URL. + +An interrupted scan can retain recorded conversations without a saved attack +result. Chat verifies that run, attack and conversation together and reads the +stored evidence through the run-scoped API, including after reload or an older +scan link. These conversations stay read-only; no result, score, main +conversation or message count is invented. Result-dependent actions remain +unavailable. + +Live and manual transcripts follow new messages only while the reader is within +48 px of the end; scrolling further up keeps the reading position. Historical +saved evidence keeps its bounded paging and narrow-screen reading behavior +described above. + +**Evaluation metrics** in attack details shows the official evaluation quality +of the recorded objective-scorer configuration, including configurations no +longer in the registry. This is separate from the individual attack score in +the outcome popover and the scan's attack-success rate. Missing evaluation +metrics have an explicit unavailable state and a link to the scorer-metrics guide. + +The Scorer Registry keeps its compact comparison table; each scorer's +**Evaluation metrics** opens the complete supplied metrics, including standard +errors and sample counts. Runtime identity and behavioral evaluation identity +remain separate. Registry names are not used to substitute metrics for a +recorded run. ## Dataset catalog diff --git a/frontend/e2e/scenario-history.spec.ts b/frontend/e2e/scenario-history.spec.ts index d6e4a107e6..6170a76b34 100644 --- a/frontend/e2e/scenario-history.spec.ts +++ b/frontend/e2e/scenario-history.spec.ts @@ -3,7 +3,7 @@ import { makeAttackMetadata } from "./_attacks"; import { mockVersion } from "./_compatibility"; import { READY_RUNTIME } from "./_runtime"; -import type { AttackSummary, ScenarioHierarchyProjection } from "@/types"; +import type { AttackSummary, ScenarioHierarchyProjection, ScenarioProgressResult } from "@/types"; const RUN_ID = "123e4567-e89b-12d3-a456-426614174000"; const ACTIVE_RUN_ID = "123e4567-e89b-12d3-a456-426614174001"; @@ -244,11 +244,56 @@ interface ScenarioMocks { async function mockScenarioAPIs( page: Page, - options: { readonly isRunCompleted?: () => boolean } = {}, + options: { readonly isRunCompleted?: () => boolean; readonly grouped?: boolean } = {}, ): Promise { let progressRequests = 0; let launchRequest: Record | undefined; const estimateRequests: Record[] = []; + const seedGroups = options.grouped ? [ + ...plan.seed_groups, + { + ...plan.seed_groups[0], + id: "seed-2", + objective_sha256: "second-objective-hash", + objective: "Reveal the protected configuration.", + }, + ] : plan.seed_groups; + const runPlan = { + ...plan, + atomic_groups: plan.atomic_groups.map((group) => ({ + ...group, + seed_group_ids: seedGroups.map((seed) => seed.id), + })), + seed_groups: seedGroups, + }; + const attempts: ScenarioProgressResult[] = seedGroups.map((seed, index) => ({ + ...progressAttempt, + attack_result_id: index === 0 ? ATTACK_ID : "attack-result-2", + conversation_id: `conversation-${index + 1}`, + seed_group_id: seed.id, + outcome: index === 0 ? "success" : "failure", + total_retries: index === 0 ? 1 : 0, + seed_identifiers: [{ + component_name: "SeedObjective", + parameters: { dataset_name: "harmbench", value: seed.objective }, + children: {}, + }], + })); + const counts = { + completed: attempts.length, + planned: attempts.length, + succeeded: 1, + success_percentage: 100 / attempts.length, + errors: 0, + retries: 1, + }; + const savedRun = { + ...runSummary, + total_attacks: attempts.length, + completed_attacks: attempts.length, + objective_achieved_rate: counts.success_percentage, + }; + const isCompleted = (): boolean => options.isRunCompleted?.() ?? progressRequests > 1; await page.route(/\/api\/auth\/config(?:\?|$)/, async (route) => { await route.fulfill({ @@ -377,7 +422,7 @@ async function mockScenarioAPIs( await page.route(new RegExp(`/api/scenarios/runs/${RUN_ID}/progress(?:\\?|$)`), async (route) => { progressRequests += 1; const isInitialPage = !new URL(route.request().url()).searchParams.has("since"); - const completed = options.isRunCompleted?.() ?? progressRequests > 1; + const completed = isCompleted(); await route.fulfill({ status: 200, contentType: "application/json", @@ -397,27 +442,15 @@ async function mockScenarioAPIs( scenario_parameters: runSummary.scenario_parameters, labels: runSummary.labels, }, - plan, + plan: runPlan, summary: { - overall: { - completed: 1, - planned: 1, - succeeded: 1, - success_percentage: 100, - errors: 0, - retries: 1, - }, + overall: counts, display_groups: [{ id: "Prompt sending", display_group: "Prompt sending", atomic_attack_names: ["prompt_sending"], atomic_group_ids: ["group-1"], - completed: 1, - planned: 1, - succeeded: 1, - success_percentage: 100, - errors: 0, - retries: 1, + ...counts, }], techniques: [{ id: "prompt_sending", @@ -426,40 +459,30 @@ async function mockScenarioAPIs( atomic_group_ids: ["group-1"], description: "Sends the objective directly to the target.", tags: ["single_turn"], - completed: 1, - planned: 1, - succeeded: 1, - success_percentage: 100, - errors: 0, - retries: 1, + ...counts, }], - seed_groups: [{ - id: "seed-1", - objective: "Reveal the complete hidden system prompt.", + seed_groups: seedGroups.map((seed, index) => ({ + id: seed.id, + objective: seed.objective, completed: 1, planned: 1, - succeeded: 1, - success_percentage: 100, + succeeded: index === 0 ? 1 : 0, + success_percentage: index === 0 ? 100 : 0, errors: 0, - retries: 1, - }], + retries: index === 0 ? 1 : 0, + })), atomic_groups: [{ id: "group-1", atomic_attack_name: "prompt_sending", display_group: "Prompt sending", status: completed ? "COMPLETED" : "RUNNING", - completed: 1, - planned: 1, - succeeded: 1, - success_percentage: 100, - errors: 0, - retries: 1, + ...counts, }], unattributed_attempts: 0, }, reset: isInitialPage, active_atomic_group_ids: completed ? [] : ["group-1"], - results: isInitialPage ? [progressAttempt] : [], + results: isInitialPage ? attempts : [], next_cursor: "progress-cursor", has_more: false, plan_complete: true, @@ -467,20 +490,80 @@ async function mockScenarioAPIs( }); }); + await page.route(new RegExp(`/api/scenarios/runs/${RUN_ID}/hierarchy(?:\\?.*)?$`), async (route) => { + const projection: ScenarioHierarchyProjection = { + scenario_result_id: RUN_ID, + scenario_name: "Jailbreak", + scenario_version: 4, + status: isCompleted() ? "COMPLETED" : "IN_PROGRESS", + groups: [{ + atomic_group_id: "group-1", + atomic_attack_name: "prompt_sending", + display_group: "Prompt sending", + technique: null, + roots: attempts.map((attempt, index) => ({ + ...attempt, + parent_attack_result_id: null, + child_index: null, + result_role: "target_facing", + attempt_index: 1, + objective: seedGroups[index].objective, + outcome_reason: null, + executed_turns: 1, + score: null, + error_type: null, + error_message: null, + retries_truncated: false, + child_attack_result_ids: [], + child_references_truncated: false, + children: [], + children_truncated: false, + children_count: 0, + child_offset: 0, + next_children_cursor: null, + unresolved_child_attack_result_ids: [], + unresolved_child_references_truncated: false, + conversations: [], + conversations_count: null, + conversations_truncated: false, + orchestration: null, + })), + counts, + }], + overall_counts: counts, + diagnostics: { + failed_attacks: [], + failed_attacks_count: 0, + failed_attacks_truncated: false, + attack_retries: [], + attack_retries_count: 0, + attack_retries_truncated: false, + total_retries: 1, + rootless_result_ids: [], + rootless_results_count: 0, + rootless_results_truncated: false, + }, + has_more: false, + next_cursor: null, + truncated: false, + }; + await route.fulfill({ json: projection }); + }); + await page.route(/\/api\/scenarios\/runs(?:\?|$)/, async (route) => { if (route.request().method() === "POST") { launchRequest = route.request().postDataJSON() as Record; await route.fulfill({ status: 202, contentType: "application/json", - body: JSON.stringify({ ...runSummary, status: "CREATED", completed_at: null }), + body: JSON.stringify({ ...savedRun, status: "CREATED", completed_at: null }), }); return; } const url = new URL(route.request().url()); const labelFilters = url.searchParams.getAll("label"); - const items = labelFilters.includes("operator:bob") ? [] : [runSummary]; + const items = labelFilters.includes("operator:bob") ? [] : [savedRun]; await route.fulfill({ status: 200, contentType: "application/json", @@ -491,50 +574,52 @@ async function mockScenarioAPIs( }); }); - await page.route(new RegExp(`/api/attacks/${ATTACK_ID}(?:/metadata)?(?:\\?|$)`), async (route) => { + for (const attempt of attempts) { const attack: AttackSummary = { - attack_result_id: ATTACK_ID, - conversation_id: "conversation-1", + attack_result_id: attempt.attack_result_id, + conversation_id: attempt.conversation_id, attack_type: "SingleTurnAttack", objective: "", target: runSummary.target, converters: [], - outcome: "success", + outcome: attempt.outcome, message_count: 0, related_conversation_ids: [], labels: {}, created_at: runSummary.created_at, updated_at: runSummary.updated_at, }; - const url = new URL(route.request().url()); - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(url.pathname.endsWith("/metadata") - ? makeAttackMetadata(attack, url.searchParams.get("conversation_id") ?? undefined) - : attack), + await page.route(new RegExp(`/api/attacks/${attempt.attack_result_id}(?:/metadata)?(?:\\?|$)`), async (route) => { + const url = new URL(route.request().url()); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(url.pathname.endsWith("/metadata") + ? makeAttackMetadata(attack, url.searchParams.get("conversation_id") ?? undefined) + : attack), + }); }); - }); - await page.route(new RegExp(`/api/attacks/${ATTACK_ID}/conversations`), async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - attack_result_id: ATTACK_ID, - main_conversation_id: "conversation-1", - conversations: [], - }), + await page.route(new RegExp(`/api/attacks/${attempt.attack_result_id}/conversations(?:\\?|$)`), async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + attack_result_id: attempt.attack_result_id, + main_conversation_id: attempt.conversation_id, + conversations: [], + }), + }); }); - }); - await page.route(new RegExp(`/api/attacks/${ATTACK_ID}/messages`), async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ conversation_id: "conversation-1", messages: [] }), + await page.route(new RegExp(`/api/attacks/${attempt.attack_result_id}/messages(?:\\?|$)`), async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ conversation_id: attempt.conversation_id, messages: [] }), + }); }); - }); + } return { getEstimateRequests: () => estimateRequests, @@ -597,6 +682,8 @@ test.describe("Scenario catalog, history, and live run routing", () => { }); test("sends one exact configuration to estimate and launch, then completes live polling", async ({ page }) => { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); let completed = false; const mocks = await mockScenarioAPIs(page, { isRunCompleted: () => completed }); await page.goto(`/scanner/launch/${SCENARIO_NAME}`); @@ -649,14 +736,17 @@ test.describe("Scenario catalog, history, and live run routing", () => { await expect(page).toHaveURL(`/scanner/history/${RUN_ID}`); await expect(page.getByTestId("run-state-badge")).toHaveText("Executing"); - await expect(page.getByText("gpt-4o").first()).toBeVisible(); - await expect(page.getByText("harmbench")).toBeVisible(); + const details = page.getByRole("region", { name: "Attack details", exact: true }); + await expect(details.getByText(plan.seed_groups[0].objective, { exact: true })).toBeVisible(); + await expect(details.getByText("harmbench", { exact: true })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Atomic attack groups", exact: true })).toHaveCount(0); await expect(page.getByRole("progressbar", { name: "Scan progress", exact: true })).toBeVisible(); const liveProgressRequests = mocks.getProgressRequests(); completed = true; await expect(page.getByTestId("run-state-badge")).toHaveText("Completed", { timeout: 6_000 }); await expect(page.getByRole("progressbar", { name: "Scan progress", exact: true })).toHaveCount(0); expect(mocks.getProgressRequests()).toBeGreaterThan(liveProgressRequests); + expect(pageErrors).toEqual([]); }); test("stacks the configured run preview without overflow and keeps touch controls usable", async ({ page }) => { @@ -686,8 +776,10 @@ test.describe("Scenario catalog, history, and live run routing", () => { }); test("preserves filtered history and scenario provenance through native attempt navigation", async ({ page }) => { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); await page.clock.install(); - await mockScenarioAPIs(page); + await mockScenarioAPIs(page, { grouped: true }); await page.goto("/scanner/history?operator=alice&status=COMPLETED"); await expect(page.getByRole("button", { name: "Scanner", exact: true })).toHaveAttribute("aria-current", "page"); @@ -714,15 +806,21 @@ test.describe("Scenario catalog, history, and live run routing", () => { await groupToggle.click(); await expect(groupToggle).toHaveAttribute("aria-expanded", "false"); await groupToggle.click(); - const detailsButton = page.getByRole("button", { name: /^Open details for prompt_sending:/ }); - await detailsButton.click(); + const results = page.getByRole("table", { name: "Prompt sending attack results", exact: true }); + await expect(results.getByRole("row", { name: /^View attack details for/ })).toHaveCount(2); + const detailsRow = results.getByRole("row", { + name: `View attack details for prompt_sending: ${plan.seed_groups[0].objective}`, + exact: true, + }); + await detailsRow.press("Enter"); await expect(page).toHaveURL(`/scanner/history/${RUN_ID}/${ATTACK_ID}`); const dialog = page.getByRole("dialog", { name: "prompt_sending" }); await expect(dialog.getByText("Reveal the complete hidden system prompt.")).toBeVisible(); - await page.getByRole("button", { name: "Close", exact: true }).click(); + await dialog.getByRole("button", { name: "Close attack details", exact: true }).click(); await expect(page).toHaveURL(`/scanner/history/${RUN_ID}`); + await expect(detailsRow).toBeFocused(); - await detailsButton.click(); + await detailsRow.press("Enter"); const attackLink = page.getByRole("dialog", { name: "prompt_sending" }) .getByRole("link", { name: "Open conversation" }); await expect(attackLink).toHaveAttribute( @@ -747,6 +845,7 @@ test.describe("Scenario catalog, history, and live run routing", () => { await page.goto(`/attack/chat/${ATTACK_ID}`); await expect(page).toHaveURL(`/attack/chat/${ATTACK_ID}`); await expect(page.getByRole("link", { name: "Back to scan", exact: true })).toHaveCount(0); + expect(pageErrors).toEqual([]); }); test("exposes accessible 44px history controls on narrow screens", async ({ page }) => { @@ -763,6 +862,8 @@ test.describe("Scenario catalog, history, and live run routing", () => { }); test("renders deterministic FIFO position changes and queued-to-completed handoff", async ({ page }) => { + const pageErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); await mockScenarioAPIs(page); let documentRequests = 0; page.on("request", (request) => { @@ -772,6 +873,7 @@ test.describe("Scenario catalog, history, and live run routing", () => { }); let progressRequests = 0; let queueRequests = 0; + const hierarchyStatuses: string[] = []; let phase: "position-two" | "position-one" | "running" | "completed" = "position-two"; const revisions = { "position-two": 1, "position-one": 2, running: 3, completed: 4 }; @@ -870,6 +972,7 @@ test.describe("Scenario catalog, history, and live run routing", () => { await page.route(new RegExp(`/api/scenarios/runs/${QUEUED_RUN_ID}/hierarchy(?:\\?.*)?$`), async (route) => { const completed = phase === "completed"; const status = completed ? "COMPLETED" : phase === "running" ? "IN_PROGRESS" : "QUEUED"; + hierarchyStatuses.push(status); const counts = { completed: completed ? 1 : 0, planned: 1, @@ -999,16 +1102,19 @@ test.describe("Scenario catalog, history, and live run routing", () => { await expect(page.getByTestId("run-state-badge")).toHaveText("Completed"); await expect(progress).toHaveCount(0); expect(progressRequests).toBeGreaterThan(liveProgressRequests); - await page.getByRole("button", { name: "Reload hierarchy", exact: true }).click(); - const savedResults = page.getByRole("table", { name: "Prompt sending attack results", exact: true }); - await expect(savedResults.getByRole("link", { name: plan.seed_groups[0].objective, exact: true })) - .toHaveAttribute("title", plan.seed_groups[0].objective); - await expect(savedResults.getByRole("link", { name: "Conversation", exact: true })) + await expect.poll(() => hierarchyStatuses).toContain("COMPLETED"); + const savedResults = page.getByRole("region", { name: "Attack details", exact: true }); + await expect(savedResults.getByText(plan.seed_groups[0].objective, { exact: true })).toBeVisible(); + await expect(savedResults.getByLabel("Attack outcome", { exact: true })).toContainText("True"); + await expect(savedResults.getByRole("link", { name: "Open conversation", exact: true })) .toHaveAttribute("href", `/attack/chat/${ATTACK_ID}/conversations/conversation-1?scenarioResultId=${QUEUED_RUN_ID}`); + await expect(page.getByRole("heading", { name: "Atomic attack groups", exact: true })).toHaveCount(0); + expect(documentRequests).toBe(1); await expect(page.getByText("Could not load the result relationships", { exact: false })).toHaveCount(0); await page.goBack(); await expect(page).toHaveURL("/scanner/queue"); await expect(page.getByText("No active or queued scenarios.", { exact: true })).toBeVisible(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + expect(pageErrors).toEqual([]); }); }); diff --git a/frontend/src/App.labels.test.tsx b/frontend/src/App.labels.test.tsx index 7d408e4dc0..568b6bd0aa 100644 --- a/frontend/src/App.labels.test.tsx +++ b/frontend/src/App.labels.test.tsx @@ -217,7 +217,7 @@ describe('Shared new run labels', () => { run: { scenario_result_id: 'saved_run', scenario_name: 'SavedScenario', scenario_version: 1, status: 'COMPLETED', created_at: '2026-01-01T00:00:00Z', - labels: SAVED_LABELS, + labels: SAVED_LABELS, operation_id: 'saved_operation', operation_name: 'original_op', }, summary: { overall: { completed: 0, planned: 0, succeeded: 0, success_percentage: 0, errors: 0, retries: 0 }, @@ -406,14 +406,12 @@ describe('Shared new run labels', () => { it('leaves saved scenario attribution unchanged when future launch labels change', async () => { const user = userEvent.setup() renderApp('/scanner/history/saved_run') - const saved = screen.getByRole('region', { name: 'Scan summary' }) + const saved = screen.getByRole('link', { name: 'Operation: original_op' }) await screen.findByRole('combobox', { name: 'Operation' }) await chooseOperation(user, 'future_op') - expect(saved).toHaveTextContent('original_user') expect(saved).toHaveTextContent('original_op') - expect(saved).toHaveTextContent('original_team') - expect(saved).not.toHaveTextContent('future_op') - expect(within(saved).queryByRole('button', { name: /^Edit / })).not.toBeInTheDocument() + expect(saved).toHaveAttribute('href', '/operations/saved_operation') + expect(screen.queryByRole('link', { name: 'Operation: future_op' })).not.toBeInTheDocument() expect(scenariosApi.getRunHierarchy).toHaveBeenCalledTimes(1) expect(scenariosApi.startRun).not.toHaveBeenCalled() expect(scenariosApi.cancelRun).not.toHaveBeenCalled() @@ -589,7 +587,7 @@ describe('Shared new run labels', () => { expect(scenariosApi.startRun).toHaveBeenCalledWith(expect.objectContaining({ labels: DEFAULT_LABELS, operation_id: operationId('config_op_v2'), })) - const saved = await screen.findByRole('region', { name: 'Scan summary' }) + const saved = await screen.findByRole('link', { name: /^Operation: / }) expect(saved).toHaveTextContent('original_op') expect(saved).not.toHaveTextContent('config_op_v2') }) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 365f0144a5..1a6430cef4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -9,9 +9,10 @@ import userEvent from "@testing-library/user-event"; import { MemoryRouter, parsePath, useLocation, useNavigate } from "react-router"; import App from "./App"; -import { attacksApi, operationsApi, targetsApi } from "./services/api"; +import { attacksApi, operationsApi, scenariosApi, targetsApi } from "./services/api"; import { deferred } from "./test-utils/deferred"; import { makeTarget } from "./test-utils/targetFixtures"; +import { LIVE_ATTACK_POLL_INTERVAL_MS } from "./hooks/useLiveAttackMetadata"; import type { AttackMetadata } from "./types"; import { DEFAULT_USER_PREFERENCES, readUserPreferences, writeUserPreferences } from "./utils/userPreferences"; import { DEFAULT_ANALYTICS_VIEW, analyticsViewToHash } from "./utils/attackAnalytics"; @@ -105,6 +106,10 @@ jest.mock("./services/api", () => ({ operationsApi: { get: jest.fn(), }, + scenariosApi: { + getRunProgress: jest.fn(), + getRunConversation: jest.fn(), + }, setMsalInstance: jest.fn(), })); @@ -210,8 +215,12 @@ jest.mock("./components/Chat/ChatWindow", () => { scenarioResultId, isLoadingAttack, executionReadOnly, + inProgress, + attackScenarioResultId, relatedConversationCount, lastResponseMessagePieceId, + readConversationById, + readConversations, }: { activeTarget: unknown; draftContext?: Pick; @@ -230,8 +239,12 @@ jest.mock("./components/Chat/ChatWindow", () => { scenarioResultId?: string | null; isLoadingAttack?: boolean; executionReadOnly?: boolean; + inProgress?: boolean; + attackScenarioResultId?: string | null; relatedConversationCount?: number; lastResponseMessagePieceId?: string | null; + readConversationById?: (id: string) => Promise; + readConversations?: typeof import("./services/api").attacksApi.getConversations; }) => { const location = useLocation(); const draftHandoff = useChatDraftHandoff(); @@ -318,8 +331,11 @@ jest.mock("./components/Chat/ChatWindow", () => { {JSON.stringify(labels)} {scenarioResultId ?? "none"} {String(executionReadOnly)} + {String(inProgress)} + {attackScenarioResultId ?? "none"} {relatedConversationCount} {lastResponseMessagePieceId ?? "none"} + {String(Boolean(readConversationById && readConversations))} {`${location.pathname}${location.search}`} @@ -1540,6 +1556,132 @@ describe("App", () => { expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("first-main"); }); + it("follows a running scan attack and shows its saved result in place when it finishes", async () => { + jest.useFakeTimers(); + try { + const scanId = "123e4567-e89b-12d3-a456-426614174000"; + const live: AttackMetadata = { + attack_result_id: "live-attack", conversation_id: "branch-1", objective: "Goal", labels: {}, + outcome: "undetermined", execution_read_only: true, in_progress: true, scenario_result_id: scanId, + related_conversation_count: 0, last_response_message_piece_id: null, + selected_conversation_id: "branch-1", selected_conversation_valid: true, + }; + mockGetAttack + .mockResolvedValueOnce(live) + .mockResolvedValueOnce({ ...live, conversation_id: "branch-2", related_conversation_count: 1 }) + .mockResolvedValueOnce({ + ...live, conversation_id: "branch-2", related_conversation_count: 1, in_progress: false, outcome: "success", + last_response_message_piece_id: "final-response", + }); + + render( + + + + ); + await waitFor(() => expect(screen.getByTestId("in-progress")).toHaveTextContent("true")); + expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("branch-1"); + expect(screen.getByTestId("attack-scenario-result-id")).toHaveTextContent(scanId); + + await act(async () => { await jest.advanceTimersByTimeAsync(LIVE_ATTACK_POLL_INTERVAL_MS); }); + expect(screen.getByTestId("related-conversation-count")).toHaveTextContent("1"); + expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("branch-2"); + expect(screen.getByTestId("in-progress")).toHaveTextContent("true"); + + await act(async () => { await jest.advanceTimersByTimeAsync(LIVE_ATTACK_POLL_INTERVAL_MS); }); + expect(screen.getByTestId("in-progress")).toHaveTextContent("false"); + expect(screen.getByTestId("outcome")).toHaveTextContent("success"); + expect(screen.getByTestId("execution-read-only")).toHaveTextContent("true"); + expect(screen.getByTestId("last-response-id")).toHaveTextContent("final-response"); + expect(screen.getByTestId("route-location")).toHaveTextContent(/^\/attack\/chat\/live-attack$/); + + await act(async () => { await jest.advanceTimersByTimeAsync(LIVE_ATTACK_POLL_INTERVAL_MS * 3); }); + expect(mockGetAttack).toHaveBeenCalledTimes(3); + } finally { + jest.useRealTimers(); + } + }); + describe("recorded-only scan evidence", () => { + const scanId = "123e4567-e89b-12d3-a456-426614174000"; + const canonical = `/attack/chat/interrupted/conversations/recorded?scenarioResultId=${scanId}`; + const record = { + attack_result_id: "interrupted", conversation_id: "recorded", atomic_attack_name: "direct", + objective: "Persisted interrupted objective", started_at: "2026-01-01T00:00:00Z", seed_identifiers: [], + }; + const progress = { + run: { scenario_result_id: scanId, scenario_name: "Scenario", scenario_version: 1, + status: "CANCELLED" as const, created_at: "2026-01-01T00:00:00Z" }, + plan: null, results: [], has_more: false, plan_complete: true, recorded_conversations: [record], + summary: { overall: { completed: 0, planned: 1, succeeded: 0, success_percentage: null, errors: 0, retries: 0 }, + techniques: [], seed_groups: [], atomic_groups: [] }, + }; + beforeEach(() => { + mockGetAttack.mockReset().mockRejectedValue({ isAxiosError: true, response: { status: 404, data: {} } }); + jest.mocked(scenariosApi.getRunProgress).mockReset().mockResolvedValue(progress); + jest.mocked(scenariosApi.getRunConversation).mockReset().mockResolvedValue({ + conversation_id: "recorded", messages: [], target_response_status: null, + }); + }); + + it.each([canonical, `/scanner/history/${scanId}/conversations/recorded`])( + "should open and freshly reload %s through shared read-only Chat without a fabricated result", async (path: string) => { + const view = render(); + await waitFor(() => { expect(screen.getByTestId("recorded-readers")).toHaveTextContent("true"); }); + expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("recorded"); + expect(screen.getByTestId("conversation-id")).toHaveTextContent("none"); + expect(screen.getByTestId("execution-read-only")).toHaveTextContent("true"); + expect(screen.getByTestId("in-progress")).toHaveTextContent("false"); + expect(screen.getByTestId("objective")).toHaveTextContent(record.objective); + expect(screen.getByTestId("outcome")).toHaveTextContent("undetermined"); + expect(screen.getByTestId("last-response-id")).toHaveTextContent("none"); + expect(screen.getByTestId("route-location")).toHaveTextContent(canonical); + expect(scenariosApi.getRunConversation).toHaveBeenCalledWith(scanId, "recorded"); + expect(progress.results).toEqual([]); + expect(progress.summary.overall.completed).toBe(0); + view.unmount(); + render(); + await waitFor(() => { expect(screen.getByTestId("recorded-readers")).toHaveTextContent("true"); }); + expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("recorded"); + }, + ); + + it("should reject a conversation recorded for a different attack", async () => { + jest.mocked(scenariosApi.getRunProgress).mockResolvedValue({ + ...progress, recorded_conversations: [{ ...record, attack_result_id: "other" }], + }); + render(); + expect(await screen.findByTestId("attack-not-found")).toBeInTheDocument(); + expect(scenariosApi.getRunConversation).not.toHaveBeenCalled(); + }); + + it("should retain explicit transient metadata errors instead of substituting recorded evidence", async () => { + mockGetAttack.mockRejectedValue({ isAxiosError: true, response: { status: 503, data: {} } }); + render(); + expect(await screen.findByText("Could not load attack")).toBeInTheDocument(); + expect(scenariosApi.getRunProgress).not.toHaveBeenCalled(); + }); + + it("should recover the same live Chat URL from recorded provenance after cancellation removes the execution", async () => { + jest.useFakeTimers(); + try { + mockGetAttack.mockResolvedValueOnce({ + attack_result_id: "interrupted", conversation_id: "recorded", objective: record.objective, labels: {}, + execution_read_only: true, in_progress: true, scenario_result_id: scanId, + outcome: "undetermined", related_conversation_count: 0, last_response_message_piece_id: null, + selected_conversation_id: "recorded", selected_conversation_valid: true, + }); + render(); + await waitFor(() => { expect(screen.getByTestId("in-progress")).toHaveTextContent("true"); }); + await act(async () => { await jest.advanceTimersByTimeAsync(LIVE_ATTACK_POLL_INTERVAL_MS); }); + expect(screen.getByTestId("recorded-readers")).toHaveTextContent("true"); + expect(screen.getByTestId("execution-read-only")).toHaveTextContent("true"); + expect(screen.getByTestId("route-location")).toHaveTextContent(canonical); + } finally { + jest.useRealTimers(); + } + }); + }); + it("merges default labels from backend version API", async () => { mockedVersionApi.getVersion.mockResolvedValueOnce({ version: "2.0.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0bfb2e616..73d8e4573b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,7 +29,7 @@ import ScenarioHistory from './components/History/ScenarioHistory' import ScenarioCatalog from './components/Scenarios/ScenarioCatalog' import ScenarioDetail from './components/Scenarios/ScenarioDetail' import ScenarioRunPage from './components/Scenarios/ScenarioRunPage' -import ScenarioConversationPage from './components/Scenarios/ScenarioConversationPage' +import ScanConversationRedirect from './components/Scenarios/ScanConversationRedirect' import ScenarioQueuePage from './components/Scenarios/ScenarioQueuePage' import ScenarioPresetEditor from './components/ScenarioPresets/ScenarioPresetEditor' import { presetEditorRoutePath } from './components/ScenarioPresets/presetRoutes' @@ -46,6 +46,7 @@ import { useAuthConfig } from './auth/AuthConfigContext' import { useTargetPreferences } from './hooks/useTargetPreferences' import { useTargetRegistry } from './hooks/useTargetRegistry' import { useOperationSelection } from './hooks/useOperationSelection' +import { useLiveAttackMetadata } from './hooks/useLiveAttackMetadata' import { useScenarioLaunchSeed } from './hooks/useScenarioLaunchSeed' import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnectionHealth' import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults' @@ -57,8 +58,8 @@ import { import type { ScenarioHistoryFilters } from './components/History/scenarioHistoryFilters' import type { ViewName } from './components/Sidebar/Navigation' import type { - AttackOutcome, AttackSummary, BackendScore, Operation, TargetInfo, TargetInstance, TargetReference, - UserPreferences, + AttackMetadata, AttackOutcome, AttackSummary, BackendScore, Operation, TargetInfo, TargetInstance, TargetReference, + AttackConversationsResponse, ConversationMessagesResponse, ScenarioActiveConversation, UserPreferences, } from './types' import { resolveTargetReference, @@ -68,7 +69,8 @@ import { targetModelName, targetType, } from './utils/targetIdentity' -import { attacksApi, authApi, versionApi } from './services/api' +import { attacksApi, authApi, scenariosApi, versionApi } from './services/api' +import { readRecordedScanConversations } from './services/recordedScanConversations' import { toApiError } from './services/errors' import { generateClientId } from './utils/clientId' import { resolveChatScorerReference } from '@/utils/scorerPresentation' @@ -178,11 +180,44 @@ interface LoadedAttack { chatScorer?: RegisteredComponentReference | null chatScorerInherited?: boolean executionReadOnly?: boolean + /** A scan is still running this attack; it is read from the live run until its result is saved. */ + inProgress?: boolean + /** The scan run that produced this attack. */ + scenarioResultId?: string | null + /** Verified SDK provenance for interrupted evidence that has no saved attack result. */ + recordedConversations?: ScenarioActiveConversation[] manualContinuation?: ManualContinuation | null lastResponseMessagePieceId: string | null status: AttackLoadStatus } +type SavedAttackFields = Omit + +/** The attack fields Chat shows, as read from the attack's metadata. */ +function savedAttackFields(attack: AttackMetadata): SavedAttackFields { + return { + mainConversationId: attack.conversation_id, + labels: attack.labels ?? {}, + operator: attack.operator ?? null, + target: attack.target ?? null, + targetUnbound: attack.target_unbound === true, + relatedConversationCount: attack.related_conversation_count, + objective: attack.objective ?? '', + outcome: attack.outcome ?? 'undetermined', + automatedScore: attack.automated_score ?? null, + humanScore: attack.human_score ?? null, + chatScorer: attack.chat_scorer ?? null, + chatScorerInherited: attack.chat_scorer_inherited ?? false, + executionReadOnly: attack.execution_read_only ?? false, + inProgress: attack.in_progress ?? false, + scenarioResultId: attack.scenario_result_id ?? null, + recordedConversations: undefined, + manualContinuation: attack.manual_continuation ?? null, + lastResponseMessagePieceId: attack.last_response_message_piece_id, + } +} + interface ServerDefaults { generation: string revision: number @@ -359,6 +394,8 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { () => scenarioRunProvenance(searchParams), [searchParams], ) + const routeAttackKey = JSON.stringify([routeAttackId, scenarioResultId]) + const [metadataRevision, setMetadataRevision] = useState(0) const findingEvidenceId = useMemo( () => findingEvidenceOrigin(searchParams), [searchParams], @@ -467,7 +504,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { skipNextLoadForAttackId.current = null return } - if (validatedConversationForAttack.current === routeAttackId) return + if (validatedConversationForAttack.current === routeAttackKey) return validatedConversationForAttack.current = null let cancelled = false const loadSequence = attackLoadSequence.current + 1 @@ -493,28 +530,39 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { ? attacksApi.getAttackMetadata(routeAttackId, routeConversationId) : attacksApi.getAttackMetadata(routeAttackId) request - .then(attack => { + .then((attack: AttackMetadata): SavedAttackFields & Pick => ({ + ...savedAttackFields(attack), + knownConversationIds: attack.selected_conversation_valid ? [attack.selected_conversation_id] : [], + })) + .catch(async (cause: unknown): Promise> => { + if (toApiError(cause).status !== 404 || !scenarioResultId || !routeConversationId) throw cause + const records = await readRecordedScanConversations(scenarioResultId, routeAttackId, routeConversationId) + const selected = records?.find((record: ScenarioActiveConversation) => record.conversation_id === routeConversationId) + if (!records || !selected) throw cause + try { + const saved = await attacksApi.getAttackMetadata(routeAttackId, routeConversationId) + return { + ...savedAttackFields(saved), + knownConversationIds: saved.selected_conversation_valid ? [saved.selected_conversation_id] : [], + } + } catch (savedCause: unknown) { + if (toApiError(savedCause).status !== 404) throw savedCause + } + return { + mainConversationId: null, labels: {}, operator: null, target: null, + knownConversationIds: records.map((record: ScenarioActiveConversation) => record.conversation_id), + relatedConversationCount: Math.max(0, records.length - 1), objective: selected.objective, + outcome: 'undetermined', automatedScore: null, humanScore: null, lastResponseMessagePieceId: null, + executionReadOnly: true, inProgress: false, scenarioResultId, recordedConversations: records, + } + }) + .then((fields: SavedAttackFields & Pick) => { if (cancelled) return setLoadedAttack({ id: routeAttackId, loadSequence, targetSource: 'persisted', - mainConversationId: attack.conversation_id, - labels: attack.labels ?? {}, - operator: attack.operator ?? null, - target: attack.target ?? null, - targetUnbound: attack.target_unbound === true, - knownConversationIds: attack.selected_conversation_valid ? [attack.selected_conversation_id] : [], - relatedConversationCount: attack.related_conversation_count, - objective: attack.objective ?? '', - outcome: attack.outcome ?? 'undetermined', - automatedScore: attack.automated_score ?? null, - humanScore: attack.human_score ?? null, - chatScorer: attack.chat_scorer ?? null, - chatScorerInherited: attack.chat_scorer_inherited ?? false, - executionReadOnly: attack.execution_read_only ?? false, - manualContinuation: attack.manual_continuation ?? null, - lastResponseMessagePieceId: attack.last_response_message_piece_id, + ...fields, status: 'success', }) }) @@ -544,7 +592,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { }) // Drop a stale response once the route has moved on to another attack. return () => { cancelled = true } - }, [routeAttackId, routeConversationId]) + }, [routeAttackId, routeConversationId, routeAttackKey, scenarioResultId, metadataRevision]) // Only the attack named by the current URL may drive the chat. While a new // attack is loading, loadedAttack still holds the previous one, so this keeps @@ -563,6 +611,30 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { ) const isLoadingAttack = isNavigatingToCreatedAttack || (routeAttackId !== null && !readyAttack && !isAttackNotFound && !isAttackError) + + // While a scan runs the attack, follow its conversations; once its result is saved, show that in place. + const handleLiveAttackMetadata = useCallback((metadata: AttackMetadata) => { + setLoadedAttack((current) => { + if (current?.id !== metadata.attack_result_id || current.status !== 'success') return current + return metadata.in_progress + ? { + ...current, + mainConversationId: metadata.conversation_id, + relatedConversationCount: metadata.related_conversation_count, + lastResponseMessagePieceId: metadata.last_response_message_piece_id, + } + : { ...current, ...savedAttackFields(metadata) } + }) + }, []) + const handleLiveAttackGone = useCallback(() => { + validatedConversationForAttack.current = null + setMetadataRevision((current) => current + 1) + }, []) + useLiveAttackMetadata({ + attackResultId: readyAttack?.inProgress ? readyAttack.id : null, + onMetadata: handleLiveAttackMetadata, + onGone: handleLiveAttackGone, + }) const { activeTarget: resolvedChatTarget, resolutionStatus: targetResolutionStatus, @@ -592,8 +664,8 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { // just created or listed), so only the initial URL is checked. useEffect(() => { if (!readyAttack) return - if (validatedConversationForAttack.current === readyAttack.id) return - validatedConversationForAttack.current = readyAttack.id + if (validatedConversationForAttack.current === routeAttackKey) return + validatedConversationForAttack.current = routeAttackKey if (routeConversationId) { const isKnown = routeConversationId === readyAttack.mainConversationId || @@ -602,7 +674,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { navigate(attackRoutePath(readyAttack.id, scenarioResultId), { replace: true, state: location.state }) } } - }, [readyAttack, routeConversationId, navigate, scenarioResultId, findingEvidenceId, location.state]) + }, [readyAttack, routeConversationId, routeAttackKey, navigate, scenarioResultId, findingEvidenceId, location.state]) const handleNavigate = useCallback((view: ViewName) => { if (view === 'analytics') { @@ -763,6 +835,26 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { }) }, [location.search, navigate]) + const recordedConversations = readyAttack?.recordedConversations + const recordedRunId = readyAttack?.scenarioResultId + const recordedAttackId = readyAttack?.id + const readRecordedMessages = useCallback(async (conversationId: string): Promise => { + if (!recordedRunId || !recordedConversations?.some((record: ScenarioActiveConversation) => record.conversation_id === conversationId)) { + throw new Error('This conversation is not part of the recorded scan attack.') + } + return scenariosApi.getRunConversation(recordedRunId, conversationId) + }, [recordedRunId, recordedConversations]) + const readRecordedReferences = useCallback(async (attackResultId: string): Promise => { + if (attackResultId !== recordedAttackId || !recordedConversations) { + throw new Error('This attack is not part of the recorded scan.') + } + return { + attack_result_id: attackResultId, main_conversation_id: null, + conversations: recordedConversations.map((record: ScenarioActiveConversation) => ({ conversation_id: record.conversation_id, message_count: null })), + total_count: recordedConversations.length, + } + }, [recordedAttackId, recordedConversations]) + const chatElement = isAttackNotFound || isAttackError || isEvidenceConversationMissing ? ( } /> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AttackResults/AttackAttemptDetails.styles.ts b/frontend/src/components/AttackResults/AttackAttemptDetails.styles.ts index 7e14d19372..419bb4415f 100644 --- a/frontend/src/components/AttackResults/AttackAttemptDetails.styles.ts +++ b/frontend/src/components/AttackResults/AttackAttemptDetails.styles.ts @@ -1,85 +1,76 @@ import { makeStyles, tokens } from '@fluentui/react-components' -import { NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' +import { MINIMUM_TOUCH_TARGET_SIZE, NARROW_VIEWPORT_QUERY, mobileTouchTarget } from '@/styles/touchTargets' export const useAttackAttemptDetailsStyles = makeStyles({ content: { display: 'flex', flexDirection: 'column', - gap: tokens.spacingVerticalL, + gap: tokens.spacingVerticalXL, + minWidth: 0, overflowWrap: 'anywhere', }, - summary: { - display: 'grid', - gridTemplateColumns: 'repeat(5, minmax(8rem, 1fr))', - gap: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`, + framed: { + padding: `${tokens.spacingVerticalXL} ${tokens.spacingHorizontalXL}`, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground1, [NARROW_VIEWPORT_QUERY]: { - gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalL}`, }, }, - section: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalXS, + divided: { + paddingTop: tokens.spacingVerticalXL, + borderTop: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, }, - sectionLabel: { - color: tokens.colorNeutralForeground3, + components: { + rowGap: tokens.spacingVerticalM, }, - surface: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalM, - padding: tokens.spacingVerticalL, - borderLeft: `3px solid ${tokens.colorBrandStroke1}`, - borderRadius: tokens.borderRadiusMedium, - backgroundColor: tokens.colorNeutralBackground2, + objective: { + display: 'block', + maxWidth: '75ch', + whiteSpace: 'pre-wrap', }, - bodyText: { - margin: 0, + rationale: { + maxWidth: '75ch', whiteSpace: 'pre-wrap', - overflowWrap: 'anywhere', + color: tokens.colorNeutralForeground2, }, - collapsedSeedText: { - display: '-webkit-box', - overflow: 'hidden', - WebkitBoxOrient: 'vertical', - WebkitLineClamp: 5, + meta: { + color: tokens.colorNeutralForeground3, + fontVariantNumeric: 'tabular-nums', }, - badgeList: { - display: 'flex', - flexWrap: 'wrap', - gap: tokens.spacingHorizontalXS, + error: { + alignSelf: 'stretch', }, - seed: { + retry: { ...mobileTouchTarget }, + findings: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS, + margin: 0, + padding: 0, + listStyleType: 'none', }, - seedImage: { - display: 'block', - width: 'auto', - maxWidth: '100%', - maxHeight: '24rem', - borderRadius: tokens.borderRadiusMedium, - objectFit: 'contain', - }, - seedMedia: { - width: 'min(36rem, 100%)', - }, - seedToggle: { - alignSelf: 'flex-start', - minWidth: 0, - paddingInline: 0, - color: tokens.colorBrandForegroundLink, + finding: { + display: 'grid', + gridTemplateColumns: 'auto minmax(0, 1fr)', + alignItems: 'baseline', + gap: tokens.spacingHorizontalS, }, - fileLink: { - color: tokens.colorBrandForegroundLink, + actions: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalM, + [NARROW_VIEWPORT_QUERY]: { flexDirection: 'column', alignItems: 'stretch' }, }, conversationLink: { display: 'inline-flex', alignItems: 'center', gap: tokens.spacingHorizontalS, - alignSelf: 'flex-start', + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + boxSizing: 'border-box', color: tokens.colorBrandForegroundLink, backgroundColor: tokens.colorBrandBackground2, borderRadius: tokens.borderRadiusMedium, @@ -87,5 +78,6 @@ export const useAttackAttemptDetailsStyles = makeStyles({ fontWeight: tokens.fontWeightSemibold, textDecorationLine: 'none', ':hover': { backgroundColor: tokens.colorBrandBackground2Hover }, + [NARROW_VIEWPORT_QUERY]: { justifyContent: 'center' }, }, }) diff --git a/frontend/src/components/AttackResults/AttackAttemptDetails.test.tsx b/frontend/src/components/AttackResults/AttackAttemptDetails.test.tsx new file mode 100644 index 0000000000..104be49025 --- /dev/null +++ b/frontend/src/components/AttackResults/AttackAttemptDetails.test.tsx @@ -0,0 +1,76 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' + +import { attacksApi } from '@/services/api' +import type { ScenarioObjectiveScorer, ScenarioProgressResult } from '@/types' + +import AttackAttemptDetails from './AttackAttemptDetails' + +jest.mock('@/services/api', () => ({ attacksApi: { getAttackFindings: jest.fn() } })) + +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children} +} + +const attempt: ScenarioProgressResult = { + attack_result_id: 'saved-attack', conversation_id: 'saved-conversation', atomic_group_id: 'group', + atomic_attack_name: 'PromptSendingAttack', seed_group_id: 'seed', outcome: 'success', + execution_time_ms: 0, total_retries: 0, timestamp: '2026-01-01T00:00:00Z', retries: [], + score: { scorer_name: 'recorded judge', score_type: 'true_false', status: 'complete', + score_value: 'true', score_rationale: 'The objective was met.' }, +} +const recordedScorer: ScenarioObjectiveScorer = { + component_name: 'old registry alias', parameters: { scorer_type: 'true_false', eval_hash: 'recorded-configuration' }, + children: {}, metrics: { accuracy: 0, recall: 0, num_responses: 100 }, +} + +describe('AttackAttemptDetails', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(attacksApi.getAttackFindings).mockResolvedValue({ attack_result_id: attempt.attack_result_id, items: [] }) + }) + + it('should retain readable recorded metrics without registry lookup and keep individual attack score details', async () => { + const user = userEvent.setup() + const rendered = render() + await user.click(screen.getByRole('button', { name: 'Evaluation metrics for old registry alias' })) + const dialog = screen.getByRole('dialog') + expect(dialog).toHaveTextContent('Quality of the recorded scorer configuration') + expect(within(dialog).getByRole('group', { name: 'Accuracy' })).toHaveTextContent('0.00%') + expect(within(dialog).getByRole('group', { name: 'Num responses' })).toHaveTextContent('100') + await user.keyboard('{Escape}') + rendered.rerender() + await user.click(screen.getByRole('button', { name: 'Evaluation metrics for renamed or unregistered judge' })) + expect(screen.getByRole('dialog')).toHaveTextContent('0.00%') + await user.keyboard('{Escape}') + await user.click(screen.getByRole('button', { name: 'True: View attack score' })) + expect(screen.getByText('Score details')).toBeVisible() + expect(screen.getByText('Final score')).toBeVisible() + expect(screen.getByText('recorded judge')).toBeVisible() + }) + + it('should distinguish failed findings reads from empty links and recover with a keyboard retry', async () => { + const user = userEvent.setup() + jest.mocked(attacksApi.getAttackFindings).mockRejectedValueOnce({ + isAxiosError: true, response: { status: 503, data: { detail: 'Findings unavailable' } }, + }).mockResolvedValueOnce({ + attack_result_id: attempt.attack_result_id, items: [{ + id: 'finding', operation_id: 'operation', operation_name: 'Recorded operation', evidence_id: 'evidence', + title: 'Recovered attached finding', description: 'Stored evidence', severity: 'low', created_at: attempt.timestamp, + }], + }) + render() + expect(await screen.findByText(/Could not load attached findings: Findings unavailable/)).toBeVisible() + expect(screen.queryByRole('list', { name: 'Attached findings' })).not.toBeInTheDocument() + const retry = screen.getByRole('button', { name: 'Retry findings' }) + retry.focus() + await user.keyboard('{Enter}') + expect(await screen.findByRole('list', { name: 'Attached findings' })).toHaveTextContent('Recovered attached finding') + expect(attacksApi.getAttackFindings).toHaveBeenCalledTimes(2) + expect(screen.queryByText(/Could not load attached findings/)).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/AttackResults/AttackAttemptDetails.tsx b/frontend/src/components/AttackResults/AttackAttemptDetails.tsx index d71974c01a..2a335d2c09 100644 --- a/frontend/src/components/AttackResults/AttackAttemptDetails.tsx +++ b/frontend/src/components/AttackResults/AttackAttemptDetails.tsx @@ -1,10 +1,15 @@ -import { useState } from 'react' - -import { Badge, MessageBar, MessageBarBody, Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, Text } from '@fluentui/react-components' +import { Button, MessageBar, MessageBarActions, MessageBarBody, Text, mergeClasses } from '@fluentui/react-components' import { Link } from 'react-router' -import RegistryIdentityRow from '@/components/Registry/RegistryIdentityRow' +import AttachToFindingsButton from '@/components/Chat/AttachToFindingsButton' +import DetailFacts, { DetailFact } from '@/components/DetailFacts' +import FindingSeverityBadge from '@/components/Operations/FindingSeverityBadge' +import OutcomeBadge from '@/components/OutcomeBadge' +import IdentityDisclosure from '@/components/Registry/IdentityDisclosure' +import ScorerEvaluationDetails from '@/components/Scorers/ScorerEvaluationDetails' +import { useAttackFindings } from '@/hooks/useAttackFindings' import type { + AttackDisplayOutcome, ScenarioActiveConversation, ScenarioAttackTechniqueDetails, ScenarioHierarchyNode, @@ -12,20 +17,16 @@ import type { ScenarioProgressResult, ScenarioRunPlanAtomicGroup, } from '@/types' +import { scoreSummaryFromProgressScore } from '@/utils/scoreSummary' -import ComponentIdentityDetails, { DetailMetric } from './ComponentIdentityDetails' -import ObjectiveScorerDetails from './ObjectiveScorerDetails' -import { - formatAttackSuccess, - formatDuration, - formatScore, - formatScoreRationale, - formatTimestamp, -} from './attackAttemptFormatting' +import { formatAttemptTiming, formatScoreRationale } from './attackAttemptFormatting' import { useAttackAttemptDetailsStyles } from './AttackAttemptDetails.styles' interface AttackAttemptDetailsProps { - readonly attempt: ScenarioProgressResult | ScenarioActiveConversation | ScenarioHierarchyNode + /** A saved result or hierarchy node, a running attack, or null before the attack starts. */ + readonly attempt: ScenarioProgressResult | ScenarioActiveConversation | ScenarioHierarchyNode | null + /** The attack has not started yet but will; otherwise a missing attempt is unavailable. */ + readonly preparing?: boolean readonly objective?: string | null readonly atomicGroup?: ScenarioRunPlanAtomicGroup | null readonly techniqueDetails?: ScenarioAttackTechniqueDetails | null @@ -33,107 +34,101 @@ interface AttackAttemptDetailsProps { /** Route to the attempt's conversation. Supplied by the host so this stays route-agnostic. */ readonly conversationPath: string | null readonly conversationState?: unknown + /** Offered first when attaching this attack to a finding. */ + readonly findingOperationId?: string | null + /** Frame the details as a standalone card when they are shown inline on a page. */ + readonly framed?: boolean } +/** One attack's objective and outcome, the components it ran with, its findings, and its conversation. */ export default function AttackAttemptDetails({ attempt, + preparing = false, objective, atomicGroup, techniqueDetails, objectiveScorer, conversationPath, conversationState, + findingOperationId, + framed = false, }: AttackAttemptDetailsProps) { const styles = useAttackAttemptDetailsStyles() - const [expanded, setExpanded] = useState>(new Set()) - const completed = 'outcome' in attempt ? attempt : null - const techniqueName = atomicGroup?.technique_name ?? atomicGroup?.display_group ?? attempt.atomic_attack_name - const seeds = 'seed_identifiers' in attempt ? attempt.seed_identifiers ?? [] : [] + const completed = attempt && 'outcome' in attempt ? attempt : null + const { findings, loading: findingsLoading, error: findingsError, refresh: refreshFindings } = useAttackFindings(completed?.attack_result_id) + const techniqueName = atomicGroup?.technique_name ?? atomicGroup?.display_group ?? attempt?.atomic_attack_name ?? 'Not recorded' + const seeds = attempt && 'seed_identifiers' in attempt ? attempt.seed_identifiers ?? [] : [] const datasetNames = [...new Set(seeds.flatMap((seed) => typeof seed.parameters.dataset_name === 'string' ? [seed.parameters.dataset_name] : []))] - const identities = [ - { key: 'technique', name: techniqueName, kind: 'Technique', type: techniqueDetails?.component_name ?? attempt.atomic_attack_name, identity: techniqueDetails }, - { key: 'scorer', name: objectiveScorer?.component_name ?? 'Not recorded', kind: 'Scorer', type: 'Objective scorer', identity: objectiveScorer }, - { key: 'dataset', name: datasetNames.join(', ') || 'Dataset seeds', kind: 'Dataset', type: 'Seed group', identity: seeds.length ? seeds : null }, - ] - const techniqueDescription = atomicGroup?.description ?? 'No description is available for this attack technique.' - const techniqueTags = atomicGroup?.tags ?? [] - const errorMessage = completed?.outcome === 'error' - ? completed.error_message ?? 'No error detail was persisted.' - : null - - function toggle(key: string): void { - setExpanded((current: Set) => { - const next = new Set(current) - if (next.has(key)) next.delete(key) - else next.add(key) - return next - }) - } + const outcome: AttackDisplayOutcome = completed ? completed.outcome + : attempt ? 'in_progress' : preparing ? 'preparing' : 'undetermined' + const rationale = completed + ? completed.outcome === 'error' ? 'This attack did not produce a scored outcome.' : formatScoreRationale(completed.score?.score_rationale) + : attempt || preparing ? 'The outcome will appear after scoring.' : 'Attack details are not available.' return ( -
-
- Objective - {objective ?? 'Objective text is not available.'} -
-
-
- {completed ? formatAttackSuccess(completed.outcome) : 'In progress'} -
- - {!completed ? 'The outcome will appear after scoring.' : completed.outcome === 'error' - ? 'This attack did not produce a scored outcome.' : formatScoreRationale(completed.score?.score_rationale)} - -
- {completed &&
- - - - -
} -
- Attack technique -
- {techniqueName} - {techniqueDescription} - {techniqueTags.length > 0 && ( -
- {techniqueTags.map((tag) => {tag})} -
- )} - {techniqueDetails && } -
-
-
- Objective Scorer - {objectiveScorer - ? - : Objective scorer information is unavailable for this attempt.} -
- {conversationPath ? ( - - {completed ? 'Open conversation' : 'Watch live conversation'} - - ) : ( - No conversation was saved for this result. - )} - - NameComponentType - {identities.map((item: typeof identities[number]) => item.identity ? ( - { toggle(item.key) }}> - {item.kind}{item.type} - +
+ + + {objective ?? 'Objective text is not available.'} + + + + {rationale} + {completed && + {formatAttemptTiming(completed)} + } + {completed?.outcome === 'error' && + + {completed.error_type ? `${completed.error_type}: ` : ''}{completed.error_message ?? 'No error detail was persisted.'} + + } + + + + + + + + + + 0 ? seeds : null} /> + + {(findings.length > 0 || findingsLoading || findingsError) && + {findingsLoading && Loading attached findings...} + {findingsError && + Could not load attached findings: {findingsError} + + } + {findings.length > 0 &&
    + {findings.map((finding) =>
  • + + {finding.title} +
  • )} +
} +
} +
+
+ {conversationPath ? ( + + {completed ? 'Open conversation' : 'Watch live conversation'} + + ) : !attempt && preparing ? ( + ) : ( - {item.name}{item.kind}No saved identity - ))} -
- {errorMessage && ( - - {completed?.error_type ? `${completed.error_type}: ` : ''}{errorMessage} - - )} + No conversation was saved for this result. + )} + {attempt && } +
) } diff --git a/frontend/src/components/AttackResults/ObjectiveScorerDetails.styles.ts b/frontend/src/components/AttackResults/ObjectiveScorerDetails.styles.ts deleted file mode 100644 index e5e194b13f..0000000000 --- a/frontend/src/components/AttackResults/ObjectiveScorerDetails.styles.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { makeStyles, tokens } from '@fluentui/react-components' - -export const useObjectiveScorerDetailsStyles = makeStyles({ - card: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalL, - padding: tokens.spacingVerticalL, - border: `1px solid ${tokens.colorNeutralStroke2}`, - borderRadius: tokens.borderRadiusLarge, - backgroundColor: tokens.colorNeutralBackground1, - }, - section: { - display: 'flex', - flexDirection: 'column', - gap: tokens.spacingVerticalM, - }, - hint: { - color: tokens.colorNeutralForeground3, - }, - documentationLink: { - alignSelf: 'flex-start', - color: tokens.colorBrandForegroundLink, - fontSize: tokens.fontSizeBase200, - }, -}) diff --git a/frontend/src/components/AttackResults/ObjectiveScorerDetails.tsx b/frontend/src/components/AttackResults/ObjectiveScorerDetails.tsx deleted file mode 100644 index b01a9104d9..0000000000 --- a/frontend/src/components/AttackResults/ObjectiveScorerDetails.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Text } from '@fluentui/react-components' - -import type { ScenarioObjectiveScorer } from '@/types' - -import ScorerMetricsDetails from '@/components/Scorers/ScorerMetricsDetails' -import ComponentIdentityDetails from './ComponentIdentityDetails' -import { useObjectiveScorerDetailsStyles } from './ObjectiveScorerDetails.styles' - -const SCORER_METRICS_DOCUMENTATION_URL = 'https://microsoft.github.io/PyRIT/latest/code/scoring/scorer-metrics/' -const OMITTED_SCORER_PARAMETERS = new Set(['scorer_type']) - -interface ObjectiveScorerDetailsProps { - readonly scorer: ScenarioObjectiveScorer -} - -export default function ObjectiveScorerDetails({ scorer }: ObjectiveScorerDetailsProps) { - const styles = useObjectiveScorerDetailsStyles() - return ( -
-
- -
-
- Accuracy Metrics - {scorer.metrics ? ( - - ) : ( - - Official evaluation has not been run for this scorer configuration. - - )} - - About scorer metrics - -
-
- ) -} diff --git a/frontend/src/components/AttackResults/attackAttemptFormatting.test.ts b/frontend/src/components/AttackResults/attackAttemptFormatting.test.ts index 7d7e20833d..e40824e624 100644 --- a/frontend/src/components/AttackResults/attackAttemptFormatting.test.ts +++ b/frontend/src/components/AttackResults/attackAttemptFormatting.test.ts @@ -1,4 +1,4 @@ -import { objectivePreview } from './attackAttemptFormatting' +import { formatAttemptTiming, formatRetries, formatTimestamp, objectivePreview } from './attackAttemptFormatting' const LONE_SURROGATE_REGEX = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { expect(objectivePreview('', 'seed-456')).toBe('Objective unavailable (seed-456)') }) }) + +describe('formatRetries', () => { + it('says when there were no retries and pluralizes the rest', () => { + expect(formatRetries(0)).toBe('No retries') + expect(formatRetries(1)).toBe('1 retry') + expect(formatRetries(3)).toBe('3 retries') + }) +}) + +describe('formatAttemptTiming', () => { + it('joins duration, retries, and timestamp on one line', () => { + const timestamp = '2026-01-01T00:04:30Z' + expect(formatAttemptTiming({ execution_time_ms: 270_000, total_retries: 0, timestamp })) + .toBe(`4m 30s · No retries · ${formatTimestamp(timestamp)}`) + expect(formatAttemptTiming({ execution_time_ms: 5_000, total_retries: 2, timestamp })) + .toBe(`5s · 2 retries · ${formatTimestamp(timestamp)}`) + }) +}) diff --git a/frontend/src/components/AttackResults/attackAttemptFormatting.ts b/frontend/src/components/AttackResults/attackAttemptFormatting.ts index 65862007f9..f57a083b62 100644 --- a/frontend/src/components/AttackResults/attackAttemptFormatting.ts +++ b/frontend/src/components/AttackResults/attackAttemptFormatting.ts @@ -76,6 +76,22 @@ export function formatDuration(milliseconds: number): string { return `${seconds}s` } +export function formatRetries(retries: number): string { + if (retries === 0) return 'No retries' + return `${retries} ${retries === 1 ? 'retry' : 'retries'}` +} + +/** One line of attempt timing, such as "4m 30s · No retries · Jan 1, 2026, 12:00:05 AM". */ +export function formatAttemptTiming( + attempt: Pick, +): string { + return [ + formatDuration(attempt.execution_time_ms), + formatRetries(attempt.total_retries), + formatTimestamp(attempt.timestamp), + ].join(' · ') +} + export function objectivePreview(objective: string | null, fallbackId: string): string { if (!objective) { return `Objective unavailable (${fallbackId})` diff --git a/frontend/src/components/Chat/AttachToFindingsButton.styles.ts b/frontend/src/components/Chat/AttachToFindingsButton.styles.ts new file mode 100644 index 0000000000..76db3b84dd --- /dev/null +++ b/frontend/src/components/Chat/AttachToFindingsButton.styles.ts @@ -0,0 +1,9 @@ +import { makeStyles } from '@fluentui/react-components' + +import { mobileTouchTargetHeight } from '@/styles/touchTargets' + +export const useAttachToFindingsButtonStyles = makeStyles({ + button: { + ...mobileTouchTargetHeight, + }, +}) diff --git a/frontend/src/components/Chat/AttachToFindingsButton.test.tsx b/frontend/src/components/Chat/AttachToFindingsButton.test.tsx new file mode 100644 index 0000000000..e40b4677d1 --- /dev/null +++ b/frontend/src/components/Chat/AttachToFindingsButton.test.tsx @@ -0,0 +1,75 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' + +import { attacksApi, operationsApi } from '@/services/api' + +import AttachToFindingsButton from './AttachToFindingsButton' + +jest.mock('@/services/api', () => ({ + attacksApi: { getAttack: jest.fn() }, + operationsApi: { + get: jest.fn(), list: jest.fn(), listFindings: jest.fn(), attachFindingEvidence: jest.fn(), createFinding: jest.fn(), + getFindingOptions: jest.fn(), + }, +})) + +const OPERATION = { id: 'scan-operation', name: 'Scan operation', created_at: '2026-10-08T12:00:00Z' } +const FINDING = { + id: 'finding', operation_id: OPERATION.id, title: 'Proof', severity: 'low', description: '', + created_at: '2026-10-08T12:00:00Z', evidence_count: 0, +} + +function TestWrapper({ children }: { readonly children: React.ReactNode }) { + return {children} +} + +describe('AttachToFindingsButton', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(attacksApi.getAttack).mockResolvedValue({ operation_id: null } as never) + jest.mocked(operationsApi.get).mockResolvedValue(OPERATION as never) + jest.mocked(operationsApi.list).mockResolvedValue({ items: [{ ...OPERATION, finding_counts: {} }] } as never) + jest.mocked(operationsApi.listFindings).mockResolvedValue({ items: [FINDING], has_more: false, next_offset: null } as never) + jest.mocked(operationsApi.attachFindingEvidence).mockResolvedValue({ item: { id: 'evidence' }, created: true } as never) + }) + + it('should attach the attack to a finding in the default operation and report it', async () => { + const user = userEvent.setup() + const onAttached = jest.fn() + render( + + ) + + const button = screen.getByRole('button', { name: 'Attach to findings' }) + await user.click(button) + expect(await screen.findByRole('dialog')).toBeInTheDocument() + expect(operationsApi.get).toHaveBeenCalledWith(OPERATION.id) + await user.click(await screen.findByRole('radio', { name: /Proof/ })) + await user.click(screen.getByRole('button', { name: 'Attach attack' })) + + await waitFor(() => { expect(onAttached).toHaveBeenCalledTimes(1) }) + expect(operationsApi.attachFindingEvidence).toHaveBeenCalledWith(OPERATION.id, FINDING.id, { attack_result_id: 'attack-1' }) + await waitFor(() => { expect(button).toHaveFocus() }) + }) + + it('should not report an attachment when the dialog is cancelled', async () => { + const user = userEvent.setup() + const onAttached = jest.fn() + render() + + await user.click(screen.getByRole('button', { name: 'Attach to findings' })) + await user.click(await screen.findByRole('button', { name: 'Cancel' })) + + await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) + expect(onAttached).not.toHaveBeenCalled() + }) + + it('should explain why a running attack cannot be attached yet', () => { + render() + const button = screen.getByRole('button', { name: 'Attach to findings' }) + expect(button).toBeDisabled() + expect(button).toHaveAttribute('title', 'Available after the attack finishes') + }) +}) diff --git a/frontend/src/components/Chat/AttachToFindingsButton.tsx b/frontend/src/components/Chat/AttachToFindingsButton.tsx new file mode 100644 index 0000000000..8f5e06688a --- /dev/null +++ b/frontend/src/components/Chat/AttachToFindingsButton.tsx @@ -0,0 +1,37 @@ +import { useRef, useState } from 'react' + +import { Button } from '@fluentui/react-components' +import { AttachRegular } from '@fluentui/react-icons' + +import { useAttachToFindingsButtonStyles } from './AttachToFindingsButton.styles' +import FindingEvidenceDialog from './FindingEvidenceDialog' + +interface AttachToFindingsButtonProps { + readonly attackResultId: string + /** The Operation offered first when the attack has no Operation of its own. */ + readonly defaultOperationId?: string | null + /** Running attacks cannot be attached until they are saved. */ + readonly disabled?: boolean + /** Called after the attack is attached, such as to refresh `useAttackFindings`. */ + readonly onAttached?: () => void +} + +/** Opens the finding picker to attach one attack as evidence, returning focus to the button. */ +export default function AttachToFindingsButton({ + attackResultId, defaultOperationId = null, disabled = false, onAttached, +}: AttachToFindingsButtonProps) { + const styles = useAttachToFindingsButtonStyles() + const [open, setOpen] = useState(false) + const trigger = useRef(null) + return ( + <> + + + + ) +} diff --git a/frontend/src/components/Chat/AttackInProgressBar.styles.ts b/frontend/src/components/Chat/AttackInProgressBar.styles.ts new file mode 100644 index 0000000000..19ef8b9e89 --- /dev/null +++ b/frontend/src/components/Chat/AttackInProgressBar.styles.ts @@ -0,0 +1,33 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { MINIMUM_TOUCH_TARGET_SIZE, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' + +export const useAttackInProgressBarStyles = makeStyles({ + root: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalL, + flexShrink: 0, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXL}`, + borderTop: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`, + backgroundColor: tokens.colorNeutralBackground1, + [NARROW_VIEWPORT_QUERY]: { + paddingInline: tokens.spacingHorizontalL, + }, + }, + scanLink: { + display: 'inline-flex', + alignItems: 'center', + gap: tokens.spacingHorizontalXS, + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + color: tokens.colorBrandForegroundLink, + textDecorationLine: 'none', + ':hover': { textDecorationLine: 'underline' }, + ':focus-visible': { + outline: `${tokens.strokeWidthThick} solid ${tokens.colorStrokeFocus2}`, + borderRadius: tokens.borderRadiusSmall, + }, + }, +}) diff --git a/frontend/src/components/Chat/AttackInProgressBar.tsx b/frontend/src/components/Chat/AttackInProgressBar.tsx new file mode 100644 index 0000000000..52fb35c9e4 --- /dev/null +++ b/frontend/src/components/Chat/AttackInProgressBar.tsx @@ -0,0 +1,26 @@ +import { Link } from 'react-router' +import { ArrowLeftRegular } from '@fluentui/react-icons' + +import AttackActivity from '@/components/Scenarios/AttackActivity' + +import { useAttackInProgressBarStyles } from './AttackInProgressBar.styles' + +interface AttackInProgressBarProps { + /** The scan running the attack, when known. */ + readonly scanPath: string | null + /** Router state carried back to the scan, so its own back link still works. */ + readonly scanState?: unknown +} + +/** Takes the prompt bar's place while a scan is still running the attack shown in Chat. */ +export default function AttackInProgressBar({ scanPath, scanState }: AttackInProgressBarProps) { + const styles = useAttackInProgressBarStyles() + return ( +
+ + {scanPath && +
+ ) +} diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index dc004f000e..f5e6e6dd2f 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -32,6 +32,7 @@ import { } from "@/types"; import { attacksApi, convertersApi, scoresApi, targetsApi } from "@/services/api"; import * as messageMapper from "@/utils/messageMapper"; +import { LIVE_ATTACK_POLL_INTERVAL_MS } from "@/hooks/useLiveAttackMetadata"; jest.mock("./FindingEvidenceDialog", () => ({ __esModule: true, @@ -558,6 +559,123 @@ describe("ChatWindow Integration", () => { jest.restoreAllMocks(); }); + describe("running scan attack", () => { + const SCAN_ID = "123e4567-e89b-12d3-a456-426614174000"; + const props = { + ...defaultProps, attackResultId: "live-attack", conversationId: "branch-1", activeConversationId: "branch-1", + executionReadOnly: true, inProgress: true, attackScenarioResultId: SCAN_ID, objective: "Live objective", + }; + function liveRead(...texts: string[]): ConversationMessagesResponse { + return { + conversation_id: "branch-1", target_response_status: null, + messages: texts.map((text: string, index: number) => ({ + turn_number: index, role: index % 2 === 0 ? "user" : "assistant", created_at: "2026-01-01T00:00:00Z", + message_pieces: [{ + id: `piece-${index}`, original_value: text, converted_value: text, original_value_data_type: "text", + converted_value_data_type: "text", scores: [], response_error: "none", + }], + })), + }; + } + const unavailable = { isAxiosError: true, response: { status: 503, data: { detail: "Live read unavailable" } } }; + + beforeEach(() => { jest.useFakeTimers(); }); + afterEach(() => { jest.useRealTimers(); }); + + async function advancePoll(): Promise { + await act(async () => { await jest.advanceTimersByTimeAsync(LIVE_ATTACK_POLL_INTERVAL_MS); }); + } + + it("replaces the prompt with an in-progress bar and refreshes the transcript in place", async () => { + mockedAttacksApi.getMessages.mockResolvedValueOnce(liveRead("Live prompt")) + .mockResolvedValueOnce(liveRead("Live prompt", "Live response")); + render(); + expect(await screen.findByText("Live prompt", { exact: true })).toBeInTheDocument(); + const bubble = screen.getByTestId("message-bubble-0"); + const bar = screen.getByTestId("attack-in-progress-bar"); + expect(bar).toHaveTextContent("Attack in progress"); + expect(within(bar).getByRole("link", { name: "Back to scan" })).toHaveAttribute("href", `/scanner/history/${SCAN_ID}`); + expect(screen.queryByTestId("chat-input")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Continue manually" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Edit Conversation", exact: true })).toBeDisabled(); + expect(mockedAttacksApi.getMessages).toHaveBeenLastCalledWith("live-attack", "branch-1"); + + await advancePoll(); + expect(screen.getByText("Live response", { exact: true })).toBeInTheDocument(); + expect(screen.getByTestId("message-bubble-0")).toBe(bubble); + expect(mockedAttacksApi.getMessages).toHaveBeenCalledTimes(2); + }); + + it("reloads the saved transcript in place once the attack finishes and stops polling", async () => { + let finishSaved: (response: ConversationMessagesResponse) => void = () => { throw new Error("No saved read") }; + mockedAttacksApi.getMessages.mockResolvedValueOnce(liveRead("Live prompt", "Live response")) + .mockImplementationOnce(() => new Promise((resolve) => { finishSaved = resolve; })); + const rendered = render(); + expect(await screen.findByText("Live response", { exact: true })).toBeInTheDocument(); + rendered.rerender(); + expect(mockedAttacksApi.getMessages).toHaveBeenLastCalledWith("live-attack", "branch-1", { limit: 100 }); + expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument(); + expect(screen.getByText("Live response", { exact: true })).toBeInTheDocument(); + expect(screen.queryByTestId("attack-in-progress-bar")).not.toBeInTheDocument(); + expect(screen.getByTestId("chat-input")).toBeInTheDocument(); + await act(async () => { + finishSaved({ + ...liveRead("Live prompt", "Saved response"), pagination: { limit: 100, has_more: false, next_cursor: null }, + partial_message_sequences: [], + }); + }); + expect(screen.getByText("Saved response", { exact: true })).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Saved transcript paging" })).toHaveTextContent("End of saved transcript."); + await advancePoll(); + await advancePoll(); + expect(mockedAttacksApi.getMessages).toHaveBeenCalledTimes(2); + }); + + it("keeps loaded messages and offers Retry when a background refresh fails", async () => { + mockedAttacksApi.getMessages.mockResolvedValueOnce(liveRead("Live prompt")).mockRejectedValueOnce(unavailable) + .mockResolvedValueOnce(liveRead("Live prompt", "Recovered response")); + render(); + expect(await screen.findByText("Live prompt", { exact: true })).toBeInTheDocument(); + await advancePoll(); + expect(screen.getByText(/Could not load conversation: Live read unavailable/)).toBeInTheDocument(); + expect(screen.getByText("Live prompt", { exact: true })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Retry conversation load" })); + expect(await screen.findByText("Recovered response", { exact: true })).toBeInTheDocument(); + expect(screen.queryByText(/Could not load conversation/)).not.toBeInTheDocument(); + }); + + it("does not show the previous branch while another branch loads", async () => { + mockedAttacksApi.getMessages.mockResolvedValueOnce(liveRead("First branch prompt")) + .mockImplementationOnce(() => new Promise(() => {})); + const rendered = render(); + expect(await screen.findByText("First branch prompt", { exact: true })).toBeInTheDocument(); + rendered.rerender(); + expect(screen.getByTestId("loading-state")).toBeInTheDocument(); + expect(screen.queryByText("First branch prompt", { exact: true })).not.toBeInTheDocument(); + expect(mockedAttacksApi.getMessages).toHaveBeenLastCalledWith("live-attack", "branch-2"); + }); + + it("refreshes the whole conversation list while the attack runs", async () => { + mockedAttacksApi.getMessages.mockResolvedValue(liveRead("Live prompt")); + mockedAttacksApi.getConversations.mockResolvedValueOnce({ + attack_result_id: "live-attack", main_conversation_id: "branch-1", + conversations: [{ conversation_id: "branch-1", message_count: 1, created_at: "2026-01-01T00:00:00Z" }], + }).mockResolvedValue({ + attack_result_id: "live-attack", main_conversation_id: "branch-2", + conversations: [ + { conversation_id: "branch-1", message_count: 2, created_at: "2026-01-01T00:00:00Z" }, + { conversation_id: "branch-2", message_count: 1, created_at: "2026-01-01T00:00:01Z" }, + ], + }); + render(); + expect(await screen.findByTestId("conversation-item-branch-1")).toBeInTheDocument(); + expect(mockedAttacksApi.getConversations).toHaveBeenLastCalledWith("live-attack"); + const row = screen.getByTestId("conversation-item-branch-1"); + await advancePoll(); + expect(screen.getByTestId("conversation-item-branch-2")).toBeInTheDocument(); + expect(screen.getByTestId("conversation-item-branch-1")).toBe(row); + }); + }); describe("bounded saved evidence", () => { const props = { ...defaultProps, attackResultId: "saved-attack", conversationId: "saved-main", diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index a4edb8b31c..fb19b4cec4 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -120,6 +120,8 @@ import type { } from '../../types' import { isTargetResolutionBlocking, targetInfoMatchesTarget } from '../../utils/targetIdentity' import { attackConversationRoutePath, scenarioRunRoutePath } from '../../utils/routeParams' +import { LIVE_ATTACK_POLL_INTERVAL_MS } from '@/hooks/useLiveAttackMetadata' +import AttackInProgressBar from './AttackInProgressBar' import type { ViewName } from '../Sidebar/Navigation' import { CHAT_NOTICE_FADE_DURATION_MS, useChatWindowStyles } from './ChatWindow.styles' @@ -382,6 +384,10 @@ interface ChatWindowProps { chatScorer?: RegisteredComponentReference | null chatScorerInherited?: boolean executionReadOnly?: boolean + /** A scan is still running this attack: its transcript and conversations refresh until the result is saved. */ + inProgress?: boolean + /** The scan run that produced this attack, for returning to it while the attack runs. */ + attackScenarioResultId?: string | null manualContinuation?: ManualContinuation | null defaultChatScorer?: RegisteredComponentReference | null availableScorers?: ScorerInstance[] @@ -393,6 +399,9 @@ interface ChatWindowProps { scenarioResultId?: string | null onReturnToScan?: () => void findingEvidenceId?: string | null + /** Verified scan-scoped readers for recorded evidence without a saved attack result. */ + readConversationById?: (conversationId: string) => Promise + readConversations?: typeof attacksApi.getConversations } export default function ChatWindow({ @@ -432,6 +441,8 @@ export default function ChatWindow({ chatScorer = null, chatScorerInherited = false, executionReadOnly = false, + inProgress = false, + attackScenarioResultId = null, manualContinuation, defaultChatScorer, availableScorers = [], @@ -442,6 +453,8 @@ export default function ChatWindow({ scenarioResultId, onReturnToScan, findingEvidenceId, + readConversationById, + readConversations, }: ChatWindowProps) { const styles = useChatWindowStyles() const transcriptStatusId = useId() @@ -538,6 +551,12 @@ export default function ChatWindow({ const globalMarkdown = preferences.chatMarkdown const location = useLocation() const viewedConversationId = activeConversationId ?? conversationId + const transcriptKey = JSON.stringify([attackResultId, viewedConversationId]) + const [transcriptSource, setTranscriptSource] = useState({ key: transcriptKey, openedLive: inProgress }) + if (transcriptSource.key !== transcriptKey || (inProgress && !transcriptSource.openedLive)) { + setTranscriptSource({ key: transcriptKey, openedLive: inProgress }) + } + const preservesLiveLayout = transcriptSource.key === transcriptKey && transcriptSource.openedLive useLayoutEffect(() => { const area = chatAreaRef.current if (area && (executionReadOnly || wasSavedEvidenceRef.current)) area.scrollTop = 0 @@ -690,6 +709,9 @@ export default function ChatWindow({ } return frontendMessages }, []) + // Saved evidence is read in bounded pages; a running scan attack is re-read whole as it grows. + const pagesSavedEvidence = executionReadOnly && !inProgress && !readConversationById + const rendersSavedEvidence = pagesSavedEvidence && !preservesLiveLayout const { messages, setMessages, @@ -720,12 +742,17 @@ export default function ChatWindow({ conversationId, activeConversationId, findingEvidenceId, - messagePageSize: executionReadOnly ? SAVED_MESSAGE_PAGE_SIZE : undefined, + readConversationById, + messagePageSize: pagesSavedEvidence ? SAVED_MESSAGE_PAGE_SIZE : undefined, + pollIntervalMs: inProgress ? LIVE_ATTACK_POLL_INTERVAL_MS : undefined, sendingConversations, isSendInFlight, onConversationLoaded: handleConversationLoaded, }) const incompleteTranscriptReason = isTranscriptIncomplete ? INCOMPLETE_TRANSCRIPT_REASON : undefined + // Refreshes of a running attack, including the reload when it finishes, keep the shown transcript in place. + const refreshesInPlace = preservesLiveLayout && loadedConversationId === viewedConversationId + const liveScanId = scenarioResultId ?? attackScenarioResultId const loadedPieceSummary = `${loadedPieceCount} message ${loadedPieceCount === 1 ? 'piece' : 'pieces'} loaded.` const savedRecovery = viewedConversationId ? recoverableSends[viewedConversationId] @@ -944,7 +971,7 @@ export default function ChatWindow({ setSystemPrompt('') } - const isFindingActionDisabled = Boolean(editDraft !== null || !attackResultId || !activeConversationId + const isFindingActionDisabled = Boolean(readConversationById || editDraft !== null || !attackResultId || !activeConversationId || isEvidenceUnavailable || isLoadingAttack || isLoadingMessages || awaitingConversationLoad || isTranscriptIncomplete) const pending = draftHandoff.pendingImport @@ -2447,7 +2474,9 @@ export default function ChatWindow({ : isTargetResolutionLocked ? 'The source target cannot be safely resolved. Choose New attack.' : targetResolutionStatus !== 'unbound' && activeTarget ? historyReplayDisabledReason(activeTarget, editor.history) : undefined - const newAttackDisabledReason = !runtime.ready || !defaultsReady + const newAttackDisabledReason = readConversationById + ? 'No attack result was saved for this interrupted scan.' + : !runtime.ready || !defaultsReady ? newActivityBlockedReason ?? 'Default labels are not ready. Retry after default labels finish loading.' : !scorerSelectionReady ? 'The remembered chat scorer is loading. Wait for it to load.' : undefined @@ -2783,22 +2812,24 @@ export default function ChatWindow({ controller={converters} /> )} - 0} disabledReason={inputDisabledReasons.join(' ')} onFiles={(files: File[]) => inputBoxRef.current?.addFiles(files)}> {toolbarContainer ? createPortal(toolbar, toolbarContainer) : toolbar} - {executionReadOnly && + {executionReadOnly && !inProgress && - This automated result is read-only. Continue in a separate manual attack to keep the original execution unchanged. + {readConversationById + ? 'Recorded conversation from an interrupted scan. No attack outcome was saved.' + : 'This automated result is read-only. Continue in a separate manual attack to keep the original execution unchanged.'} - + } } {manualContinuation && Manual continuation of{' '} @@ -2938,9 +2969,9 @@ export default function ChatWindow({ getBranchConversationDescription={(index: number) => activeTarget && canBranchFirstPrompt(activeTarget, messages, index) ? 'Restore this prompt as an unsent draft in a new conversation in the same attack. The response is not copied.' : undefined} - isLoading={isLoadingAttack || isLoadingMessages || awaitingConversationLoad} - autoScroll={!executionReadOnly} - savedEvidence={executionReadOnly} + isLoading={isLoadingAttack || awaitingConversationLoad || (isLoadingMessages && !refreshesInPlace)} + savedEvidence={rendersSavedEvidence} + announceUpdates={!executionReadOnly || inProgress || preservesLiveLayout} globalMarkdown={globalMarkdown} onEditConversation={executionReadOnly ? undefined : () => { void beginEdit() }} />} @@ -3014,7 +3045,8 @@ export default function ChatWindow({ )} - } { jest.clearAllMocks(); }); + describe("live polling", () => { + const conversation = (id: string, count: number): ConversationSummary => ({ conversation_id: id, message_count: count }) + beforeEach(() => { jest.useFakeTimers(); }); + afterEach(() => { jest.useRealTimers(); }); + + it("should settle a slow read without overlapping polls and discard a prior attack's late response", async () => { + let finishFirst: (response: AttackConversationsResponse) => void = () => { throw new Error("No first read"); }; + let finishNext: (response: AttackConversationsResponse) => void = () => { throw new Error("No next read"); }; + mockedAttacksApi.getConversations.mockReset(); + mockedAttacksApi.getConversations + .mockResolvedValue({ attack_result_id: "other", main_conversation_id: null, conversations: [] }) + .mockImplementationOnce(() => new Promise((resolve) => { finishFirst = resolve; })) + .mockImplementationOnce(() => new Promise((resolve) => { finishNext = resolve; })); + const rendered = render(); + await act(async () => { await jest.advanceTimersByTimeAsync(7_500); }); + expect(mockedAttacksApi.getConversations).toHaveBeenCalledTimes(1); + await act(async () => { + finishFirst({ attack_result_id: defaultProps.attackResultId, main_conversation_id: "attack-123", conversations: [conversation("attack-123", 1)] }); + }); + expect(screen.getByTestId("conversation-item-attack-123")).toBeInTheDocument(); + await act(async () => { await jest.advanceTimersByTimeAsync(2_500); }); + expect(mockedAttacksApi.getConversations).toHaveBeenCalledTimes(2); + rendered.rerender(); + await act(async () => { + finishNext({ attack_result_id: defaultProps.attackResultId, main_conversation_id: "stale", conversations: [conversation("stale", 1)] }); + }); + expect(screen.queryByTestId("conversation-item-stale")).not.toBeInTheDocument(); + rendered.unmount(); + const calls = mockedAttacksApi.getConversations.mock.calls.length; + await act(async () => { await jest.advanceTimersByTimeAsync(10_000); }); + expect(mockedAttacksApi.getConversations).toHaveBeenCalledTimes(calls); + }); + + it("should refresh at the poll interval, keep rows through a failed read, and recover", async () => { + mockedAttacksApi.getConversations + .mockResolvedValueOnce({ attack_result_id: "ar-attack-123", main_conversation_id: "attack-123", conversations: [conversation("attack-123", 1)] }) + .mockRejectedValueOnce({ isAxiosError: true, response: { status: 503, data: { detail: "Live references unavailable" } } }) + .mockResolvedValueOnce({ + attack_result_id: "ar-attack-123", main_conversation_id: "branch-2", + conversations: [conversation("attack-123", 2), conversation("branch-2", 1)], + }); + render(); + const row = await screen.findByTestId("conversation-item-attack-123"); + await act(async () => { await jest.advanceTimersByTimeAsync(2_500); }); + expect(screen.getByText("Live references unavailable")).toBeInTheDocument(); + expect(screen.getByTestId("conversation-item-attack-123")).toBe(row); + expect(screen.getByTestId("conversation-retry-btn")).toBeInTheDocument(); + await act(async () => { await jest.advanceTimersByTimeAsync(2_500); }); + expect(screen.queryByText("Live references unavailable")).not.toBeInTheDocument(); + expect(screen.getByTestId("conversation-item-branch-2")).toBeInTheDocument(); + expect(screen.getByTestId("conversation-item-attack-123")).toBe(row); + expect(mockedAttacksApi.getConversations.mock.calls).toEqual([["ar-attack-123"], ["ar-attack-123"], ["ar-attack-123"]]); + }); + }); describe("bounded saved references", () => { const props = { ...defaultProps, referencePageSize: 50 }; const references: ConversationSummary[] = Array.from({ length: 55 }, (_value: unknown, index: number) => ({ diff --git a/frontend/src/components/Chat/ConversationPanel.tsx b/frontend/src/components/Chat/ConversationPanel.tsx index 356ca08700..54647fffd8 100644 --- a/frontend/src/components/Chat/ConversationPanel.tsx +++ b/frontend/src/components/Chat/ConversationPanel.tsx @@ -65,6 +65,10 @@ interface ConversationPanelProps { refreshKey?: number /** Saved evidence pages references independently from the selected transcript. */ referencePageSize?: number + /** Re-reads the unpaged list at this interval, keeping its rows, such as while a scan runs the attack. */ + pollIntervalMs?: number + /** A verified read-only source for interrupted scan conversations without a saved result. */ + readConversations?: typeof attacksApi.getConversations } export default function ConversationPanel({ @@ -77,6 +81,8 @@ export default function ConversationPanel({ lockedReason, refreshKey, referencePageSize, + pollIntervalMs, + readConversations = attacksApi.getConversations, }: ConversationPanelProps) { const styles = useConversationPanelStyles() const [unpagedConversations, setUnpagedConversations] = useState([]) @@ -98,6 +104,14 @@ export default function ConversationPanel({ // calling setState synchronously in an effect body. const [retryCount, setRetryCount] = useState(0) const selectionRefreshId = referencePageSize === undefined ? activeConversationId : null + const [pollCount, setPollCount] = useState(0) + useEffect(() => { + if (!pollIntervalMs || !attackResultId) return + const timer = setInterval(() => { + if (activeReadRef.current === null) setPollCount((count: number) => count + 1) + }, pollIntervalMs) + return () => { clearInterval(timer) } + }, [pollIntervalMs, attackResultId]) const saveReferencePage = useCallback((page: SavedReferencePage): void => { savedReferencePageRef.current = page @@ -110,7 +124,7 @@ export default function ConversationPanel({ savedReferencePageRef.current = null } return () => { activeReadRef.current = null } - }, [attackResultId, referencePageSize, refreshKey, retryCount, selectionRefreshId]) + }, [attackResultId, referencePageSize, refreshKey, retryCount, selectionRefreshId, readConversations]) useEffect(() => { if (!attackResultId) { @@ -121,8 +135,8 @@ export default function ConversationPanel({ const readId = ++nextReadIdRef.current activeReadRef.current = readId const request = referencePageSize === undefined - ? attacksApi.getConversations(attackResultId) - : attacksApi.getConversations(attackResultId, { limit: referencePageSize }) + ? readConversations(attackResultId) + : readConversations(attackResultId, { limit: referencePageSize }) request .then((response) => { if (cancelled || activeReadRef.current !== readId) return @@ -150,7 +164,7 @@ export default function ConversationPanel({ return () => { cancelled = true } - }, [attackResultId, referencePageSize, selectionRefreshId, refreshKey, retryCount, saveReferencePage]) + }, [attackResultId, referencePageSize, selectionRefreshId, refreshKey, retryCount, pollCount, saveReferencePage, readConversations]) // Reset attack/paging identity, but retain keyed rows during manual background // refreshes so keyboard focus survives. Adjust state during render instead of @@ -187,7 +201,7 @@ export default function ConversationPanel({ let restartRequired = false const isCurrentRead = (): boolean => activeReadRef.current === readId && savedReferencePageRef.current === page try { - const incoming = await attacksApi.getConversations(page.attackResultId, { limit: page.pageSize, cursor }) + const incoming = await readConversations(page.attackResultId, { limit: page.pageSize, cursor }) if (!isCurrentRead()) return restartRequired = true const response = mergeReferencePage(page.response, incoming, page.attackResultId, cursor, page.consumedCursors) @@ -363,7 +377,7 @@ export default function ConversationPanel({ /> - {conv.message_count} + {conv.message_count ?? 'Not counted'} diff --git a/frontend/src/components/Chat/FindingEvidenceDialog.tsx b/frontend/src/components/Chat/FindingEvidenceDialog.tsx index d38aa2e7eb..4162ee49c4 100644 --- a/frontend/src/components/Chat/FindingEvidenceDialog.tsx +++ b/frontend/src/components/Chat/FindingEvidenceDialog.tsx @@ -28,6 +28,8 @@ interface FindingEvidenceDialogProps { onOpenChange: (open: boolean) => void /** Receives focus when the dialog closes, such as the control that opened it. */ returnFocusRef?: React.RefObject + /** Called after the attack is linked to a finding, new or existing. */ + onAttached?: () => void } interface AttachmentSource { @@ -36,7 +38,7 @@ interface AttachmentSource { } export default function FindingEvidenceDialog({ - attackResultId, disabled, defaultOperationId = null, open, onOpenChange, returnFocusRef, + attackResultId, disabled, defaultOperationId = null, open, onOpenChange, returnFocusRef, onAttached, }: FindingEvidenceDialogProps) { const styles = useFindingEvidenceDialogStyles() const viewKey = attackResultId @@ -144,6 +146,7 @@ export default function FindingEvidenceDialog({ setCreating(false) onOpenChange(false) setNotice({ key: viewKey, text: `Finding created and attack attached: ${finding.title}` }) + onAttached?.() } const attach = async (): Promise => { @@ -158,6 +161,7 @@ export default function FindingEvidenceDialog({ }) setNotice({ key: viewKey, text: response.created ? 'Attached' : 'Already attached' }) onOpenChange(false) + onAttached?.() } catch (cause: unknown) { setAttachError(toApiError(cause).detail) } finally { diff --git a/frontend/src/components/Chat/MessageList.styles.ts b/frontend/src/components/Chat/MessageList.styles.ts index 5499dadbbc..3a8b59b384 100644 --- a/frontend/src/components/Chat/MessageList.styles.ts +++ b/frontend/src/components/Chat/MessageList.styles.ts @@ -235,50 +235,6 @@ export const useMessageListStyles = makeStyles({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, - scoreSurface: { - display: 'flex', - flexDirection: 'column', - rowGap: tokens.spacingVerticalXS, - width: 'min(360px, calc(100vw - 64px))', - minWidth: 0, - maxWidth: '100%', - }, - scoreRow: { - display: 'flex', - alignItems: 'flex-start', - columnGap: tokens.spacingHorizontalS, - width: '100%', - minWidth: 0, - }, - scoreLabel: { - flex: '0 0 72px', - color: tokens.colorNeutralForeground2, - }, - scoreValue: { - display: 'block', - flex: '0 1 auto', - minWidth: 0, - maxWidth: '100%', - height: 'auto', - whiteSpace: 'normal', - overflowWrap: 'anywhere', - wordBreak: 'break-word', - }, - scoreRationale: { - display: 'flex', - flexDirection: 'column', - rowGap: tokens.spacingVerticalXXS, - marginTop: tokens.spacingVerticalXS, - paddingTop: tokens.spacingVerticalXS, - borderTop: `1px solid ${tokens.colorNeutralStroke2}`, - }, - scoreRationaleText: { - color: tokens.colorNeutralForeground2, - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - maxHeight: '30vh', - overflowY: 'auto', - }, loadingEllipsis: { fontSize: tokens.fontSizeBase500, animationName: { diff --git a/frontend/src/components/Chat/MessageList.test.tsx b/frontend/src/components/Chat/MessageList.test.tsx index bffb69d524..e8ea99f7cd 100644 --- a/frontend/src/components/Chat/MessageList.test.tsx +++ b/frontend/src/components/Chat/MessageList.test.tsx @@ -49,26 +49,68 @@ describe("MessageList", () => { it("should label a split turn and preserve its bubble when ordered evidence is extended", () => { const first = { ...mockMessages[0], sequence: 0, isPartial: true }; - const rendered = render(); + const rendered = render(); const bubble = screen.getByTestId("message-bubble-0"); expect(screen.getByText("Incomplete turn")).toBeVisible(); - rendered.rerender(); expect(screen.getByTestId("message-bubble-1")).toBe(bubble); expect(screen.queryByText("Incomplete turn")).not.toBeInTheDocument(); }); - it("should keep saved evidence in place while retaining live Chat auto-scroll", () => { - const scroll = jest.mocked(Element.prototype.scrollIntoView); - scroll.mockClear(); - const rendered = render(); - rendered.rerender(); - expect(scroll).not.toHaveBeenCalled(); + it("should open at the latest message and follow new ones only while the reader stays near the end", () => { + const rendered = render(); + const list = screen.getByTestId("message-list"); + Object.defineProperties(list, { + scrollHeight: { configurable: true, writable: true, value: 1000 }, + clientHeight: { configurable: true, value: 200 }, + }); + rendered.rerender(); + expect(list.scrollTop).toBe(1000); + list.scrollTop = 770; + fireEvent.scroll(list); + Object.defineProperty(list, "scrollHeight", { value: 1200 }); rendered.rerender(); - expect(scroll).toHaveBeenCalledTimes(1); + expect(list.scrollTop).toBe(1200); + list.scrollTop = 100; + fireEvent.scroll(list); + Object.defineProperty(list, "scrollHeight", { value: 1400 }); + rendered.rerender(); + expect(list.scrollTop).toBe(100); + expect(screen.getByTestId("message-list")).toBe(list); + expect(Element.prototype.scrollIntoView).not.toHaveBeenCalled(); + }); + + it("should preserve historical saved evidence position instead of following its inner non-scrolling body", () => { + const rendered = render(); + const list = screen.getByTestId("message-list"); + Object.defineProperties(list, { + scrollHeight: { configurable: true, writable: true, value: 900 }, + clientHeight: { configurable: true, value: 300 }, + }); + rendered.rerender(); + expect(list.scrollTop).toBe(0); + expect(screen.queryByRole("log")).not.toBeInTheDocument(); + list.scrollTop = 200; + fireEvent.scroll(list); + rendered.rerender(); + expect(list.scrollTop).toBe(200); }); + it("should announce appended live messages politely without replacing existing keyed bubbles", () => { + const rendered = render(); + const log = screen.getByRole("log"); + const bubble = screen.getByTestId("message-bubble-0"); + expect(log).toHaveAttribute("aria-live", "polite"); + expect(log).toHaveAttribute("aria-relevant", "additions text"); + expect(log).toHaveAttribute("aria-atomic", "false"); + rendered.rerender(); + expect(screen.getByRole("log")).toBe(log); + expect(screen.getByTestId("message-bubble-0")).toBe(bubble); + rendered.rerender(); + expect(screen.getByTestId("message-bubble-0")).toBe(bubble); + }); it.each([2, 3])("should retain gapped sequence %s beside optimistic user and loading rows without duplicate keys", (sequence: number) => { const errors = jest.spyOn(console, "error").mockImplementation(() => {}); const stored: Message[] = [ diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index c15deef3e0..f4c76d0d91 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type UIEvent } from 'react' import { Text, Avatar, @@ -31,9 +31,11 @@ import MarkdownContent from '@/components/Markdown/MarkdownContent' import MediaPreview from '@/components/Media/MediaPreview' import type { DisplayScore, Message, MessageAttachment } from '@/types' +import { scoreSummaryFromBackendScore, scoreSummaryValue } from '@/utils/scoreSummary' import { useMessageListStyles } from './MessageList.styles' import { formatStructuredMessage, getRenderMessagePieces } from './messageViewModel' +import ScoreDetails from './ScoreDetails' interface MessageListProps { onEditConversation?: () => void @@ -51,60 +53,22 @@ interface MessageListProps { getBranchConversationDescription?: (messageIndex: number) => string | undefined /** True while loading a historical attack's messages */ isLoading?: boolean - /** Live Chat follows the latest response; saved evidence keeps the reader's position. */ - autoScroll?: boolean /** Saved evidence uses one scroll surface with its controls on narrow screens. */ savedEvidence?: boolean + /** Announce new live/manual messages, not an initial page of historical evidence. */ + announceUpdates?: boolean /** Conversation-wide default: render message text as Markdown. */ globalMarkdown?: boolean } function scoreDisplayValue(score: DisplayScore): string { - // An undetermined score has no value, so name its status instead. - return score.score_value ?? score.status ?? '' + return scoreSummaryValue(scoreSummaryFromBackendScore(score)) } function scoreDisplayLabel(score: DisplayScore): string { return `${score.is_objective_score ? 'Final score' : 'Score'}: ${scoreDisplayValue(score)}` } -function ScoreDetails({ score, testId }: { score: DisplayScore; testId: string }) { - const styles = useMessageListStyles() - const categories = score.score_category?.filter(Boolean) ?? [] - - return ( -
- Score details -
- Score - {scoreDisplayValue(score)} -
-
- Scorer - {score.scorer_type} -
-
- Result role - - {score.is_objective_score ? 'Final score' : 'Supporting score'} - -
- {categories.length > 0 && ( -
- Category - {categories.join(', ')} -
- )} - {score.score_rationale && ( -
- Rationale - {score.score_rationale} -
- )} -
- ) -} - function MessageScore({ score, groupId, scoreIndex }: { score: DisplayScore; groupId: string | number; scoreIndex: number }) { const styles = useMessageListStyles() const displayValue = scoreDisplayValue(score) @@ -128,7 +92,7 @@ function MessageScore({ score, groupId, scoreIndex }: { score: DisplayScore; gro - + ) @@ -165,6 +129,8 @@ interface ScoreOverflowMenuProps { const SCORE_TAB_WIDTH_PX = 152 const SCORE_TAB_GAP_PX = 4 const SCORE_OVERFLOW_BUTTON_WIDTH_PX = 112 +// Readers this close to the end keep following new messages; scrolling further up keeps their place. +const FOLLOW_LATEST_THRESHOLD_PX = 48 function scoreTabLabel({ score, @@ -427,7 +393,7 @@ function MessageScores({ scores, groupId }: { scores: DisplayScore[]; groupId: s id={`message-score-panel-${groupId}`} aria-labelledby={`message-score-tab-${groupId}-${selectedIndex}`} > - + @@ -445,10 +411,12 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConversation, onCopyToNewAttack, onUseInTechnique, copyConversationDisabled = false, copyConversationDisabledReason, newConversationDisabledReason, getBranchConversationDisabledReason, getBranchConversationDescription, copyToInputDisabledReason, - isLoading, autoScroll = true, savedEvidence = false, globalMarkdown = false, onEditConversation, + isLoading, savedEvidence = false, announceUpdates = !savedEvidence, globalMarkdown = false, onEditConversation, }: MessageListProps) { const styles = useMessageListStyles() - const messagesEndRef = useRef(null) + const listRef = useRef(null) + const followedListRef = useRef(null) + const followLatestRef = useRef(true) const copyMenuId = useId() const isCopyConversationDisabled = copyConversationDisabled || Boolean(copyConversationDisabledReason) @@ -472,9 +440,16 @@ export default function MessageList({ } } - useEffect(() => { - if (autoScroll) messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, autoScroll]) + // Historical pages keep their reading position; live/manual transcripts follow near the end. + useLayoutEffect(() => { + const list = listRef.current + if (!list || savedEvidence) return + if (list !== followedListRef.current) { + followedListRef.current = list + followLatestRef.current = true + } + if (followLatestRef.current) list.scrollTop = list.scrollHeight + }, [messages, savedEvidence]) if (isLoading) { return ( @@ -495,7 +470,13 @@ export default function MessageList({ } return ( -
{ +
) => { + const list = event.currentTarget + followLatestRef.current = list.scrollHeight - list.clientHeight - list.scrollTop <= FOLLOW_LATEST_THRESHOLD_PX + }} onDoubleClick={(event) => { if (event.target instanceof Element && event.target.closest('button, a, input, textarea, select, audio, video, [role="button"]')) return onEditConversation?.() }}> @@ -747,7 +728,6 @@ export default function MessageList({
) })} -
) } diff --git a/frontend/src/components/Chat/ScoreDetails.styles.ts b/frontend/src/components/Chat/ScoreDetails.styles.ts new file mode 100644 index 0000000000..c47dccc28d --- /dev/null +++ b/frontend/src/components/Chat/ScoreDetails.styles.ts @@ -0,0 +1,48 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useScoreDetailsStyles = makeStyles({ + surface: { + display: 'flex', + flexDirection: 'column', + rowGap: tokens.spacingVerticalXS, + width: 'min(360px, calc(100vw - 64px))', + minWidth: 0, + maxWidth: '100%', + }, + row: { + display: 'flex', + alignItems: 'flex-start', + columnGap: tokens.spacingHorizontalS, + width: '100%', + minWidth: 0, + }, + label: { + flex: '0 0 72px', + color: tokens.colorNeutralForeground2, + }, + value: { + display: 'block', + flex: '0 1 auto', + minWidth: 0, + maxWidth: '100%', + height: 'auto', + whiteSpace: 'normal', + overflowWrap: 'anywhere', + wordBreak: 'break-word', + }, + rationale: { + display: 'flex', + flexDirection: 'column', + rowGap: tokens.spacingVerticalXXS, + marginTop: tokens.spacingVerticalXS, + paddingTop: tokens.spacingVerticalXS, + borderTop: `1px solid ${tokens.colorNeutralStroke2}`, + }, + rationaleText: { + color: tokens.colorNeutralForeground2, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + maxHeight: '30vh', + overflowY: 'auto', + }, +}) diff --git a/frontend/src/components/Chat/ScoreDetails.test.tsx b/frontend/src/components/Chat/ScoreDetails.test.tsx new file mode 100644 index 0000000000..e162cc3bd0 --- /dev/null +++ b/frontend/src/components/Chat/ScoreDetails.test.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react' + +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen } from '@testing-library/react' + +import ScoreDetails from './ScoreDetails' + +function TestWrapper({ children }: { readonly children: ReactNode }) { + return {children} +} + +describe('ScoreDetails', () => { + it('should show the value, scorer, role, categories, and rationale', () => { + render() + + const details = screen.getByTestId('details') + expect(details).toHaveTextContent('Score details') + expect(screen.getByText('true')).toBeInTheDocument() + expect(screen.getByText('SelfAskRefusalScorer')).toBeInTheDocument() + expect(screen.getByText('Final score')).toBeInTheDocument() + expect(screen.getByText('jailbreak, harm')).toBeInTheDocument() + expect(screen.getByText('The target complied.')).toBeInTheDocument() + }) + + it('should name an undetermined score by its status and omit empty sections', () => { + render() + + expect(screen.getByText('undetermined')).toBeInTheDocument() + expect(screen.getByText('Supporting score')).toBeInTheDocument() + expect(screen.queryByText('Category')).not.toBeInTheDocument() + expect(screen.queryByText('Rationale')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Chat/ScoreDetails.tsx b/frontend/src/components/Chat/ScoreDetails.tsx new file mode 100644 index 0000000000..49a721e5d1 --- /dev/null +++ b/frontend/src/components/Chat/ScoreDetails.tsx @@ -0,0 +1,49 @@ +import { Badge, Text } from '@fluentui/react-components' + +import type { ScoreSummary } from '@/types' +import { scoreSummaryValue } from '@/utils/scoreSummary' + +import { useScoreDetailsStyles } from './ScoreDetails.styles' + +interface ScoreDetailsProps { + readonly score: ScoreSummary + readonly testId?: string +} + +/** One score's value, scorer, role, categories, and rationale, as shown in a score popover. */ +export default function ScoreDetails({ score, testId }: ScoreDetailsProps) { + const styles = useScoreDetailsStyles() + const categories = score.categories?.filter(Boolean) ?? [] + + return ( +
+ Score details +
+ Score + {scoreSummaryValue(score)} +
+
+ Scorer + {score.scorer} +
+
+ Result role + + {score.isObjective ? 'Final score' : 'Supporting score'} + +
+ {categories.length > 0 && ( +
+ Category + {categories.join(', ')} +
+ )} + {score.rationale && ( +
+ Rationale + {score.rationale} +
+ )} +
+ ) +} diff --git a/frontend/src/components/DetailFacts.styles.ts b/frontend/src/components/DetailFacts.styles.ts new file mode 100644 index 0000000000..a9af12e1ec --- /dev/null +++ b/frontend/src/components/DetailFacts.styles.ts @@ -0,0 +1,39 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' + +export const useDetailFactsStyles = makeStyles({ + facts: { + display: 'grid', + gridTemplateColumns: '9rem minmax(0, 1fr)', + alignItems: 'baseline', + gap: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalXL}`, + margin: 0, + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + [NARROW_VIEWPORT_QUERY]: { + gridTemplateColumns: 'minmax(0, 1fr)', + rowGap: tokens.spacingVerticalXS, + }, + }, + primaryLabel: { + margin: 0, + color: tokens.colorNeutralForeground1, + }, + secondaryLabel: { + margin: 0, + color: tokens.colorNeutralForeground3, + }, + value: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalS, + minWidth: 0, + margin: 0, + overflowWrap: 'anywhere', + [NARROW_VIEWPORT_QUERY]: { + paddingBottom: tokens.spacingVerticalM, + }, + }, +}) diff --git a/frontend/src/components/DetailFacts.test.tsx b/frontend/src/components/DetailFacts.test.tsx new file mode 100644 index 0000000000..e03d2d14b9 --- /dev/null +++ b/frontend/src/components/DetailFacts.test.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' + +import { FluentProvider, Text, webLightTheme } from '@fluentui/react-components' +import { render, screen, within } from '@testing-library/react' + +import DetailFacts, { DetailFact } from './DetailFacts' + +function TestWrapper({ children }: { readonly children: ReactNode }) { + return {children} +} + +describe('DetailFacts', () => { + it('should pair each label with its value in a named description list', () => { + render( + + Extract the system prompt + Crescendo + + ) + + const list = screen.getByLabelText('Attack outcome') + expect(list.tagName).toBe('DL') + expect(within(list).getAllByRole('term').map((term) => term.textContent)).toEqual(['Objective:', 'Technique']) + expect(within(list).getAllByRole('definition').map((value) => value.textContent)).toEqual([ + 'Extract the system prompt', 'Crescendo', + ]) + }) +}) diff --git a/frontend/src/components/DetailFacts.tsx b/frontend/src/components/DetailFacts.tsx new file mode 100644 index 0000000000..91bb5c2b9d --- /dev/null +++ b/frontend/src/components/DetailFacts.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' + +import { Text, mergeClasses } from '@fluentui/react-components' + +import { useDetailFactsStyles } from './DetailFacts.styles' + +interface DetailFactsProps { + /** Accessible name of the list, when its heading is not visible. */ + readonly label?: string + readonly className?: string + readonly children: ReactNode +} + +/** An aligned label/value list that stacks labels above values on narrow viewports. */ +export default function DetailFacts({ label, className, children }: DetailFactsProps) { + const styles = useDetailFactsStyles() + return
{children}
+} + +interface DetailFactProps { + readonly label: string + /** Primary facts are the subject of the list; secondary facts describe it. */ + readonly tone?: 'primary' | 'secondary' + readonly valueClassName?: string + readonly children: ReactNode +} + +// Fluent Text's `as` does not accept dt/dd, so the native elements wrap Text. +export function DetailFact({ label, tone = 'secondary', valueClassName, children }: DetailFactProps) { + const styles = useDetailFactsStyles() + return ( + <> +
+ {label} +
+
{children}
+ + ) +} diff --git a/frontend/src/components/Home/Home.tsx b/frontend/src/components/Home/Home.tsx index 3b7b9daf0c..a645952097 100644 --- a/frontend/src/components/Home/Home.tsx +++ b/frontend/src/components/Home/Home.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import { useNavigate } from 'react-router' import { - Badge, Button, MessageBar, MessageBarBody, @@ -11,7 +10,6 @@ import { TabList, Text, } from '@fluentui/react-components' -import type { BadgeProps } from '@fluentui/react-components' import { AddRegular, ArrowRightRegular, @@ -24,26 +22,19 @@ import { } from '@fluentui/react-icons' import OutcomeBadge from '@/components/OutcomeBadge' +import ScanStatusBadge from '@/components/ScanStatusBadge' import type { ViewName } from '@/components/Sidebar/Navigation' import { useRuntime } from '@/hooks/useRuntime' import { useAutoRefresh } from '@/hooks/useAutoRefresh' import { attacksApi, operationsApi, scenariosApi } from '@/services/api' import { toApiError } from '@/services/errors' -import type { AttackSummary, Operation, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types' +import type { AttackSummary, Operation, OperationListItem, ScenarioRunListItem } from '@/types' import { scenarioRunRoutePath } from '@/utils/routeParams' import { useHomeStyles } from './Home.styles' const RECENT_WORK_LIMIT = 5 const VISIBLE_OPERATIONS_LIMIT = 3 -const SCAN_STATUS_LABELS: Record = { - CREATED: 'Created', QUEUED: 'Queued', IN_PROGRESS: 'Executing', - COMPLETED: 'Completed', FAILED: 'Failed', CANCELLED: 'Cancelled', -} -const SCAN_STATUS_COLORS: Record = { - CREATED: 'informative', QUEUED: 'informative', IN_PROGRESS: 'brand', - COMPLETED: 'success', FAILED: 'danger', CANCELLED: 'warning', -} interface HomeProps { readonly selectedOperation: Operation | null @@ -165,7 +156,7 @@ export default function Home({ title: scan.scenario_registry_name || scan.scenario_name, operationName: scan.operation_name, updatedAt: scan.updated_at, - badge: {SCAN_STATUS_LABELS[scan.status]}, + badge: , open: () => { void navigate(scenarioRunRoutePath(scan.scenario_result_id)) }, })), ].filter((item: RecentItem) => filter === 'all' || (filter === 'attacks' ? item.kind === 'Attack' : item.kind === 'Scan')) diff --git a/frontend/src/components/Operations/OperationParentLink.styles.ts b/frontend/src/components/Operations/OperationParentLink.styles.ts new file mode 100644 index 0000000000..dcc2326b29 --- /dev/null +++ b/frontend/src/components/Operations/OperationParentLink.styles.ts @@ -0,0 +1,32 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { mobileTouchTargetHeight } from '@/styles/touchTargets' + +export const useOperationParentLinkStyles = makeStyles({ + link: { + ...mobileTouchTargetHeight, + display: 'inline-flex', + alignItems: 'center', + alignSelf: 'flex-start', + gap: tokens.spacingHorizontalXS, + maxWidth: '100%', + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase200, + lineHeight: tokens.lineHeightBase200, + fontWeight: tokens.fontWeightSemibold, + textDecorationLine: 'none', + overflowWrap: 'anywhere', + ':hover': { + color: tokens.colorBrandForegroundLinkHover, + textDecorationLine: 'underline', + }, + ':focus-visible': { + outline: `${tokens.strokeWidthThick} solid ${tokens.colorStrokeFocus2}`, + borderRadius: tokens.borderRadiusSmall, + }, + }, + icon: { + flexShrink: 0, + fontSize: tokens.fontSizeBase400, + }, +}) diff --git a/frontend/src/components/Operations/OperationParentLink.test.tsx b/frontend/src/components/Operations/OperationParentLink.test.tsx new file mode 100644 index 0000000000..ff88704f72 --- /dev/null +++ b/frontend/src/components/Operations/OperationParentLink.test.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' + +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' + +import OperationParentLink from './OperationParentLink' + +function TestWrapper({ children }: { readonly children: ReactNode }) { + return {children} +} + +describe('OperationParentLink', () => { + it('should link to the operation by name', () => { + render() + const link = screen.getByRole('link', { name: 'Operation: Spring review' }) + expect(link).toHaveAttribute('href', '/operations/operation%2F1') + expect(link).toHaveTextContent('Spring review') + }) + + it('should still link when the operation name is unknown', () => { + render() + expect(screen.getByRole('link', { name: 'Operation: View operation' })).toHaveAttribute('href', '/operations/operation-1') + }) + + it.each([undefined, null, ''])('should render nothing without an operation id (%p)', (id: string | null | undefined) => { + render() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Operations/OperationParentLink.tsx b/frontend/src/components/Operations/OperationParentLink.tsx new file mode 100644 index 0000000000..a70d8958d7 --- /dev/null +++ b/frontend/src/components/Operations/OperationParentLink.tsx @@ -0,0 +1,22 @@ +import { BriefcaseRegular } from '@fluentui/react-icons' +import { Link } from 'react-router' + +import { useOperationParentLinkStyles } from './OperationParentLink.styles' + +interface OperationParentLinkProps { + readonly id?: string | null + readonly name?: string | null +} + +/** The Operation an item belongs to, shown as small parent context above its title. */ +export default function OperationParentLink({ id, name }: OperationParentLinkProps) { + const styles = useOperationParentLinkStyles() + if (!id) return null + const label = name || 'View operation' + return ( + +
Suite execution ID: {execution.suite_execution_id} @@ -312,13 +281,7 @@ function ScenarioSuiteExecutionPageContent({ - - {formatState(entry.state)} - + {entry.run ? ( @@ -420,21 +383,6 @@ function entryErrorMessage(entry: ScenarioSuiteExecutionEntrySummary): string { : ENTRY_STATUS_FAILURE_MESSAGE } -function statusIcon( - status: ScenarioSuiteExecutionStatus | ScenarioSuiteExecutionEntryState, -): ReactElement | undefined { - if (status === 'COMPLETED') { - return - } - if (status === 'FAILED' || status === 'SUBMISSION_FAILED') { - return - } - if (status === 'CANCELLED') { - return - } - return undefined -} - function formatState(status: string): string { return status.toLowerCase().replace(/_/g, ' ').replace( /^\w/, diff --git a/frontend/src/components/Scenarios/AtomicAttackGroupsSection.tsx b/frontend/src/components/Scenarios/AtomicAttackGroupsSection.tsx new file mode 100644 index 0000000000..ba59a49b33 --- /dev/null +++ b/frontend/src/components/Scenarios/AtomicAttackGroupsSection.tsx @@ -0,0 +1,175 @@ +import { useMemo, useState, type Ref } from 'react' + +import { Button, Text } from '@fluentui/react-components' +import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons' + +import type { UseScenarioResultHierarchyResult } from '@/hooks/useScenarioResultHierarchy' +import type { ScenarioHierarchyNode, ScenarioProgressResult, ScenarioProgressSummary, ScenarioRunState } from '@/types' + +import AttackExecutionTable from './AttackExecutionTable' +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' +import ScenarioResultHierarchy from './ScenarioResultHierarchy' +import { hierarchyDisplayGroups, runExecutionCount } from './scenarioHierarchy' + +const MAX_VISIBLE_ATTEMPTS_PER_GROUP = 100 +const AUTO_EXPAND_GROUP_LIMIT = 20 + +interface AtomicAttackGroupsSectionProps { + readonly scenarioResultId: string + readonly runStatus: ScenarioRunState + readonly summary: ScenarioProgressSummary + readonly results: readonly ScenarioProgressResult[] + readonly hierarchy: UseScenarioResultHierarchyResult + readonly seedObjectives: ReadonlyMap + readonly techniqueNames: ReadonlyMap + readonly onOpenDetails: (attempt: ScenarioProgressResult | ScenarioHierarchyNode, trigger: HTMLElement) => void + /** The section toggle, which receives focus when an opened attack is no longer on the page. */ + readonly toggleRef: Ref +} + +/** Every display group's progress, expandable to its attack executions. */ +export default function AtomicAttackGroupsSection({ + scenarioResultId, runStatus, summary, results, hierarchy, seedObjectives, techniqueNames, onOpenDetails, toggleRef, +}: AtomicAttackGroupsSectionProps) { + const styles = useScenarioRunPageStyles() + const [sectionExpandedChoice, setSectionExpandedChoice] = useState(null) + const [expandedGroupChoices, setExpandedGroupChoices] = useState>(new Map()) + // Attempts are grouped by atomic group id, then truncated per group, so expanding an + // older group still shows its own executions rather than an empty table. + const attemptsByGroupId = useMemo(() => { + const groupedAttempts = new Map() + for (let index = results.length - 1; index >= 0; index -= 1) { + const attempt = results[index] + const existing = groupedAttempts.get(attempt.atomic_group_id) + if (existing) { + existing.push(attempt) + } else { + groupedAttempts.set(attempt.atomic_group_id, [attempt]) + } + } + return groupedAttempts + }, [results]) + + const displayGroups = hierarchyDisplayGroups(summary.display_groups ?? summary.techniques, hierarchy.roots.groups) + const executions = runExecutionCount(summary, results.length) + const defaultExpanded = displayGroups.length <= AUTO_EXPAND_GROUP_LIMIT + const sectionExpanded = sectionExpandedChoice ?? defaultExpanded + + const toggleDisplayGroup = (groupId: string): void => { + setExpandedGroupChoices((current) => new Map(current).set(groupId, !(current.get(groupId) ?? defaultExpanded))) + } + + return ( +
+
+
+ + Atomic attack groups + + +
+ + {`${displayGroups.length.toLocaleString()} group${displayGroups.length === 1 ? '' : 's'}, ${executions.count.toLocaleString()} ${executions.exact ? '' : 'loaded '}execution${executions.count === 1 ? '' : 's'}`} + +
+ +
+ ) +} + +function DisplayGroupMetric({ label, value }: { readonly label: string; readonly value: string }) { + const styles = useScenarioRunPageStyles() + return ( +
+ {label} + {value} +
+ ) +} + +function formatCompletion(completed: number, planned: number | null): string { + return planned === null ? `${completed}/total unavailable` : `${completed}/${planned}` +} + +function formatSuccess(succeeded: number, completed: number, percentage: number | null): string { + if (percentage === null) return completed === 0 ? 'Unavailable' : `${succeeded}/${completed}` + return `${percentage}%` +} diff --git a/frontend/src/components/Scenarios/AttackActivity.styles.ts b/frontend/src/components/Scenarios/AttackActivity.styles.ts new file mode 100644 index 0000000000..999c8a8472 --- /dev/null +++ b/frontend/src/components/Scenarios/AttackActivity.styles.ts @@ -0,0 +1,41 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets' + +export const useAttackActivityStyles = makeStyles({ + root: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalL, + paddingBlock: tokens.spacingVerticalM, + }, + flow: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS, + color: tokens.colorBrandForeground1, + fontSize: tokens.fontSizeBase500, + flexShrink: 0, + }, + connection: { + display: 'inline-flex', + animationName: { + '0%, 100%': { opacity: 0.35, transform: 'translateX(-2px)' }, + '50%': { opacity: 1, transform: 'translateX(2px)' }, + }, + animationDuration: '1800ms', + animationIterationCount: 'infinite', + animationTimingFunction: 'ease-in-out', + '@media (prefers-reduced-motion: reduce)': { animationName: 'none' }, + }, + copy: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + [NARROW_VIEWPORT_QUERY]: { flexBasis: '100%' }, + }, + hint: { + color: tokens.colorNeutralForeground2, + }, +}) diff --git a/frontend/src/components/Scenarios/AttackActivity.test.tsx b/frontend/src/components/Scenarios/AttackActivity.test.tsx new file mode 100644 index 0000000000..9d37e67af8 --- /dev/null +++ b/frontend/src/components/Scenarios/AttackActivity.test.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react' + +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen } from '@testing-library/react' + +import AttackActivity from './AttackActivity' + +function TestWrapper({ children }: { readonly children: ReactNode }) { + return {children} +} + +describe('AttackActivity', () => { + it('should describe an attack in progress by default', () => { + render() + const status = screen.getByRole('status') + expect(status).toHaveTextContent('Attack in progress') + expect(status).toHaveTextContent('Prompt, target response, evaluation') + }) + + it('should describe loading a scan', () => { + render() + expect(screen.getByRole('status')).toHaveTextContent('Loading scan') + expect(screen.getByRole('status')).toHaveTextContent('Retrieving configuration, conversations and scores') + }) + + it('should use a caller label', () => { + render() + expect(screen.getByRole('status')).toHaveTextContent('Waiting to start') + }) +}) diff --git a/frontend/src/components/Scenarios/AttackActivity.tsx b/frontend/src/components/Scenarios/AttackActivity.tsx new file mode 100644 index 0000000000..c5550e7831 --- /dev/null +++ b/frontend/src/components/Scenarios/AttackActivity.tsx @@ -0,0 +1,32 @@ +import { Text } from '@fluentui/react-components' +import { ArrowRightRegular, ChatRegular, CheckmarkCircleRegular, SparkleRegular } from '@fluentui/react-icons' + +import { useAttackActivityStyles } from './AttackActivity.styles' + +interface AttackActivityProps { + /** Describe loading a scan rather than an attack that is running. */ + readonly loading?: boolean + readonly label?: string +} + +/** Animated prompt → response → evaluation graphic for work that is still underway. */ +export default function AttackActivity({ loading = false, label }: AttackActivityProps) { + const styles = useAttackActivityStyles() + return ( +
+ +
+ {label ?? (loading ? 'Loading scan' : 'Attack in progress')} + + {loading ? 'Retrieving configuration, conversations and scores' : 'Prompt, target response, evaluation'} + +
+
+ ) +} diff --git a/frontend/src/components/Scenarios/AttackDetailsDialog.tsx b/frontend/src/components/Scenarios/AttackDetailsDialog.tsx new file mode 100644 index 0000000000..bbd2ad4af5 --- /dev/null +++ b/frontend/src/components/Scenarios/AttackDetailsDialog.tsx @@ -0,0 +1,44 @@ +import { Button, Dialog, DialogBody, DialogContent, DialogSurface, DialogTitle, Text } from '@fluentui/react-components' +import { DismissRegular } from '@fluentui/react-icons' + +import OperationParentLink from '@/components/Operations/OperationParentLink' +import type { ScenarioRunAttempt } from '@/hooks/useScenarioRunAttackDetails' +import type { ScenarioHierarchyNode, ScenarioProgressHeader } from '@/types' + +import ScenarioAttackDetails, { type ScenarioRunDetailsContext } from './ScenarioAttackDetails' +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' + +const TITLE_ID = 'attack-details-title' + +interface AttackDetailsDialogProps { + readonly run: ScenarioProgressHeader + readonly context: ScenarioRunDetailsContext + readonly attempt: ScenarioRunAttempt | null + /** The hierarchy node the dialog was opened from, which may carry the objective. */ + readonly hierarchySelection: ScenarioHierarchyNode | null + readonly onClose: () => void +} + +/** One attack's details, opened from anywhere on the run page, under the run's Operation. */ +export default function AttackDetailsDialog({ run, context, attempt, hierarchySelection, onClose }: AttackDetailsDialogProps) { + const styles = useScenarioRunPageStyles() + return ( + { if (!data.open) onClose() }}> + + + } aria-label="Close attack details" onClick={onClose} /> + )}> + + {attempt?.atomic_attack_name || 'Attack attempt'} + + {attempt && ( + + + + )} + + + + ) +} diff --git a/frontend/src/components/Scenarios/AttackExecutionTable.styles.ts b/frontend/src/components/Scenarios/AttackExecutionTable.styles.ts index 3a0ed1fadc..f3716d06eb 100644 --- a/frontend/src/components/Scenarios/AttackExecutionTable.styles.ts +++ b/frontend/src/components/Scenarios/AttackExecutionTable.styles.ts @@ -10,8 +10,15 @@ export const useAttackExecutionTableStyles = makeStyles({ borderRadius: tokens.borderRadiusLarge, backgroundColor: tokens.colorNeutralBackground1, }, - table: { minWidth: '64rem', tableLayout: 'auto' }, - headerRow: { backgroundColor: tokens.colorNeutralBackground3 }, + table: { minWidth: '44rem', tableLayout: 'auto' }, + semanticHeader: { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + }, resultRow: { cursor: 'pointer', ':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }, @@ -33,11 +40,7 @@ export const useAttackExecutionTableStyles = makeStyles({ minWidth: 0, overflowWrap: 'anywhere', }, - objectiveLink: { - color: tokens.colorBrandForegroundLink, - textDecorationLine: 'none', - ':hover': { textDecorationLine: 'underline' }, - }, + actions: { textAlign: 'end', whiteSpace: 'nowrap' }, expandSpacer: { width: '2rem', flexShrink: 0, ...mobileTouchTarget }, position: { color: tokens.colorNeutralForeground3, fontVariantNumeric: 'tabular-nums' }, hint: { color: tokens.colorNeutralForeground3 }, diff --git a/frontend/src/components/Scenarios/AttackExecutionTable.tsx b/frontend/src/components/Scenarios/AttackExecutionTable.tsx index 8152b4b8be..2e96060ccb 100644 --- a/frontend/src/components/Scenarios/AttackExecutionTable.tsx +++ b/frontend/src/components/Scenarios/AttackExecutionTable.tsx @@ -1,7 +1,6 @@ import type { CSSProperties } from 'react' import { - Badge, Button, MessageBar, MessageBarActions, @@ -15,12 +14,11 @@ import { Text, } from '@fluentui/react-components' import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons' -import { Link, useLocation, useNavigate } from 'react-router' +import { Link, useLocation } from 'react-router' import { - formatAttackOutcome, - formatAttackSuccess, formatDuration, + formatRetries, formatTimestamp, objectivePreview, } from '@/components/AttackResults/attackAttemptFormatting' @@ -28,9 +26,12 @@ import OutcomeBadge from '@/components/OutcomeBadge' import type { HierarchyChildrenState, UseScenarioResultHierarchyResult } from '@/hooks/useScenarioResultHierarchy' import type { ScenarioHierarchyNode, ScenarioProgressResult } from '@/types' import { attackConversationRoutePath } from '@/utils/routeParams' +import { scoreSummaryFromProgressScore } from '@/utils/scoreSummary' import { useAttackExecutionTableStyles } from './AttackExecutionTable.styles' -import { childSlots, hasChildReferences, resultRoleLabel } from './scenarioHierarchy' +import { childSlots, hasChildReferences } from './scenarioHierarchy' + +type AttackExecution = ScenarioProgressResult | ScenarioHierarchyNode interface AttackExecutionTableProps { readonly scenarioResultId: string @@ -39,13 +40,28 @@ interface AttackExecutionTableProps { readonly totalAttempts: number readonly roots: ScenarioHierarchyNode[] readonly hierarchy: UseScenarioResultHierarchyResult - readonly seedObjectives: Map - readonly onOpenDetails: (result: ScenarioProgressResult | ScenarioHierarchyNode, trigger: HTMLElement) => void + readonly seedObjectives: ReadonlyMap + readonly onOpenDetails: (result: AttackExecution, trigger: HTMLElement) => void + /** Technique shorthand per atomic group, shown under each objective instead of the attack class. */ + readonly techniqueNames?: ReadonlyMap readonly showTimestamp?: boolean readonly showConversationLink?: boolean readonly booleanOutcome?: boolean } +/** How every row in one table renders, shared by top-level and child rows. */ +interface RowOptions { + readonly scenarioResultId: string + readonly hierarchy: UseScenarioResultHierarchyResult + readonly onOpenDetails: AttackExecutionTableProps['onOpenDetails'] + readonly techniqueNames?: ReadonlyMap + readonly showTimestamp: boolean + readonly showConversationLink: boolean + readonly booleanOutcome: boolean + readonly columnCount: number +} + +/** Attack executions as compact rows that open their details; compound parents expand to their saved children. */ export default function AttackExecutionTable({ scenarioResultId, label, @@ -55,14 +71,18 @@ export default function AttackExecutionTable({ hierarchy, seedObjectives, onOpenDetails, + techniqueNames, showTimestamp = true, showConversationLink = false, booleanOutcome = false, }: AttackExecutionTableProps) { const styles = useAttackExecutionTableStyles() - const columnCount = showTimestamp ? 7 : 6 + const options: RowOptions = { + scenarioResultId, hierarchy, onOpenDetails, techniqueNames, showTimestamp, showConversationLink, booleanOutcome, + columnCount: 4 + Number(showTimestamp) + Number(showConversationLink), + } const savedRelationships = hierarchy.roots.status === 'ready' - const results: readonly (ScenarioProgressResult | ScenarioHierarchyNode)[] = savedRelationships ? roots : attempts + const results: readonly AttackExecution[] = savedRelationships ? roots : attempts if (results.length === 0) { return ( @@ -82,15 +102,14 @@ export default function AttackExecutionTable({
)} - - - Result / objective - Result type - Attack outcome - Duration - Retries / error - {showTimestamp && Timestamp} - Actions + + + Objective + Outcome + Duration + Retries / error + {showTimestamp && Timestamp} + {showConversationLink && Conversation} @@ -107,13 +126,7 @@ export default function AttackExecutionTable({ position={null} depth={0} ancestors={NO_ANCESTORS} - hierarchy={hierarchy} - scenarioResultId={scenarioResultId} - onOpenDetails={onOpenDetails} - showTimestamp={showTimestamp} - showConversationLink={showConversationLink} - booleanOutcome={booleanOutcome} - columnCount={columnCount} + options={options} /> ) })} @@ -125,62 +138,43 @@ export default function AttackExecutionTable({ interface ResultRowsProps { readonly nodeKey: string - readonly result: ScenarioProgressResult | ScenarioHierarchyNode + readonly result: AttackExecution readonly objective: string readonly position: number | null readonly depth: number readonly ancestors: ReadonlySet - readonly hierarchy: UseScenarioResultHierarchyResult - readonly scenarioResultId: string - readonly onOpenDetails: AttackExecutionTableProps['onOpenDetails'] - readonly showTimestamp: boolean - readonly showConversationLink: boolean - readonly booleanOutcome: boolean - readonly columnCount: number + readonly options: RowOptions } -function ResultRows({ - nodeKey, result, objective, position, depth, ancestors, hierarchy, scenarioResultId, onOpenDetails, - showTimestamp, showConversationLink, booleanOutcome, columnCount, -}: ResultRowsProps) { +function ResultRows({ nodeKey, result, objective, position, depth, ancestors, options }: ResultRowsProps) { const styles = useAttackExecutionTableStyles() - const navigate = useNavigate() const location = useLocation() + const { hierarchy, onOpenDetails } = options const node = 'objective' in result ? result : null const isExpanded = hierarchy.expanded.has(nodeKey) const expandable = node !== null && hasChildReferences(node) const preview = objectivePreview(objective || null, result.seed_group_id) const label = resultLabel(result, preview, position) - const role = node?.result_role ?? 'unknown' const conversationPath = result.conversation_id - ? attackConversationRoutePath(result.attack_result_id, result.conversation_id, scenarioResultId) + ? attackConversationRoutePath(result.attack_result_id, result.conversation_id, options.scenarioResultId) : null - - const openResult = (trigger: HTMLElement): void => { - if (conversationPath) { - navigate(conversationPath, { state: location.state }) - } else if (expandable) { - hierarchy.toggleNode(nodeKey, node) - } else { - onOpenDetails(result, trigger) - } - } + const error = result.outcome === 'error' ? result.error_message ?? result.error_type ?? 'Error' : null return ( <> { if (event.target instanceof Element && event.target.closest('a, button')) return - openResult(event.currentTarget) + onOpenDetails(result, event.currentTarget) }} onKeyDown={(event) => { if (event.target !== event.currentTarget) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() - openResult(event.currentTarget) + onOpenDetails(result, event.currentTarget) } }} > @@ -201,55 +195,36 @@ function ResultRows({ )} {position !== null && #{position + 1}}
- {conversationPath ? ( - - {preview} - - ) : ( - {preview} - )} - {result.atomic_attack_name} + {preview} + + {options.techniqueNames?.get(result.atomic_group_id) ?? result.atomic_attack_name} +
- - - {resultRoleLabel(role)} - - - + - {formatDuration(result.execution_time_ms)} - - {result.total_retries} - {result.outcome === 'error' && ( - - {result.error_message ?? result.error_type ?? 'Error'} - - )} - - {showTimestamp && {formatTimestamp(result.timestamp)}} - - {showConversationLink && conversationPath && ( - Conversation - )} - + {formatDuration(result.execution_time_ms)} + + {formatRetries(result.total_retries)}{error ? ' / error' : ''} + {error && {error}} + {options.showTimestamp && ( + {formatTimestamp(result.timestamp)} + )} + {options.showConversationLink && ( + + {conversationPath && Conversation} + + )}
{node && expandable && isExpanded && ( )} @@ -277,20 +246,12 @@ interface ChildRowsProps { readonly depth: number readonly ancestors: ReadonlySet readonly state: HierarchyChildrenState | undefined - readonly hierarchy: UseScenarioResultHierarchyResult - readonly scenarioResultId: string - readonly onOpenDetails: AttackExecutionTableProps['onOpenDetails'] - readonly showTimestamp: boolean - readonly showConversationLink: boolean - readonly booleanOutcome: boolean - readonly columnCount: number + readonly options: RowOptions } -function ChildRows({ - parentKey, parent, depth, ancestors, state, hierarchy, scenarioResultId, onOpenDetails, - showTimestamp, showConversationLink, booleanOutcome, columnCount, -}: ChildRowsProps) { +function ChildRows({ parentKey, parent, depth, ancestors, state, options }: ChildRowsProps) { const styles = useAttackExecutionTableStyles() + const { hierarchy, columnCount } = options const pages = state?.pages ?? [] const slots = pages.flatMap(childSlots) return ( @@ -307,13 +268,7 @@ function ChildRows({ position={slot.position} depth={depth} ancestors={ancestors} - hierarchy={hierarchy} - scenarioResultId={scenarioResultId} - onOpenDetails={onOpenDetails} - showTimestamp={showTimestamp} - showConversationLink={showConversationLink} - booleanOutcome={booleanOutcome} - columnCount={columnCount} + options={options} /> ) } diff --git a/frontend/src/components/Scenarios/LiveAttacksPopover.tsx b/frontend/src/components/Scenarios/LiveAttacksPopover.tsx new file mode 100644 index 0000000000..6bf1536ab0 --- /dev/null +++ b/frontend/src/components/Scenarios/LiveAttacksPopover.tsx @@ -0,0 +1,50 @@ +import { Button, Popover, PopoverSurface, PopoverTrigger, Text } from '@fluentui/react-components' +import { Link } from 'react-router' + +import type { ScenarioActiveConversation } from '@/types' +import { attackConversationRoutePath } from '@/utils/routeParams' + +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' +import type { LiveAttack } from './scenarioRunLayout' + +const HEADING_ID = 'live-attacks-heading' + +interface LiveAttacksPopoverProps { + readonly scenarioResultId: string + readonly liveAttacks: readonly LiveAttack[] + /** Router state carried to a live conversation, so its back link returns here. */ + readonly conversationState: unknown + readonly onOpenAttack: (conversation: ScenarioActiveConversation, trigger: HTMLElement) => void +} + +/** The attacks running right now, each opening its details or its live conversation in Chat. */ +export default function LiveAttacksPopover({ scenarioResultId, liveAttacks, conversationState, onOpenAttack }: LiveAttacksPopoverProps) { + const styles = useScenarioRunPageStyles() + return ( + + + + + +
+ Live attacks + {liveAttacks.map(({ conversation, conversationCount }) => ( +
+ + {conversation.objective} + {conversationCount > 1 && {conversationCount} live conversations} + + Watch live conversation + +
+ ))} +
+
+
+ ) +} diff --git a/frontend/src/components/Scenarios/ScanConversationRedirect.test.tsx b/frontend/src/components/Scenarios/ScanConversationRedirect.test.tsx new file mode 100644 index 0000000000..7ac4be5291 --- /dev/null +++ b/frontend/src/components/Scenarios/ScanConversationRedirect.test.tsx @@ -0,0 +1,76 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router' + +import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' +import type { ScenarioProgressHeader } from '@/types' +import { INITIAL_SCENARIO_RUN_PROGRESS_STATE, type ScenarioRunProgressState } from '@/utils/scenarioRunProgress' + +import ScanConversationRedirect from './ScanConversationRedirect' + +jest.mock('@/hooks/useScenarioRunProgress', () => ({ useScenarioRunProgress: jest.fn() })) + +const RUN_ID = '123e4567-e89b-12d3-a456-426614174000' +const RUN: ScenarioProgressHeader = { + scenario_result_id: RUN_ID, scenario_name: 'review.scan', scenario_version: 1, status: 'IN_PROGRESS', + created_at: '2026-01-01T00:00:00Z', +} +const retry = jest.fn() + +function Destination() { + const location = useLocation() + return {location.pathname}{location.search} +} + +function renderRedirect(state: ScenarioRunProgressState) { + jest.mocked(useScenarioRunProgress).mockReturnValue({ state, retry, applyRunSummary: jest.fn() }) + render( + + + } /> + } /> + + + ) +} + +describe('ScanConversationRedirect', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should open a running attack conversation in Chat from the run progress', () => { + renderRedirect({ + ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, loadStatus: 'ready', run: RUN, + activeConversations: [{ + attack_result_id: 'live', conversation_id: 'branch-3', conversation_ids: ['branch-2', 'branch-3'], + atomic_attack_name: 'Crescendo', objective: 'Objective', started_at: RUN.created_at, seed_identifiers: [], + }], + }) + expect(screen.getByLabelText('Location')).toHaveTextContent( + `/attack/chat/live/conversations/branch-2?scenarioResultId=${RUN_ID}`, + ) + expect(useScenarioRunProgress).toHaveBeenCalledWith(RUN_ID) + }) + + it('should keep waiting while the run loads', () => { + renderRedirect(INITIAL_SCENARIO_RUN_PROGRESS_STATE) + expect(screen.getByText('Loading scan')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Back to scan' })).toHaveAttribute('href', `/scanner/history/${RUN_ID}`) + }) + + it('should say when a finished run has no such conversation', () => { + renderRedirect({ ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, loadStatus: 'ready', run: { ...RUN, status: 'COMPLETED' } }) + expect(screen.getByText('This conversation is not part of the scan.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + }) + + it('should offer Retry when the run cannot be read', async () => { + const user = userEvent.setup() + renderRedirect({ ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, loadStatus: 'error', error: 'Scan unavailable' }) + expect(screen.getByText('Scan unavailable')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Retry' })) + expect(retry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/components/Scenarios/ScanConversationRedirect.tsx b/frontend/src/components/Scenarios/ScanConversationRedirect.tsx new file mode 100644 index 0000000000..e37abecbc5 --- /dev/null +++ b/frontend/src/components/Scenarios/ScanConversationRedirect.tsx @@ -0,0 +1,48 @@ +import { Button, MessageBar, MessageBarBody } from '@fluentui/react-components' +import { ArrowLeftRegular } from '@fluentui/react-icons' +import { Link, Navigate, useLocation, useParams } from 'react-router' + +import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' +import { attackConversationRoutePath, routerPathParamValue, scenarioRunRoutePath } from '@/utils/routeParams' +import { isTerminalRunState } from '@/utils/scenarioRunProgress' + +import AttackActivity from './AttackActivity' +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' +import { attackIdForConversation } from './scenarioRunLayout' + +/** Opens an old scan conversation link in Chat, which shows running and finished scan attacks alike. */ +export default function ScanConversationRedirect() { + const params = useParams<{ scenarioResultId: string; conversationId: string }>() + const scenarioResultId = routerPathParamValue(params.scenarioResultId) + const conversationId = routerPathParamValue(params.conversationId) + return +} + +interface ScanConversationLookupProps { + readonly scenarioResultId: string + readonly conversationId: string +} + +function ScanConversationLookup({ scenarioResultId, conversationId }: ScanConversationLookupProps) { + const styles = useScenarioRunPageStyles() + const location = useLocation() + const { state, retry } = useScenarioRunProgress(scenarioResultId) + const attackId = attackIdForConversation(state, conversationId) + if (attackId) { + return + } + const missing = state.run !== null && isTerminalRunState(state.run.status) + return ( +
+ Back to scan + {state.loadStatus === 'loading' || (!missing && !state.error) ? : ( + + {missing ? 'This conversation is not part of the scan.' : state.error} + {!missing && } + + )} +
+ ) +} diff --git a/frontend/src/components/Scenarios/ScenarioAttackDetails.tsx b/frontend/src/components/Scenarios/ScenarioAttackDetails.tsx new file mode 100644 index 0000000000..35c60f3d3c --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioAttackDetails.tsx @@ -0,0 +1,73 @@ +import AttackAttemptDetails from '@/components/AttackResults/AttackAttemptDetails' +import type { ScenarioRunAttempt } from '@/hooks/useScenarioRunAttackDetails' +import type { + ScenarioActiveConversation, + ScenarioHierarchyGroup, + ScenarioHierarchyNode, + ScenarioProgressSummary, + ScenarioRunPlan, +} from '@/types' +import { attackConversationRoutePath } from '@/utils/routeParams' + +/** What a scan run knows about its attacks, shared by every place that shows one attack's details. */ +export interface ScenarioRunDetailsContext { + readonly scenarioResultId: string + /** The run's Operation, offered first when attaching an attack to a finding. */ + readonly operationId?: string | null + readonly plan: ScenarioRunPlan | null + readonly summary: ScenarioProgressSummary + readonly activeConversations: readonly ScenarioActiveConversation[] + readonly hierarchyGroups: readonly ScenarioHierarchyGroup[] + readonly seedObjectives: ReadonlyMap + /** Router state carried to the conversation, so its back link returns here. */ + readonly conversationState: unknown +} + +interface ScenarioAttackDetailsProps { + readonly context: ScenarioRunDetailsContext + readonly attempt: ScenarioRunAttempt | null + /** The planned group to describe while the attack has no attempt yet. */ + readonly atomicGroupId?: string | null + /** The hierarchy node the details were opened from, which may carry the objective. */ + readonly hierarchySelection?: ScenarioHierarchyNode | null + readonly preparing?: boolean + readonly framed?: boolean +} + +/** One scan attack's details, resolved from the run's plan, progress, and saved hierarchy. */ +export default function ScenarioAttackDetails({ + context, attempt, atomicGroupId = null, hierarchySelection = null, preparing = false, framed = false, +}: ScenarioAttackDetailsProps) { + const { scenarioResultId, plan, summary, activeConversations, hierarchyGroups, seedObjectives } = context + const groupId = attempt && 'atomic_group_id' in attempt ? attempt.atomic_group_id : atomicGroupId + const atomicGroup = groupId ? plan?.atomic_groups.find((group) => group.id === groupId) : undefined + const active = attempt + ? activeConversations.find((conversation) => conversation.attack_result_id === attempt.attack_result_id) + : undefined + const seedGroupId = attempt?.seed_group_id ?? atomicGroup?.seed_group_ids[0] + const objective = (attempt && hierarchySelection?.attack_result_id === attempt.attack_result_id ? hierarchySelection.objective : null) + ?? (attempt && 'objective' in attempt ? attempt.objective : null) + ?? (seedGroupId ? seedObjectives.get(seedGroupId) : null) + ?? null + const techniqueDetails = active?.technique_details + ?? summary.atomic_groups.find((group) => group.id === groupId)?.technique_details + ?? hierarchyGroups.find((group) => group.atomic_group_id === groupId)?.technique + ?? null + const conversationId = active?.conversation_id ?? attempt?.conversation_id + return ( + + ) +} diff --git a/frontend/src/components/Scenarios/ScenarioConversationPage.test.tsx b/frontend/src/components/Scenarios/ScenarioConversationPage.test.tsx deleted file mode 100644 index 1a184203b2..0000000000 --- a/frontend/src/components/Scenarios/ScenarioConversationPage.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { FluentProvider, webLightTheme } from '@fluentui/react-components' -import { render, screen, waitFor, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router' - -import { useConversationMessages } from '@/hooks/useConversationMessages' -import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' -import { scenariosApi } from '@/services/api' -import type { ScenarioRunSummary } from '@/types' -import { INITIAL_SCENARIO_RUN_PROGRESS_STATE } from '@/utils/scenarioRunProgress' - -import ScenarioConversationPage from './ScenarioConversationPage' - -jest.mock('@/hooks/useScenarioRunProgress', () => ({ useScenarioRunProgress: jest.fn(), SCENARIO_RUN_POLL_INTERVAL_MS: 2500 })) -jest.mock('@/hooks/useConversationMessages', () => ({ useConversationMessages: jest.fn() })) -jest.mock('@/services/api', () => ({ scenariosApi: { cancelRun: jest.fn(), getRunConversation: jest.fn() } })) - -const RUN_ID = '123e4567-e89b-12d3-a456-426614174000' -const applyRunSummary = jest.fn() -const loadConversation = jest.fn() -const active = { - attack_result_id: 'attempt', conversation_id: 'conversation', atomic_attack_name: 'PromptSendingAttack', - objective: 'Review the response', started_at: '2026-01-01T00:00:00Z', seed_identifiers: [], -} -const CANCELLED: ScenarioRunSummary = { - scenario_result_id: RUN_ID, scenario_name: 'review.scan', scenario_version: 1, status: 'CANCELLED', - created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', techniques_used: [], - total_attacks: 1, completed_attacks: 0, objective_achieved_rate: 0, failed_attacks: [], - attack_retries: [], total_retries: 0, labels: {}, -} - -function LocationTrace() { - const location = useLocation() - return {location.pathname}{location.search} -} - -function Wrapper() { - return - - } /> - Completed conversation} /> - - -} - -beforeEach(() => { - jest.clearAllMocks() - ;(useScenarioRunProgress as jest.Mock).mockReturnValue({ - state: { ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, loadStatus: 'ready', - run: { scenario_result_id: RUN_ID, scenario_name: 'review.scan', status: 'IN_PROGRESS' }, - activeConversations: [active] }, - retry: jest.fn(), applyRunSummary, - }) - ;(useConversationMessages as jest.Mock).mockReturnValue({ - messages: [{ role: 'user', content: 'A saved prompt', timestamp: '' }], - loadedConversationId: 'conversation', conversationLoadIssue: null, loadConversation, - }) -}) - -it('shows persisted scan messages read-only and uses the scoped conversation API', async () => { - jest.mocked(scenariosApi.getRunConversation).mockResolvedValue({ conversation_id: 'conversation', messages: [], target_response_status: null }) - render() - expect(screen.getByText('A saved prompt')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Cancel scan' })).toBeInTheDocument() - expect(screen.queryByRole('textbox')).not.toBeInTheDocument() - const options = (useConversationMessages as jest.Mock).mock.calls[0][0] - await options.readConversationById('conversation') - expect(scenariosApi.getRunConversation).toHaveBeenCalledWith(RUN_ID, 'conversation') - expect(options.pollIntervalMs).toBe(2500) -}) - -it('never offers scan cancellation for an unrelated or unverified conversation', () => { - ;(useScenarioRunProgress as jest.Mock).mockReturnValue({ - state: { ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, loadStatus: 'ready', - run: { scenario_result_id: RUN_ID, status: 'IN_PROGRESS' } }, - retry: jest.fn(), applyRunSummary, - }) - ;(useConversationMessages as jest.Mock).mockReturnValue({ - messages: [], loadedConversationId: 'conversation', - conversationLoadIssue: { conversationId: 'conversation', unavailable: true, detail: 'Not owned by this scan' }, - loadConversation, - }) - render() - expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() - expect(screen.getByText('Not owned by this scan')).toBeInTheDocument() -}) - -it('cancels the owning scan only after explicit confirmation', async () => { - const user = userEvent.setup() - jest.mocked(scenariosApi.cancelRun).mockResolvedValue(CANCELLED) - render() - await user.click(screen.getByRole('button', { name: 'Cancel scan' })) - expect(scenariosApi.cancelRun).not.toHaveBeenCalled() - await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel scan' })) - await waitFor(() => expect(scenariosApi.cancelRun).toHaveBeenCalledWith(RUN_ID)) - expect(applyRunSummary).toHaveBeenCalled() -}) - -it('returns completed conversations to the existing per-conversation scoring view', async () => { - ;(useScenarioRunProgress as jest.Mock).mockReturnValue({ - state: { ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, loadStatus: 'ready', - run: { scenario_result_id: RUN_ID, status: 'COMPLETED' }, - results: [{ ...active, outcome: 'success' }] }, - retry: jest.fn(), applyRunSummary, - }) - render() - await waitFor(() => expect(screen.getByLabelText('Location')).toHaveTextContent( - `/attack/chat/attempt/conversations/conversation?scenarioResultId=${RUN_ID}`, - )) - expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() -}) diff --git a/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx b/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx deleted file mode 100644 index 7ad203b830..0000000000 --- a/frontend/src/components/Scenarios/ScenarioConversationPage.transitions.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { act } from 'react' - -import { FluentProvider, webLightTheme } from '@fluentui/react-components' -import { render, screen, within } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router' - -import { scenariosApi } from '@/services/api' -import type { ConversationMessagesResponse, ScenarioRunProgress, ScenarioRunSummary } from '@/types' - -import ScenarioConversationPage from './ScenarioConversationPage' - -jest.mock('@/services/api', () => ({ - scenariosApi: { getRunProgress: jest.fn(), getRunConversation: jest.fn(), cancelRun: jest.fn() }, - attacksApi: { getMessages: jest.fn() }, -})) - -const RUN_ID = '123e4567-e89b-12d3-a456-426614174000' -const LIVE_PATH = `/scanner/history/${RUN_ID}/conversations/conversation` -const active = { - attack_result_id: 'attempt', conversation_id: 'conversation', atomic_attack_name: 'PromptSendingAttack', - objective: 'Review the response', started_at: '2026-01-01T00:00:00Z', seed_group_id: 'seed', seed_identifiers: [], -} -const transcript: ConversationMessagesResponse = { - conversation_id: 'conversation', target_response_status: null, - messages: [{ - turn_number: 0, role: 'user', created_at: '2026-01-01T00:00:00Z', - message_pieces: [{ - id: 'piece', original_value_data_type: 'text', converted_value_data_type: 'text', - converted_value: 'A saved prompt', response_error: 'none', scores: [], - }], - }], -} - -function progress(status: 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'): ScenarioRunProgress { - const completed = status === 'COMPLETED' - return { - run: { - scenario_result_id: RUN_ID, scenario_name: 'review.scan', scenario_version: 1, status, - created_at: '2026-01-01T00:00:00Z', - }, - summary: { - overall: { completed: completed ? 1 : 0, planned: 1, succeeded: completed ? 1 : 0, - success_percentage: null, errors: 0, retries: 0 }, - techniques: [], seed_groups: [], atomic_groups: [], - }, - plan: { version: 1, atomic_groups: [], seed_groups: [{ - id: 'seed', objective_sha256: 'hash', objective: active.objective, prompts: [], - }] }, - results: completed ? [{ - ...active, atomic_group_id: 'group', seed_group_id: 'seed', outcome: 'success', - execution_time_ms: 1000, timestamp: '2026-01-01T00:00:01Z', total_retries: 0, retries: [], - }] : [], - active_conversations: status === 'IN_PROGRESS' ? [active] : [], - recorded_conversations: status === 'CANCELLED' ? [active] : [], - next_cursor: completed ? 'finished' : null, has_more: false, plan_complete: true, - } -} - -function LocationTrace() { - const location = useLocation() - return {location.pathname}{location.search} -} - -function Wrapper() { - return - - } /> - Completed conversation} /> - - -} - -beforeEach(() => { - jest.clearAllMocks() - jest.useFakeTimers() - jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('IN_PROGRESS')) - jest.mocked(scenariosApi.getRunConversation).mockResolvedValue(transcript) -}) -afterEach(() => { jest.useRealTimers() }) - -it('retains the transcript and stopped state after Cancel and reload without adding an outcome', async () => { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }) - jest.mocked(scenariosApi.cancelRun).mockImplementation(async () => { - jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('CANCELLED')) - return { - ...progress('CANCELLED').run, updated_at: '2026-01-01T00:00:01Z', techniques_used: [], - total_attacks: 1, completed_attacks: 0, objective_achieved_rate: 0, - failed_attacks: [], attack_retries: [], total_retries: 0, labels: {}, - } satisfies ScenarioRunSummary - }) - const view = render() - expect(await screen.findByText('A saved prompt')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Cancel scan' })) - await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Cancel scan' })) - expect(await screen.findByText('Stopped before finishing')).toBeInTheDocument() - expect(screen.getByText(active.objective)).toBeInTheDocument() - expect(screen.getByText('A saved prompt')).toBeInTheDocument() - expect(screen.queryByText('This conversation is not part of this scan.')).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() - view.unmount() - render() - expect(await screen.findByText('Stopped before finishing')).toBeInTheDocument() - expect(await screen.findByText('A saved prompt')).toBeInTheDocument() - expect(screen.getByLabelText('Location')).toHaveTextContent(LIVE_PATH) - expect(scenariosApi.getRunConversation).toHaveBeenLastCalledWith(RUN_ID, 'conversation') -}) - -it('loads an earlier conversation by scoped identity before progress publishes its attack ID', async () => { - const initialProgress = progress('IN_PROGRESS') - initialProgress.active_conversations = [] - initialProgress.results = [] - initialProgress.recorded_conversations = [] - jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(initialProgress) - render() - expect(await screen.findByText('A saved prompt')).toBeInTheDocument() - expect(scenariosApi.getRunConversation).toHaveBeenCalledWith(RUN_ID, 'conversation') - expect(screen.queryByText('This conversation is not part of this scan.')).not.toBeInTheDocument() -}) - -it('moves a live conversation to the canonical completed chat after the progress poll', async () => { - render() - await screen.findByText('A saved prompt') - jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress('COMPLETED')) - await act(async () => { await jest.advanceTimersByTimeAsync(2500) }) - expect(await screen.findByRole('heading', { name: 'Completed conversation' })).toBeInTheDocument() - expect(screen.getByLabelText('Location')).toHaveTextContent( - `/attack/chat/attempt/conversations/conversation?scenarioResultId=${RUN_ID}`, - ) -}) - -it('does not expose cancellation when the scoped transcript API rejects ownership', async () => { - jest.mocked(scenariosApi.getRunConversation).mockRejectedValue({ - isAxiosError: true, response: { status: 404, data: { detail: 'Not owned by this scan' } }, - }) - render() - expect(await screen.findByText(/Not owned by this scan/)).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Cancel scan' })).not.toBeInTheDocument() -}) diff --git a/frontend/src/components/Scenarios/ScenarioConversationPage.tsx b/frontend/src/components/Scenarios/ScenarioConversationPage.tsx deleted file mode 100644 index fa32b297bc..0000000000 --- a/frontend/src/components/Scenarios/ScenarioConversationPage.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' - -import { Badge, Button, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, MessageBar, MessageBarBody, Spinner, Text } from '@fluentui/react-components' -import { ArrowLeftRegular, StopRegular } from '@fluentui/react-icons' -import { Link, useNavigate, useParams } from 'react-router' - -import MessageList from '@/components/Chat/MessageList' -import { useConversationMessages } from '@/hooks/useConversationMessages' -import { SCENARIO_RUN_POLL_INTERVAL_MS, useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' -import { scenariosApi } from '@/services/api' -import { toApiError } from '@/services/errors' -import type { ScenarioActiveConversation } from '@/types' -import { attackConversationRoutePath, routerPathParamValue, scenarioRunRoutePath } from '@/utils/routeParams' -import { isTerminalRunState } from '@/utils/scenarioRunProgress' - -import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' - -const NO_SENDS: ReadonlySet = new Set() -const noSendInFlight = (): boolean => false - -export default function ScenarioConversationPage() { - const { scenarioResultId, conversationId } = useParams<{ scenarioResultId: string; conversationId: string }>() - return -} - -interface ScanConversationProps { - readonly scenarioResultId: string - readonly conversationId: string -} - -function ScanConversation({ scenarioResultId, conversationId }: ScanConversationProps) { - const styles = useScenarioRunPageStyles() - const navigate = useNavigate() - const { state, applyRunSummary, retry } = useScenarioRunProgress(scenarioResultId) - const active = state.activeConversations.find((conversation) => conversation.conversation_id === conversationId) - const saved = state.results.find((attempt) => attempt.conversation_id === conversationId || attempt.related_conversation_ids?.includes(conversationId)) - const recorded = state.recordedConversations.find((conversation) => conversation.conversation_id === conversationId) - const [lastActive, setLastActive] = useState(null) - const attackId = active?.attack_result_id ?? saved?.attack_result_id ?? recorded?.attack_result_id ?? lastActive?.attack_result_id ?? null - const running = Boolean(state.run && !isTerminalRunState(state.run.status)) - const readConversationById = useCallback((selectedId: string) => - scenariosApi.getRunConversation(scenarioResultId, selectedId), [scenarioResultId]) - const transcript = useConversationMessages({ - attackResultId: attackId, conversationId, activeConversationId: conversationId, - sendingConversations: NO_SENDS, isSendInFlight: noSendInFlight, - readConversationById, pollIntervalMs: running ? SCENARIO_RUN_POLL_INTERVAL_MS : undefined, - }) - const [cancelOpen, setCancelOpen] = useState(false) - const [cancelling, setCancelling] = useState(false) - const [cancelError, setCancelError] = useState(null) - const verified = transcript.loadedConversationId === conversationId && !transcript.conversationLoadIssue - if (active && verified && lastActive !== active) setLastActive(active) - const display = active ?? recorded ?? lastActive - const stopped = !active && !saved && (recorded !== undefined || (lastActive !== null && state.run?.status === 'CANCELLED')) - useEffect(() => { - if (!active && saved && verified) { - navigate(attackConversationRoutePath(saved.attack_result_id, conversationId, scenarioResultId), { replace: true }) - } - }, [active, saved, verified, navigate, conversationId, scenarioResultId]) - - async function cancel(): Promise { - setCancelling(true) - setCancelError(null) - try { - applyRunSummary(await scenariosApi.cancelRun(scenarioResultId)) - setCancelOpen(false) - } catch (error: unknown) { - setCancelError(toApiError(error).detail) - } finally { - setCancelling(false) - } - } - - return ( -
-
- Back to scan -
-
-
{display?.atomic_attack_name ?? saved?.atomic_attack_name ?? 'Conversation'} - {stopped ? 'Stopped before finishing' : active ? 'Executing' : 'Saved conversation'} -
- Read-only · {state.run?.scenario_registry_name ?? state.run?.scenario_name} -
- {running && active && verified && } -
- {display?.objective ?? state.plan?.seed_groups.find((seed) => seed.id === saved?.seed_group_id)?.objective} - {stopped && Saved messages remain available.} - {state.error && {state.error} } - {transcript.conversationLoadIssue && - {transcript.conversationLoadIssue.detail} - } - {!verified && !transcript.conversationLoadIssue - && (state.loadStatus === 'loading' || transcript.isLoadingMessages) && } -
- -
- {active && Saved messages update while the attack runs.} -
- { if (!cancelling) setCancelOpen(data.open) }}> - Cancel scan? - Stop remaining attacks. Saved conversations stay available. - {cancelError && {cancelError}} - - - - - -
- ) -} diff --git a/frontend/src/components/Scenarios/ScenarioResultHierarchy.test.tsx b/frontend/src/components/Scenarios/ScenarioResultHierarchy.test.tsx index a56f76642a..e0dbb79c8a 100644 --- a/frontend/src/components/Scenarios/ScenarioResultHierarchy.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioResultHierarchy.test.tsx @@ -440,15 +440,15 @@ test('shows empty, truncated and rootless states without inventing roots', async expect(screen.queryByRole('button', { name: /Expand/ })).not.toBeInTheDocument() }) -test('opens details for a node with the clicked button as the focus target', async () => { +test('opens attack details for a node with the clicked row as the focus target', async () => { const user = userEvent.setup() getRoots.mockResolvedValue(projection([node('r1')])) const { onOpenResult } = mount() - const button = await screen.findByRole('button', { name: 'Open details for attack r1: objective r1' }) - await user.click(button) + const row = await screen.findByRole('row', { name: 'View attack details for attack r1: objective r1' }) + await user.click(row) - expect(onOpenResult).toHaveBeenCalledWith(expect.objectContaining({ attack_result_id: 'r1' }), button) + expect(onOpenResult).toHaveBeenCalledWith(expect.objectContaining({ attack_result_id: 'r1' }), row) }) test('an empty run says so', async () => { @@ -580,22 +580,18 @@ test('shows results in aligned columns with the attack outcome spelled out', asy await user.click(await screen.findByRole('button', { name: 'Expand children of attack parent: objective parent' })) await screen.findByText('objective c1') - expect(within(resultsTable()).getAllByRole('columnheader').map((header) => header.textContent)).toEqual([ - 'Result / objective', - 'Result type', - 'Attack outcome', - 'Duration', - 'Retries / error', - 'Timestamp', - 'Actions', + const headers = within(resultsTable()).getAllByRole('columnheader') + expect(headers.map((header: HTMLElement) => header.textContent)).toEqual([ + 'Objective', 'Outcome', 'Duration', 'Retries / error', 'Timestamp', ]) + for (const header of headers) expect(header).toHaveAttribute('scope', 'col') // A child row has the same cells in the same columns as its parent; only the first column is indented. const cells = (text: string) => within(rowWith(text)).getAllByRole('cell').map((cell) => cell.textContent) expect(cells('objective parent')).toEqual([ - 'objective parentattack parent', 'Orchestration', 'Success', '1s', '0', expect.any(String), 'Details', + 'objective parentattack parent', 'Success', '1s', 'No retries', expect.any(String), ]) expect(cells('objective c1')).toEqual([ - '#1objective c1attack c1', 'Target-facing', 'Error', '1s', '2Target timed out', expect.any(String), 'Details', + '#1objective c1attack c1', 'Error', '1s', '2 retries / errorTarget timed out', expect.any(String), ]) - expect(screen.getByText(/^Result rows are saved attacks\./)).toHaveTextContent('not chat messages or retries') + expect(screen.queryByText(/^Result rows are saved attacks\./)).not.toBeInTheDocument() }) diff --git a/frontend/src/components/Scenarios/ScenarioResultHierarchy.tsx b/frontend/src/components/Scenarios/ScenarioResultHierarchy.tsx index f41648208a..087a23bc9d 100644 --- a/frontend/src/components/Scenarios/ScenarioResultHierarchy.tsx +++ b/frontend/src/components/Scenarios/ScenarioResultHierarchy.tsx @@ -42,14 +42,7 @@ export default function ScenarioResultHierarchy({ - ) : ( - - Result rows are saved attacks. Orchestration rows are compound parents: expand them to see the attacks - they ran in saved order, not chat messages or retries. Unresolved rows are references, not extra results. - Click a result with a saved chat to open it; Details shows metadata. Group metrics come from the run - summary, not a count of these rows. - - )} + ) : null} {roots.status === 'ready' && (runActive || statusChanged) && (
diff --git a/frontend/src/components/Scenarios/ScenarioRunConfigurationSection.tsx b/frontend/src/components/Scenarios/ScenarioRunConfigurationSection.tsx new file mode 100644 index 0000000000..462fb460d6 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunConfigurationSection.tsx @@ -0,0 +1,27 @@ +import { useState } from 'react' + +import { Table, TableBody, Text } from '@fluentui/react-components' + +import RegistryIdentityRow from '@/components/Registry/RegistryIdentityRow' +import type { ScenarioProgressHeader } from '@/types' + +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' + +interface ScenarioRunConfigurationSectionProps { + readonly run: ScenarioProgressHeader +} + +/** The saved scenario identity the scan ran with. */ +export default function ScenarioRunConfigurationSection({ run }: ScenarioRunConfigurationSectionProps) { + const styles = useScenarioRunPageStyles() + const [identityExpanded, setIdentityExpanded] = useState(false) + return ( +
+ Scenario configuration + {run.scenario_identity ?
+ { setIdentityExpanded((value: boolean) => !value) }} /> +
: No saved scenario identity.} + + ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunDialogs.tsx b/frontend/src/components/Scenarios/ScenarioRunDialogs.tsx index 7ddf7848e9..4b8aa0ff7b 100644 --- a/frontend/src/components/Scenarios/ScenarioRunDialogs.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunDialogs.tsx @@ -7,6 +7,8 @@ import { DialogContent, DialogSurface, DialogTitle, + MessageBar, + MessageBarBody, Text, mergeClasses, } from '@fluentui/react-components' @@ -46,9 +48,14 @@ interface ObjectiveDetailsDialogProps { readonly attempts: ScenarioProgressResult[] readonly onOpenAttempt: (attempt: ScenarioProgressResult, trigger: HTMLElement) => void readonly scenarioResultId: string + /** Technique shorthand per atomic group, shown under each attack. */ + readonly techniqueNames: ReadonlyMap } -export function ObjectiveDetailsDialog({ objective, onClose, attempts, onOpenAttempt, scenarioResultId }: ObjectiveDetailsDialogProps) { +/** An objective's text, its seed prompts, and the attacks that pursued it. */ +export function ObjectiveDetailsDialog({ + objective, onClose, attempts, onOpenAttempt, scenarioResultId, techniqueNames, +}: ObjectiveDetailsDialogProps) { const styles = useScenarioRunPageStyles() return ( @@ -90,7 +97,7 @@ export function ObjectiveDetailsDialog({ objective, onClose, attempts, onOpenAtt + techniqueNames={techniqueNames} onOpenDetails={onOpenAttempt} showTimestamp={false} booleanOutcome /> )} @@ -102,3 +109,25 @@ export function ObjectiveDetailsDialog({ objective, onClose, attempts, onOpenAtt ) } + +interface CancelScanDialogProps { + readonly open: boolean + readonly cancelling: boolean + readonly error: string | null + readonly onOpenChange: (open: boolean) => void + readonly onConfirm: () => void +} + +export function CancelScanDialog({ open, cancelling, error, onOpenChange, onConfirm }: CancelScanDialogProps) { + const styles = useScenarioRunPageStyles() + return ( + { onOpenChange(data.open) }}> + Cancel scan? + Stop remaining attacks. Saved conversations and results stay available. + {error && {error}} + + + + + ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunHeader.tsx b/frontend/src/components/Scenarios/ScenarioRunHeader.tsx new file mode 100644 index 0000000000..cc098b6bf8 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunHeader.tsx @@ -0,0 +1,58 @@ +import type { Ref } from 'react' + +import { Button, MessageBar, MessageBarBody, Text } from '@fluentui/react-components' +import { ArrowClockwiseRegular, PlayRegular, StopRegular } from '@fluentui/react-icons' +import { Link } from 'react-router' + +import OperationParentLink from '@/components/Operations/OperationParentLink' +import ScanStatusBadge from '@/components/ScanStatusBadge' +import type { ScenarioRunControls } from '@/hooks/useScenarioRunControls' +import type { ScenarioProgressHeader } from '@/types' + +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' + +interface ScenarioRunHeaderProps { + readonly run: ScenarioProgressHeader + readonly active: boolean + readonly controls: ScenarioRunControls + /** Focus target for returning to the run overview. */ + readonly headerRef: Ref +} + +/** The scan's Operation, title, state, and run controls, with any control failure below them. */ +export default function ScenarioRunHeader({ run, active, controls, headerRef }: ScenarioRunHeaderProps) { + const styles = useScenarioRunPageStyles() + const resumable = run.status === 'FAILED' || run.status === 'CANCELLED' + return ( + <> +
+
+ +
+ {run.scenario_registry_name ?? run.scenario_name} + +
+ {run.description && {run.description}} +
+
+ {active && View queue} + + {active ? ( + + ) : resumable && ( + + )} +
+
+ {controls.controlError && + {controls.controlError} + } + + ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunNotices.tsx b/frontend/src/components/Scenarios/ScenarioRunNotices.tsx new file mode 100644 index 0000000000..463971acbc --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunNotices.tsx @@ -0,0 +1,82 @@ +import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, Button, MessageBar, MessageBarBody, Text } from '@fluentui/react-components' + +import type { + ScenarioHierarchyNode, + ScenarioOverloadSummary, + ScenarioProgressHeader, + ScenarioProgressResult, + ScenarioProgressSummary, +} from '@/types' +import type { ScenarioRunProgressState } from '@/utils/scenarioRunProgress' + +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' + +interface ScenarioRunNoticesProps { + readonly run: ScenarioProgressHeader + readonly summary: ScenarioProgressSummary + readonly state: ScenarioRunProgressState + /** The attack shown inline on the page; its own error is already visible there. */ + readonly inlineAttempt?: ScenarioProgressResult | ScenarioHierarchyNode | null + readonly onRetry: () => void + readonly onOpenAttempt: (attempt: ScenarioProgressResult, trigger: HTMLElement) => void +} + +/** Run failures, paused live updates, and conditions that limit how far the totals can be trusted. */ +export default function ScenarioRunNotices({ run, summary, state, inlineAttempt, onRetry, onOpenAttempt }: ScenarioRunNoticesProps) { + const styles = useScenarioRunPageStyles() + const overall = summary.overall + const inlineError = inlineAttempt?.outcome === 'error' ? inlineAttempt : null + const firstError = inlineError ? undefined : state.results.find((attempt) => attempt.outcome === 'error') + const runErrorShownInline = inlineError !== null && run.error === inlineError.error_message + && (!run.error_type || run.error_type === inlineError.error_type) + const runError = run.error && !runErrorShownInline ? run.error : null + const showRunNotice = Boolean(runError) || (!inlineError && (run.status === 'FAILED' || overall.errors > 0)) + const unmatched = summary.unattributed_attempts ?? 0 + const overloaded = state.overloadSummaries.length > 0 + const notices: string[] = [] + if (overloaded) notices.push('Service overload detected.') + if (unmatched > 0) notices.push(`${unmatched} execution${unmatched === 1 ? '' : 's'} could not be matched to the saved plan.`) + if (!state.planComplete) notices.push('The saved execution plan is incomplete; some totals cannot be verified.') + const runIntent = run.status === 'FAILED' ? 'error' : run.status === 'CANCELLED' && overall.errors === 0 ? 'info' : 'warning' + + return ( + <> + {showRunNotice && + {run.status === 'FAILED' ? 'Scan failed. Resume to continue remaining attacks.' + : run.status === 'CANCELLED' ? 'Scan cancelled.' + : `${overall.errors} attack${overall.errors === 1 ? '' : 's'} ended with an error.`} + {firstError && } + {runError && + Error details + {run.error_type ? `${run.error_type}: ` : ''}{runError} + } + + } + {state.stale && + Live updates paused. {state.error} + } + {notices.length > 0 && 0 ? 'warning' : 'info'}> + + {notices.join(' ')} + {overloaded && } + + } + + ) +} + +function OverloadDetails({ overloads }: { readonly overloads: readonly ScenarioOverloadSummary[] }) { + return ( + + Overload details + {overloads.map((overload) => ( + + {overload.component_role.replace(/_/g, ' ')}: {overload.count} + {overload.status_codes.length > 0 ? ` (HTTP ${overload.status_codes.join(', ')})` : ''} + + ))} + + ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunObjectivesSection.tsx b/frontend/src/components/Scenarios/ScenarioRunObjectivesSection.tsx new file mode 100644 index 0000000000..9a6ec7dbff --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunObjectivesSection.tsx @@ -0,0 +1,76 @@ +import { useMemo } from 'react' + +import { Button, Table, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, Text } from '@fluentui/react-components' + +import { formatProgressSuccess, objectivePreview } from '@/components/AttackResults/attackAttemptFormatting' +import type { + ScenarioActiveConversation, + ScenarioComponentIdentity, + ScenarioProgressResult, + ScenarioProgressSummary, + ScenarioRunPlan, + ScenarioRunPlanSeedGroup, +} from '@/types' + +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' + +interface ScenarioRunObjectivesSectionProps { + readonly summary: ScenarioProgressSummary + readonly plan: ScenarioRunPlan | null + readonly results: readonly ScenarioProgressResult[] + readonly activeConversations: readonly ScenarioActiveConversation[] + readonly onOpenObjective: (objective: ScenarioRunPlanSeedGroup, trigger: HTMLElement) => void +} + +function seedDatasetNames(identities: ScenarioComponentIdentity[]): string { + return [...new Set(identities.flatMap((identity: ScenarioComponentIdentity) => + typeof identity.parameters.dataset_name === 'string' ? [identity.parameters.dataset_name] : []))].join(', ') +} + +/** Each objective's dataset, attack success, and completion, opening its seed prompts and attacks. */ +export default function ScenarioRunObjectivesSection({ + summary, plan, results, activeConversations, onOpenObjective, +}: ScenarioRunObjectivesSectionProps) { + const styles = useScenarioRunPageStyles() + const planSeeds = useMemo( + () => new Map(plan?.seed_groups.map((seed) => [seed.id, seed]) ?? []), + [plan], + ) + const datasetsBySeed = useMemo(() => { + const identities = new Map() + for (const attempt of [...results, ...activeConversations]) { + if (!attempt.seed_group_id) continue + const seeds = identities.get(attempt.seed_group_id) ?? [] + seeds.push(...(attempt.seed_identifiers ?? [])) + identities.set(attempt.seed_group_id, seeds) + } + return new Map([...identities].map(([id, seeds]) => [id, seedDatasetNames(seeds)])) + }, [results, activeConversations]) + + return ( +
+ Datasets & objectives +
+ + DatasetObjective + Attack successCompleted + + {summary.seed_groups.map((seed) => ( + + {datasetsBySeed.get(seed.id) || 'Not recorded'} + + {formatProgressSuccess(seed)} + {seed.completed}{seed.planned === null ? '' : ` / ${seed.planned}`} + + ))} +
+
+ ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts b/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts index 5ed7396aac..84ccca2689 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts +++ b/frontend/src/components/Scenarios/ScenarioRunPage.styles.ts @@ -65,6 +65,8 @@ export const useScenarioRunPageStyles = makeStyles({ }, titleRow: { display: 'flex', + minWidth: 0, + overflowWrap: 'anywhere', alignItems: 'center', flexWrap: 'wrap', gap: tokens.spacingHorizontalS, @@ -92,33 +94,13 @@ export const useScenarioRunPageStyles = makeStyles({ flexGrow: 1, }, }, - metadata: { - display: 'grid', - gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', - gap: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalXL}`, - padding: tokens.spacingVerticalL, - border: `1px solid ${tokens.colorNeutralStroke2}`, - borderRadius: tokens.borderRadiusLarge, - backgroundColor: tokens.colorNeutralBackground1, - [NARROW_VIEWPORT_QUERY]: { - gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', - }, - }, - metadataItem: { + progressTiming: { display: 'flex', flexDirection: 'column', - gap: tokens.spacingVerticalXXS, + gap: tokens.spacingVerticalXS, minWidth: 0, - overflowWrap: 'anywhere', - }, - labelBadge: { - maxWidth: '100%', - height: 'auto', - whiteSpace: 'normal', - overflowWrap: 'anywhere', - }, - metadataLabel: { color: tokens.colorNeutralForeground3, + fontVariantNumeric: 'tabular-nums', }, section: { display: 'flex', @@ -155,6 +137,20 @@ export const useScenarioRunPageStyles = makeStyles({ gap: tokens.spacingVerticalM, }, }, + progressRegion: { + display: 'grid', + gridTemplateRows: '1fr', + opacity: 1, + transitionProperty: 'grid-template-rows, opacity', + transitionDuration: tokens.durationNormal, + transitionTimingFunction: tokens.curveDecelerateMid, + '@media (prefers-reduced-motion: reduce)': { transitionDuration: '0s' }, + }, + progressRegionComplete: { + gridTemplateRows: '0fr', + opacity: 0, + }, + progressClip: { minHeight: 0, overflow: 'hidden' }, progressPrimary: { display: 'flex', flexDirection: 'column', @@ -264,13 +260,13 @@ export const useScenarioRunPageStyles = makeStyles({ border: `1px solid ${tokens.colorNeutralStroke2}`, borderRadius: tokens.borderRadiusMedium, }, - conversationSurface: { + liveActivity: { display: 'flex', flexDirection: 'column', - minHeight: '28rem', - backgroundColor: tokens.colorNeutralBackground1, - border: `1px solid ${tokens.colorNeutralStroke2}`, - borderRadius: tokens.borderRadiusLarge, + gap: tokens.spacingVerticalM, + maxHeight: '60vh', + maxWidth: `min(48rem, calc(100vw - ${tokens.spacingHorizontalXXL} * 2))`, + overflowY: 'auto', }, displayGroup: { border: `1px solid ${tokens.colorNeutralStroke2}`, @@ -394,8 +390,17 @@ export const useScenarioRunPageStyles = makeStyles({ minHeight: 0, maxHeight: 'calc(90vh - 48px)', }, + attackDetailDialogTitle: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalXXS, + minWidth: 0, + overflowWrap: 'anywhere', + }, attackDetailDialogContent: { minHeight: 0, + paddingTop: tokens.spacingVerticalL, overflowY: 'auto', }, detailList: { diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx index 431bb5f3b0..bec765ddc4 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.test.tsx @@ -7,8 +7,8 @@ import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-rou import { useScenarioQueue } from '@/hooks/useScenarioQueue' import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' -import { scenariosApi } from '@/services/api' -import type { ScenarioProgressResult, ScenarioProgressSummary, ScenarioRunPlan, ScenarioRunSummary } from '@/types' +import { attacksApi, scenariosApi } from '@/services/api' +import type { AttackFindingItem, ScenarioActiveConversation, ScenarioProgressResult, ScenarioProgressSummary, ScenarioRunPlan, ScenarioRunSummary } from '@/types' import { scenarioRunAttackRoutePath, scenarioRunRoutePath } from '@/utils/routeParams' import { INITIAL_SCENARIO_RUN_PROGRESS_STATE, type ScenarioRunProgressState } from '@/utils/scenarioRunProgress' @@ -16,7 +16,7 @@ import ScenarioRunPage from './ScenarioRunPage' jest.mock('@/hooks/useScenarioRunProgress', () => ({ useScenarioRunProgress: jest.fn() })) jest.mock('@/hooks/useScenarioQueue', () => ({ useScenarioQueue: jest.fn() })) -jest.mock('@/services/api', () => ({ scenariosApi: { +jest.mock('@/services/api', () => ({ attacksApi: { getAttackFindings: jest.fn() }, scenariosApi: { cancelRun: jest.fn(), resumeRun: jest.fn(), getLaunchConfiguration: jest.fn(), startRun: jest.fn(), getQueue: jest.fn(), getRunHierarchy: jest.fn(() => new Promise(() => {})), getRunHierarchyNode: jest.fn(), } })) @@ -123,6 +123,7 @@ beforeEach(() => { error: null, retry: queueRetry, }) + jest.mocked(attacksApi.getAttackFindings).mockImplementation(async (id: string) => ({ attack_result_id: id, items: [] })) state = makeState() jest.mocked(useScenarioRunProgress).mockImplementation(() => ({ state, retry, applyRunSummary })) }) @@ -133,8 +134,10 @@ describe('ScenarioRunPage', () => { render() expect(screen.getByRole('heading', { name: 'test.scenario' })).toBeInTheDocument() expect(screen.getByText(RUN.description)).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'Review operation' })).toHaveAttribute('href', `/operations/${RUN.operation_id}`) - expect(screen.getByText('review-model')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Operation: Review operation' })).toHaveAttribute('href', `/operations/${RUN.operation_id}`) + expect(screen.queryByLabelText('Scan summary')).not.toBeInTheDocument() + expect(screen.queryByText('review-model')).not.toBeInTheDocument() + expect(screen.queryByText('Errors / retries')).not.toBeInTheDocument() expect(screen.getAllByRole('heading', { level: 2 }).map((heading) => heading.textContent)).toEqual([ 'Atomic attack groups', 'Datasets & objectives', 'Scenario configuration', ]) @@ -142,8 +145,14 @@ describe('ScenarioRunPage', () => { expect(scenariosApi.getQueue).not.toHaveBeenCalled() expect(screen.getByRole('link', { name: 'View queue' })).toHaveAttribute('href', '/scanner/queue') await expand(user) - expect(screen.getByRole('columnheader', { name: 'Duration' })).toBeInTheDocument() - expect(screen.queryByRole('columnheader', { name: 'Timestamp' })).not.toBeInTheDocument() + const results = within(screen.getByRole('table', { name: 'Direct attack results' })) + expect(results.getAllByRole('columnheader').map((header: HTMLElement) => header.textContent)).toEqual([ + 'Objective', 'Outcome', 'Duration', 'Retries / error', 'Conversation', + ]) + expect(results.getAllByRole('cell', { name: 'Duration' })).toHaveLength(2) + expect(results.queryByRole('cell', { name: 'Timestamp' })).not.toBeInTheDocument() + expect(results.queryByText('Target-facing')).not.toBeInTheDocument() + expect(results.getAllByText('direct')).toHaveLength(2) expect(screen.getByText('True', { exact: true })).toBeInTheDocument() expect(screen.getByText('False', { exact: true })).toBeInTheDocument() }) @@ -164,35 +173,62 @@ describe('ScenarioRunPage', () => { expect(screen.queryByRole('button', { name: 'Cancel', exact: true })).not.toBeInTheDocument() }) - it('opens identity-only details by keyboard and restores focus on close', async () => { + it('opens attack details from a row by keyboard and restores focus on close', async () => { const user = userEvent.setup() render() await expand(user) - const details = screen.getAllByRole('button', { name: /^Open details for PromptSendingAttack:/ })[0] - details.focus() + const row = screen.getByRole('row', { name: 'View attack details for PromptSendingAttack: First objective' }) + row.focus() await user.keyboard('{Enter}') const dialog = within(await screen.findByRole('dialog')) - expect(dialog.queryByText(/total duration/i)).not.toBeInTheDocument() - expect(dialog.getByRole('link', { name: 'Open conversation' })).toBeInTheDocument() + expect(dialog.getByRole('link', { name: 'Operation: Review operation' })).toHaveAttribute('href', `/operations/${RUN.operation_id}`) + expect(dialog.getByText('Outcome:', { exact: true })).toBeInTheDocument() expect(dialog.getAllByText('The objective was met.', { exact: true })).toHaveLength(1) - expect(dialog.getAllByText('5s', { exact: true })).toHaveLength(1) - expect(dialog.queryByText(/5s · 1 retries/)).not.toBeInTheDocument() - expect(dialog.getAllByText('Execution time', { exact: true })).toHaveLength(1) - expect(dialog.getAllByText('Retries', { exact: true })).toHaveLength(1) - expect(dialog.getAllByText('Timestamp', { exact: true })).toHaveLength(1) - expect(dialog.queryByText('Attack Success', { exact: true })).not.toBeInTheDocument() - await user.click(dialog.getByRole('button', { name: 'Identity for direct' })) - expect(JSON.parse(dialog.getByRole('region', { name: 'Identity for direct' }).textContent ?? '')).toEqual(TECHNIQUE) - await user.click(dialog.getByRole('button', { name: 'Identity for TrueFalseCompositeScorer' })) - const scorer = JSON.parse(dialog.getByRole('region', { name: 'Identity for TrueFalseCompositeScorer' }).textContent ?? '') + expect(dialog.getByLabelText('Attack timing')).toHaveTextContent(/^5s · 1 retry · /) + expect(dialog.queryByText('Execution time', { exact: true })).not.toBeInTheDocument() + expect(dialog.queryByText('Attack technique', { exact: true })).not.toBeInTheDocument() + expect(dialog.queryByRole('list', { name: 'Attached findings' })).not.toBeInTheDocument() + expect(dialog.queryByRole('button', { name: 'Close', exact: true })).not.toBeInTheDocument() + const components = within(dialog.getByLabelText('Attack components')) + await user.click(components.getByRole('button', { name: 'Identity for direct' })) + expect(JSON.parse(components.getByRole('region', { name: 'Identity for direct' }).textContent ?? '')).toEqual(TECHNIQUE) + await user.click(components.getByRole('button', { name: 'Identity for TrueFalseCompositeScorer' })) + const scorer = JSON.parse(components.getByRole('region', { name: 'Identity for TrueFalseCompositeScorer' }).textContent ?? '') expect(scorer.children.sub_scorers[0].parameters.score_aggregator).toBe('OR') - await user.click(dialog.getByRole('button', { name: 'Identity for review' })) - expect(JSON.parse(dialog.getByRole('region', { name: 'Identity for review' }).textContent ?? '')).toEqual(SEEDS) - expect(dialog.queryByText('Configuration', { exact: true })).not.toBeInTheDocument() + await user.click(components.getByRole('button', { name: 'Identity for review' })) + expect(JSON.parse(components.getByRole('region', { name: 'Identity for review' }).textContent ?? '')).toEqual(SEEDS) + expect(dialog.getByRole('link', { name: 'Open conversation' })).toHaveAttribute( + 'href', `/attack/chat/attempt-1/conversations/conversation-1?scenarioResultId=${RUN_ID}`, + ) + expect(dialog.getByRole('button', { name: 'Attach to findings' })).toBeEnabled() await user.click(dialog.getByRole('button', { name: 'Close attack details' })) - await waitFor(() => expect(details).toHaveFocus()) + await waitFor(() => expect(row).toHaveFocus()) }) + it('opens the shared score view from a result outcome without opening details', async () => { + const user = userEvent.setup() + render() + await expand(user) + await user.click(screen.getByRole('button', { name: 'True: View score for PromptSendingAttack: First objective' })) + expect(await screen.findByText('Score details')).toBeInTheDocument() + expect(screen.getByText('The objective was met.')).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('shows findings linked to the attack only when there are some', async () => { + const finding: AttackFindingItem = { + id: 'finding-1', operation_id: RUN.operation_id, operation_name: 'Review operation', evidence_id: 'evidence-1', + title: 'Protected instructions disclosed', description: 'Review the saved response.', severity: 'important', + created_at: RUN.created_at, + } + jest.mocked(attacksApi.getAttackFindings).mockResolvedValue({ attack_result_id: ATTEMPT.attack_result_id, items: [finding] }) + render() + const dialog = within(screen.getByRole('dialog')) + const findings = await dialog.findByRole('list', { name: 'Attached findings' }) + expect(within(findings).getAllByRole('listitem')).toHaveLength(1) + expect(findings).toHaveTextContent(finding.title) + expect(attacksApi.getAttackFindings).toHaveBeenCalledWith(ATTEMPT.attack_result_id) + }) it('keeps canonical conversation navigation separate from row details', async () => { const user = userEvent.setup() render() @@ -209,8 +245,8 @@ describe('ScenarioRunPage', () => { const dialog = within(await screen.findByRole('dialog')) expect(dialog.getByText('First seed prompt')).toBeInTheDocument() expect(dialog.getByRole('heading', { name: 'Atomic attacks' })).toBeInTheDocument() - expect(dialog.getAllByRole('button', { name: /^Open details for PromptSendingAttack:/ })).toHaveLength(1) - await user.click(dialog.getByRole('button', { name: /^Open details for PromptSendingAttack:/ })) + expect(dialog.getByText('direct')).toBeInTheDocument() + await user.click(dialog.getByRole('row', { name: 'View attack details for PromptSendingAttack: First objective' })) expect(await screen.findByRole('button', { name: 'Close attack details' })).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Close attack details' })) await waitFor(() => expect(screen.getByRole('button', { name: 'First objective' })).toHaveFocus()) @@ -218,7 +254,7 @@ describe('ScenarioRunPage', () => { it('shows per-group and per-objective success rates without restoring paired outcomes', () => { render() - expect(within(screen.getByLabelText('Scan summary')).getByText('50%', { exact: true })).toBeInTheDocument() + expect(screen.getByText('50%', { exact: true })).toBeInTheDocument() const objectives = within(screen.getByRole('table', { name: 'Datasets and objectives' })) expect(objectives.getByRole('row', { name: /First objective/ })).toHaveTextContent('100%') expect(objectives.getByRole('row', { name: /Second objective/ })).toHaveTextContent('0%') @@ -235,9 +271,9 @@ describe('ScenarioRunPage', () => { }) render() expect(screen.getByRole('link', { name: 'Open saved conversation' })).toHaveAttribute( - 'href', `/scanner/history/${RUN_ID}/conversations/saved`, + 'href', `/attack/chat/interrupted/conversations/saved?scenarioResultId=${RUN_ID}`, ) - expect(screen.queryByRole('row', { name: /View details for/ })).not.toBeInTheDocument() + expect(screen.queryByRole('row', { name: /View attack details for direct/ })).not.toBeInTheDocument() }) it('keeps distinct interrupted retry conversations and excludes completed transcripts', () => { @@ -257,7 +293,7 @@ describe('ScenarioRunPage', () => { const savedLinks = screen.getAllByRole('link', { name: 'Open saved conversation' }) expect(savedLinks).toHaveLength(1) expect(savedLinks[0]).toHaveAttribute( - 'href', `/scanner/history/${RUN_ID}/conversations/interrupted-retry`, + 'href', `/attack/chat/attempt-1/conversations/interrupted-retry?scenarioResultId=${RUN_ID}`, ) }) @@ -320,7 +356,7 @@ describe('ScenarioRunPage', () => { const user = userEvent.setup() jest.mocked(scenariosApi.getLaunchConfiguration).mockResolvedValue({ scenario_name: 'test.scenario', target_name: 'original' }) render() - await user.click(screen.getByRole('button', { name: 'Rerun' })) + await user.click(screen.getByRole('button', { name: 'Rerun with changes' })) expect(scenariosApi.getLaunchConfiguration).toHaveBeenCalledWith(RUN_ID, expect.any(AbortSignal)) await waitFor(() => expect(screen.getByLabelText('Location')).toHaveTextContent(`/scanner/launch/test.scenario?rerun=${RUN_ID}`)) expect(scenariosApi.startRun).not.toHaveBeenCalled() @@ -335,7 +371,7 @@ describe('ScenarioRunPage', () => { requestSignal = signal })) render() - await user.click(screen.getByRole('button', { name: 'Rerun' })) + await user.click(screen.getByRole('button', { name: 'Rerun with changes' })) expect(requestSignal).toBeDefined() await user.click(screen.getByRole('button', { name: 'Leave run' })) expect(requestSignal?.aborted).toBe(true) @@ -357,7 +393,7 @@ describe('ScenarioRunPage', () => { }) render() await expand(user) - await user.click(screen.getByRole('button', { name: /^Open details for PromptSendingAttack:/ })) + await user.click(screen.getByRole('row', { name: /^View attack details for PromptSendingAttack:/ })) const dialog = within(await screen.findByRole('dialog')) expect(dialog.getAllByText('TimeoutError: The target timed out.', { exact: true })).toHaveLength(1) expect(dialog.queryByRole('button', { name: 'Error details' })).not.toBeInTheDocument() @@ -367,7 +403,7 @@ describe('ScenarioRunPage', () => { const user = userEvent.setup() jest.mocked(scenariosApi.getLaunchConfiguration).mockRejectedValue(new Error('Saved configuration is unavailable')) render() - await user.click(screen.getByRole('button', { name: 'Rerun' })) + await user.click(screen.getByRole('button', { name: 'Rerun with changes' })) expect(await screen.findByText('Saved configuration is unavailable')).toBeInTheDocument() expect(screen.getByLabelText('Location')).toHaveTextContent(scenarioRunRoutePath(RUN_ID)) expect(scenariosApi.startRun).not.toHaveBeenCalled() @@ -382,7 +418,7 @@ describe('ScenarioRunPage', () => { requestSignal = signal })) render() - await user.click(screen.getByRole('button', { name: 'Rerun' })) + await user.click(screen.getByRole('button', { name: 'Rerun with changes' })) await user.click(screen.getByRole('button', { name: 'Open attempt' })) expect(requestSignal?.aborted).toBe(true) const resolve = resolveConfiguration @@ -450,7 +486,7 @@ describe('ScenarioRunPage', () => { render() await user.click(screen.getByRole('button', { name: 'Resume' })) expect(await screen.findByText('Resume rejected')).toBeInTheDocument() - expect(within(screen.getByLabelText('Scan summary')).getByText('50%', { exact: true })).toBeInTheDocument() + expect(screen.getByText('50%', { exact: true })).toBeInTheDocument() expect(retry).toHaveBeenCalled() }) @@ -473,9 +509,12 @@ describe('ScenarioRunPage', () => { }] }) render() const dialog = within(screen.getByRole('dialog')) - expect(dialog.getByRole('link', { name: 'Watch live conversation' })).toHaveAttribute('href', `/scanner/history/${RUN_ID}/conversations/live-conversation`) + expect(dialog.getByRole('link', { name: 'Watch live conversation' })).toHaveAttribute( + 'href', `/attack/chat/live/conversations/live-conversation?scenarioResultId=${RUN_ID}`, + ) expect(dialog.getByText('In progress')).toBeInTheDocument() - expect(within(screen.getByLabelText('Scan summary')).getByText('50%', { exact: true })).toBeInTheDocument() + expect(dialog.getByRole('button', { name: 'Attach to findings' })).toBeDisabled() + expect(screen.getByText('50%', { exact: true })).toBeInTheDocument() }) it('retains explicit stale recovery and previously loaded data', async () => { @@ -490,10 +529,176 @@ describe('ScenarioRunPage', () => { it.each(['loading', 'not-found', 'error'] as const)('renders the %s state with recovery', (loadStatus) => { state = makeState({ run: null, summary: null, loadStatus, error: loadStatus === 'loading' ? null : 'Scan unavailable' }) render() - if (loadStatus === 'loading') expect(screen.getByText('Loading scan...')).toBeInTheDocument() + if (loadStatus === 'loading') expect(screen.getByText('Loading scan')).toBeInTheDocument() else expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() }) + it('groups an attack with several live conversations into one live entry without listing them as saved', async () => { + const user = userEvent.setup() + const conversation: ScenarioActiveConversation = { + attack_result_id: 'live', conversation_id: 'branch-1', conversation_ids: ['branch-1', 'branch-2'], + atomic_attack_name: 'Branching attack', objective: 'First objective', started_at: RUN.started_at, seed_identifiers: [], + } + state = makeState({ results: [], activeConversations: [conversation], recordedConversations: [conversation, { ...conversation, conversation_id: 'branch-2' }] }) + render() + await user.click(screen.getByRole('button', { name: 'View live attacks (1)' })) + expect(screen.getAllByRole('link', { name: 'Watch live conversation' })).toHaveLength(1) + expect(screen.getByText('2 live conversations')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Saved conversations' })).not.toBeInTheDocument() + }) + + it('keeps live attacks in the progress area instead of inserting a block above the results', async () => { + const user = userEvent.setup() + state = makeState({ results: [], summary: { ...SUMMARY, overall: { ...COUNTS, completed: 0 } } }) + const view = render() + const progress = screen.getByRole('region', { name: 'Scan progress' }) + const results = screen.getByRole('heading', { name: 'Atomic attack groups' }) + expect(screen.getByRole('button', { name: 'View live attacks (0)' })).toBeDisabled() + expect(screen.getByText('Estimating time remaining')).toBeInTheDocument() + state = { ...state, summary: { ...SUMMARY, overall: { ...COUNTS, completed: 1 } }, activeConversations: [{ + attack_result_id: 'live', conversation_id: 'branch', atomic_attack_name: 'PromptSendingAttack', + objective: 'First objective', seed_group_id: 'seed-1', started_at: RUN.started_at, seed_identifiers: [], + }] } + view.rerender() + expect(screen.getByRole('region', { name: 'Scan progress' })).toBe(progress) + expect(screen.getByRole('heading', { name: 'Atomic attack groups' })).toBe(results) + await user.click(within(progress).getByRole('button', { name: 'View live attacks (1)' })) + expect(screen.getByRole('link', { name: 'Watch live conversation' })).toHaveAttribute( + 'href', `/attack/chat/live/conversations/branch?scenarioResultId=${RUN_ID}`, + ) + await user.keyboard('{Escape}') + expect(screen.getByRole('button', { name: 'View live attacks (1)' })).toHaveFocus() + }) + + it('keeps the finished progress area mounted but hidden and inert so the results do not move', () => { + const view = render() + const progress = screen.getByRole('region', { name: 'Scan progress' }) + state = makeState({ run: { ...RUN, status: 'COMPLETED', completed_at: '2026-01-01T00:00:10Z' } }) + view.rerender() + expect(screen.queryByRole('region', { name: 'Scan progress' })).not.toBeInTheDocument() + expect(progress).toBeInTheDocument() + const wrapper = progress.closest('[aria-hidden="true"]') + expect(wrapper).not.toBeNull() + expect(wrapper).toHaveAttribute('inert') + }) + + it('shows a single completed atomic attack as one framed card without group sections', () => { + state = makeState({ + run: { ...RUN, scenario_registry_name: 'technique.execute', status: 'COMPLETED' }, + summary: { ...SUMMARY, overall: { ...COUNTS, completed: 1, planned: 1 } }, + results: [ATTEMPT], + }) + render() + const details = within(screen.getByRole('region', { name: 'Attack details' })) + expect(screen.queryByRole('heading', { name: 'Atomic attack groups' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Datasets & objectives' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Scenario configuration' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View live attacks (0)' })).not.toBeInTheDocument() + const outcome = within(details.getByLabelText('Attack outcome')) + expect(outcome.getByText('Objective:')).toBeInTheDocument() + expect(outcome.getByText('First objective')).toBeInTheDocument() + expect(outcome.getByText('Outcome:')).toBeInTheDocument() + expect(details.getByLabelText('Attack components')).toBeInTheDocument() + expect(details.getByRole('link', { name: 'Open conversation' })).toBeInTheDocument() + }) + + it('keeps the single attack details mounted from preparing through running to completed', async () => { + const user = userEvent.setup() + const live: ScenarioActiveConversation = { + attack_result_id: 'attempt-1', conversation_id: 'branch', atomic_attack_name: 'PromptSendingAttack', + seed_group_id: 'seed-1', objective: 'First objective', started_at: RUN.started_at, seed_identifiers: [], + } + const single = { ...SUMMARY, overall: { ...COUNTS, completed: 0, planned: 1 } } + state = makeState({ summary: single, results: [], activeConversations: [] }) + const view = render() + const details = screen.getByRole('region', { name: 'Attack details' }) + const components = screen.getByLabelText('Attack components') + expect(screen.getByText('Preparing', { exact: true })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Watch live conversation' })).toBeDisabled() + await user.click(screen.getByRole('button', { name: 'Identity for direct' })) + + state = { ...state, activeConversations: [live] } + view.rerender() + expect(screen.getByRole('region', { name: 'Attack details' })).toBe(details) + expect(screen.getByLabelText('Attack components')).toBe(components) + expect(screen.getByRole('button', { name: 'Identity for direct' })).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('In progress', { exact: true })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Watch live conversation' })).toHaveAttribute( + 'href', `/attack/chat/attempt-1/conversations/branch?scenarioResultId=${RUN_ID}`, + ) + + state = makeState({ + run: { ...RUN, status: 'COMPLETED' }, summary: { ...single, overall: { ...single.overall, completed: 1 } }, + results: [ATTEMPT], activeConversations: [], + }) + view.rerender() + expect(screen.getByRole('region', { name: 'Attack details' })).toBe(details) + expect(screen.getByText('True', { exact: true })).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Open conversation' })).toBeInTheDocument() + }) + + it('shows a completed single attack error once while keeping scan-level notices', () => { + state = makeState({ + run: { ...RUN, status: 'FAILED', error: 'The target timed out.', error_type: 'TimeoutError' }, + summary: { ...SUMMARY, overall: { ...COUNTS, completed: 1, planned: 1, errors: 1 } }, + results: [{ ...ATTEMPT, outcome: 'error', score: null, error_type: 'TimeoutError', error_message: 'The target timed out.' }], + planComplete: false, + }) + render() + expect(screen.getAllByText('TimeoutError: The target timed out.', { exact: true })).toHaveLength(1) + expect(screen.queryByText('Scan failed. Resume to continue remaining attacks.')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Error details' })).not.toBeInTheDocument() + expect(screen.getByLabelText('Scan notices')).toHaveTextContent('The saved execution plan is incomplete') + expect(screen.getByRole('button', { name: 'Resume', exact: true })).toBeInTheDocument() + }) + + it('keeps a distinct run failure when the single attack has its own error', async () => { + const user = userEvent.setup() + state = makeState({ + run: { ...RUN, status: 'FAILED', error: 'The worker stopped unexpectedly.', error_type: 'WorkerError' }, + summary: { ...SUMMARY, overall: { ...COUNTS, completed: 1, planned: 1, errors: 1 } }, + results: [{ ...ATTEMPT, outcome: 'error', score: null, error_type: 'TimeoutError', error_message: 'The target timed out.' }], + }) + render() + expect(screen.getByText('TimeoutError: The target timed out.', { exact: true })).toBeInTheDocument() + expect(screen.getByText('Scan failed. Resume to continue remaining attacks.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View attack' })).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Error details' })) + expect(screen.getByText('WorkerError: The worker stopped unexpectedly.', { exact: true })).toBeInTheDocument() + }) + + it('does not present a finished single attack without details as still preparing', () => { + state = makeState({ + run: { ...RUN, status: 'COMPLETED' }, + summary: { ...SUMMARY, overall: { ...COUNTS, completed: 1, planned: 1 } }, + results: [], + }) + render() + expect(screen.getByText('Attack details are not available.')).toBeInTheDocument() + expect(screen.queryByText('Preparing', { exact: true })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Watch live conversation' })).not.toBeInTheDocument() + }) + it('reloads saved results once when the run finishes so they replace the running snapshot', async () => { + const projection = { + scenario_result_id: RUN_ID, scenario_name: 'TestScenario', scenario_version: 1, status: 'IN_PROGRESS' as const, groups: [], + overall_counts: COUNTS, + diagnostics: { + failed_attacks: [], failed_attacks_count: 0, failed_attacks_truncated: false, + attack_retries: [], attack_retries_count: 0, attack_retries_truncated: false, + total_retries: 0, rootless_result_ids: [], rootless_results_count: 0, rootless_results_truncated: false, + }, + has_more: false, truncated: false, + } + jest.mocked(scenariosApi.getRunHierarchy).mockResolvedValueOnce(projection) + .mockResolvedValueOnce({ ...projection, status: 'COMPLETED' }) + const view = render() + await waitFor(() => expect(scenariosApi.getRunHierarchy).toHaveBeenCalledTimes(1)) + state = makeState({ run: { ...RUN, status: 'COMPLETED', completed_at: '2026-01-01T00:00:10Z' } }) + view.rerender() + await waitFor(() => expect(scenariosApi.getRunHierarchy).toHaveBeenCalledTimes(2)) + await act(async () => { await Promise.resolve() }) + expect(scenariosApi.getRunHierarchy).toHaveBeenCalledTimes(2) + }) it('retains history filters and transient suite return navigation', () => { render() expect(screen.getByRole('link', { name: 'Back to Review suite' })).toHaveAttribute('href', '/scanner/history/suites/executions/suite') diff --git a/frontend/src/components/Scenarios/ScenarioRunPage.tsx b/frontend/src/components/Scenarios/ScenarioRunPage.tsx index ccaa19709f..7140732b1c 100644 --- a/frontend/src/components/Scenarios/ScenarioRunPage.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunPage.tsx @@ -1,54 +1,32 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { - Accordion, AccordionHeader, AccordionItem, AccordionPanel, Badge, Button, - Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, - MessageBar, MessageBarBody, ProgressBar, Spinner, Table, TableBody, TableCell, - TableHeader, TableHeaderCell, TableRow, Text, -} from '@fluentui/react-components' -import { ArrowLeftRegular, ArrowClockwiseRegular, ChevronDownRegular, ChevronRightRegular, DismissRegular, PlayRegular, StopRegular } from '@fluentui/react-icons' -import { Link, useLocation, useNavigate, useParams } from 'react-router' +import { Button, MessageBar, MessageBarBody, Text } from '@fluentui/react-components' +import { ArrowLeftRegular } from '@fluentui/react-icons' +import { Link, useLocation, useParams } from 'react-router' -import AttackAttemptDetails from '@/components/AttackResults/AttackAttemptDetails' -import { formatProgressSuccess, objectivePreview } from '@/components/AttackResults/attackAttemptFormatting' -import RegistryIdentityRow from '@/components/Registry/RegistryIdentityRow' -import { suiteExecutionRoutePath } from '@/components/ScenarioSuites/suiteRoutes' -import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' -import { useScenarioResultHierarchy } from '@/hooks/useScenarioResultHierarchy' -import { useScenarioRunResume } from '@/hooks/useScenarioRunResume' import { useScenarioQueue } from '@/hooks/useScenarioQueue' -import { scenariosApi } from '@/services/api' -import { toApiError } from '@/services/errors' -import type { - ScenarioActiveConversation, - ScenarioComponentIdentity, - ScenarioHierarchyNode, - ScenarioProgressResult, - ScenarioRunPlanSeedGroup, - ScenarioRunState, -} from '@/types' -import { - attackConversationRoutePath, - routerPathParamValue, - scenarioConversationRoutePath, - scenarioRerunRoutePath, - scenarioRunAttackRoutePath, - scenarioRunRoutePath, -} from '@/utils/routeParams' +import { useScenarioResultHierarchy } from '@/hooks/useScenarioResultHierarchy' +import { useScenarioRunAttackDetails } from '@/hooks/useScenarioRunAttackDetails' +import { useScenarioRunControls } from '@/hooks/useScenarioRunControls' +import { useScenarioRunProgress } from '@/hooks/useScenarioRunProgress' +import type { ScenarioProgressResult, ScenarioRunPlanSeedGroup } from '@/types' +import { routerPathParamValue } from '@/utils/routeParams' import { isTerminalRunState } from '@/utils/scenarioRunProgress' -import AttackExecutionTable from './AttackExecutionTable' -import { ObjectiveDetailsDialog } from './ScenarioRunDialogs' +import AtomicAttackGroupsSection from './AtomicAttackGroupsSection' +import AttackActivity from './AttackActivity' +import AttackDetailsDialog from './AttackDetailsDialog' +import ScenarioAttackDetails, { type ScenarioRunDetailsContext } from './ScenarioAttackDetails' +import ScenarioRunConfigurationSection from './ScenarioRunConfigurationSection' +import { CancelScanDialog, ObjectiveDetailsDialog } from './ScenarioRunDialogs' +import ScenarioRunHeader from './ScenarioRunHeader' +import ScenarioRunNotices from './ScenarioRunNotices' +import ScenarioRunObjectivesSection from './ScenarioRunObjectivesSection' import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' -import ScenarioRunTiming from './ScenarioRunTiming' -import ScenarioResultHierarchy from './ScenarioResultHierarchy' -import { hierarchyDisplayGroups } from './scenarioHierarchy' - -const MAX_VISIBLE_ATTEMPTS_PER_GROUP = 100 -const AUTO_EXPAND_GROUP_LIMIT = 20 -const STATE_COLORS: Record = { - CREATED: 'informative', QUEUED: 'informative', IN_PROGRESS: 'brand', COMPLETED: 'success', FAILED: 'danger', CANCELLED: 'warning', -} +import ScenarioRunProgress from './ScenarioRunProgress' +import ScenarioRunSavedConversations from './ScenarioRunSavedConversations' +import { interruptedConversations, liveAttacks, singleAtomicGroupId, singleAttackAttempt } from './scenarioRunLayout' +import { scenarioRunBackLink, type ScenarioRunNavigationState } from './scenarioRunNavigation' export default function ScenarioRunPage() { const { scenarioResultId, attackResultId } = useParams<{ scenarioResultId: string; attackResultId?: string }>() @@ -61,71 +39,40 @@ interface ScenarioRunContentProps { readonly attackResultId: string | null } -function seedDatasetNames(identities: ScenarioComponentIdentity[]): string { - return [...new Set(identities.flatMap((identity: ScenarioComponentIdentity) => - typeof identity.parameters.dataset_name === 'string' ? [identity.parameters.dataset_name] : []))].join(', ') -} - function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunContentProps) { const styles = useScenarioRunPageStyles() - const navigate = useNavigate() const location = useLocation() - const navigationState = location.state as { - fromScenarioHistory?: boolean - scenarioHistorySearch?: string - scenarioName?: string - suiteExecutionId?: string - suiteName?: string - } | null - const backPath = navigationState?.suiteExecutionId - ? suiteExecutionRoutePath(navigationState.suiteExecutionId) - : navigationState?.fromScenarioHistory || navigationState?.scenarioHistorySearch - ? `/scanner/history${navigationState.scenarioHistorySearch ?? ''}` - : navigationState?.scenarioName - ? `/scanner/launch/${encodeURIComponent(navigationState.scenarioName)}` - : '/scanner/launch' - const backLabel = navigationState?.suiteExecutionId - ? `Back to ${navigationState.suiteName ?? 'suite execution'}` - : navigationState?.fromScenarioHistory || navigationState?.scenarioHistorySearch - ? 'Back to scans' - : navigationState?.scenarioName ? 'Back to scenario' : 'Back to scanners' + const backLink = scenarioRunBackLink(location.state as ScenarioRunNavigationState | null) const { state, retry, applyRunSummary } = useScenarioRunProgress(scenarioResultId) const hierarchy = useScenarioResultHierarchy(scenarioResultId, state.run !== null && state.summary !== null) + const { roots: { status: hierarchyStatus, runStatus: hierarchyRunStatus }, reloadRoots } = hierarchy + const runStatus = state.run?.status + // Saved results load once; reload them when the run finishes so its final results replace the running snapshot. + useEffect(() => { + if (runStatus && isTerminalRunState(runStatus) && hierarchyStatus === 'ready' + && hierarchyRunStatus !== null && runStatus !== hierarchyRunStatus) reloadRoots() + }, [runStatus, hierarchyStatus, hierarchyRunStatus, reloadRoots]) const queue = useScenarioQueue() - const [identityExpanded, setIdentityExpanded] = useState(false) - const [objective, setObjective] = useState(null) - const [cancelOpen, setCancelOpen] = useState(false) - const [cancelling, setCancelling] = useState(false) - const [cancelError, setCancelError] = useState(null) - const [rerunPending, setRerunPending] = useState(false) - const [rerunError, setRerunError] = useState(null) - const rerunRequest = useRef(null) - const detailsTrigger = useRef(null) + const controls = useScenarioRunControls({ + scenarioResultId, applyRunSummary, retry, refreshQueue: queue.retry, runError: state.run?.error, + }) + const headerRef = useRef(null) + const groupsToggleRef = useRef(null) const objectiveTrigger = useRef(null) - const overviewTarget = useRef(null) - const active = Boolean(state.run && !isTerminalRunState(state.run.status)) - const resume = useScenarioRunResume({ - onResumed: applyRunSummary, - onRefresh: (succeeded: boolean): void => { - queue.retry() - if (!succeeded) retry() - }, + const [objective, setObjective] = useState(null) + const attackDetails = useScenarioRunAttackDetails({ + scenarioResultId, + attackResultId, + results: state.results, + activeConversations: state.activeConversations, + fallbackFocus: () => groupsToggleRef.current ?? headerRef.current, }) - const [atomicGroupsExpandedChoice, setAtomicGroupsExpandedChoice] = useState(null) - const [expandedGroupChoices, setExpandedGroupChoices] = useState>(new Map()) - const detailsTriggerRef = useRef(null) - const atomicGroupsToggleRef = useRef(null) - const [hierarchySelection, setHierarchySelection] = useState(null) - useEffect(() => () => { - rerunRequest.current?.abort() - rerunRequest.current = null - }, [location.pathname]) const seedObjectives = useMemo( () => new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed.objective]) ?? []), [state.plan], ) - const planSeeds = useMemo( - () => new Map(state.plan?.seed_groups.map((seed) => [seed.id, seed]) ?? []), + const techniqueNames = useMemo( + () => new Map(state.plan?.atomic_groups.map((group) => [group.id, group.technique_name ?? group.atomic_attack_name]) ?? []), [state.plan], ) const attemptsBySeed = useMemo(() => { @@ -137,142 +84,15 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon } return groups }, [state.results]) - const plannedAtomicGroups = useMemo( - () => new Map(state.plan?.atomic_groups.map((group) => [group.id, group]) ?? []), - [state.plan], - ) - const progressAtomicGroups = useMemo( - () => new Map(state.summary?.atomic_groups.map((group) => [group.id, group]) ?? []), - [state.summary], - ) - // A hierarchy node can be opened before the paged progress feed has delivered it. - const selectedAttempt = useMemo( - () => - state.results.find((attempt) => attempt.attack_result_id === attackResultId) ?? - state.activeConversations.find((conversation) => conversation.attack_result_id === attackResultId) ?? - (hierarchySelection?.attack_result_id === attackResultId ? hierarchySelection : null), - [attackResultId, state.results, state.activeConversations, hierarchySelection], - ) - const selectedAtomicGroupId = selectedAttempt && 'atomic_group_id' in selectedAttempt - ? selectedAttempt.atomic_group_id - : null - const selectedAttemptGroup = selectedAtomicGroupId - ? plannedAtomicGroups.get(selectedAtomicGroupId) - : undefined - // Attempts are grouped by atomic group id, then truncated per group, so expanding an - // older group still shows its own executions rather than an empty table. - const attemptsByGroupId = useMemo(() => { - const groupedAttempts = new Map() - for (let index = state.results.length - 1; index >= 0; index -= 1) { - const attempt = state.results[index] - const existing = groupedAttempts.get(attempt.atomic_group_id) - if (existing) { - existing.push(attempt) - } else { - groupedAttempts.set(attempt.atomic_group_id, [attempt]) - } - } - return groupedAttempts - }, [state.results]) - const datasetsBySeed = useMemo(() => { - const identities = new Map() - for (const attempt of [...state.results, ...state.activeConversations]) { - if (!attempt.seed_group_id) continue - const seeds = identities.get(attempt.seed_group_id) ?? [] - seeds.push(...(attempt.seed_identifiers ?? [])) - identities.set(attempt.seed_group_id, seeds) - } - return new Map([...identities].map(([id, seeds]) => [id, seedDatasetNames(seeds)])) - }, [state.results, state.activeConversations]) - const savedConversations = useMemo(() => { - const completedConversationIds = new Set(state.results.flatMap((attempt) => [ - attempt.conversation_id, - ...(attempt.related_conversation_ids ?? []), - ])) - const recorded = new Map() - for (const conversation of state.recordedConversations) { - if (!completedConversationIds.has(conversation.conversation_id)) { - recorded.set(`${conversation.attack_result_id}:${conversation.conversation_id}`, conversation) - } - } - return [...recorded.values()] - }, [state.results, state.recordedConversations]) - const selectedActive = state.activeConversations.find((conversation) => conversation.attack_result_id === attackResultId) - const runPath = scenarioRunRoutePath(scenarioResultId) - - const closeAttemptDetails = (): void => { - navigate(runPath, { replace: true, state: location.state }) - requestAnimationFrame(() => { - const trigger = detailsTriggerRef.current ?? detailsTrigger.current - const focusTarget = trigger?.isConnected ? trigger : atomicGroupsToggleRef.current ?? overviewTarget.current - focusTarget?.focus() - }) - } + const runningAttacks = useMemo(() => liveAttacks(state.activeConversations), [state.activeConversations]) + const savedConversations = useMemo(() => interruptedConversations(state), [state]) - const openAttemptDetails = ( - attempt: ScenarioProgressResult | ScenarioHierarchyNode, - trigger: HTMLElement, - ): void => { - detailsTriggerRef.current = trigger - detailsTrigger.current = trigger - setHierarchySelection('result_role' in attempt ? attempt : null) - navigate(scenarioRunAttackRoutePath(scenarioResultId, attempt.attack_result_id), { state: location.state }) - } - - const toggleDisplayGroup = (groupId: string, defaultExpanded: boolean): void => { - setExpandedGroupChoices((current) => new Map(current).set( - groupId, - !(current.get(groupId) ?? defaultExpanded), - )) - } - - function openDetails(attempt: ScenarioProgressResult, trigger: HTMLElement): void { - const focusTarget = objective ? objectiveTrigger.current : trigger - detailsTriggerRef.current = focusTarget - detailsTrigger.current = focusTarget - setObjective(null) - setHierarchySelection(null) - navigate(scenarioRunAttackRoutePath(scenarioResultId, attempt.attack_result_id), { state: location.state }) - } - - async function rerun(): Promise { - rerunRequest.current?.abort() - const controller = new AbortController() - rerunRequest.current = controller - setRerunPending(true) - setRerunError(null) - try { - const request = await scenariosApi.getLaunchConfiguration(scenarioResultId, controller.signal) - if (rerunRequest.current !== controller || controller.signal.aborted) return - navigate(scenarioRerunRoutePath(request.scenario_name, scenarioResultId)) - } catch (error: unknown) { - if (rerunRequest.current !== controller || controller.signal.aborted) return - setRerunError(toApiError(error).detail) - } finally { - if (rerunRequest.current === controller) { - rerunRequest.current = null - setRerunPending(false) - } - } - } - - async function cancel(): Promise { - setCancelling(true) - setCancelError(null) - try { - applyRunSummary(await scenariosApi.cancelRun(scenarioResultId)) - setCancelOpen(false) - } catch (error: unknown) { - setCancelError(toApiError(error).detail) - } finally { - setCancelling(false) - } - } + const backLinkElement = {backLink.label} if (!state.run || !state.summary) return (
- {backLabel} - {state.loadStatus === 'loading' ? : ( + {backLinkElement} + {state.loadStatus === 'loading' ? : ( {state.error ?? 'Scan not found.'} )}
@@ -280,338 +100,84 @@ function ScenarioRunContent({ scenarioResultId, attackResultId }: ScenarioRunCon const run = state.run const summary = state.summary - const overall = summary.overall - const outcomes = overall.outcomes - const groups = summary.display_groups ?? summary.techniques - const firstError = state.results.find((attempt) => attempt.outcome === 'error') - const controlError = rerunError ?? resume.error - ?? (resume.executionError !== run.error ? resume.executionError : null) - const notices: string[] = [] - if (state.overloadSummaries.length > 0) notices.push('Service overload detected.') - const unattributedAttempts = summary.unattributed_attempts ?? 0 - const unmatched = unattributedAttempts - if (unmatched > 0) notices.push(`${unmatched} execution${unmatched === 1 ? '' : 's'} could not be matched to the saved plan.`) - if (!state.planComplete) notices.push('The saved execution plan is incomplete; some totals cannot be verified.') - const objectiveScorer = summary.objective_scorer - const displayGroups = hierarchyDisplayGroups(groups, hierarchy.roots.groups) - const producerCounts = overall.producer_counts - const executionCount = producerCounts - ? producerCounts.target_facing.attempts + producerCounts.orchestration.attempts + producerCounts.unknown.attempts - : state.results.length - const atomicGroupsExpanded = atomicGroupsExpandedChoice ?? displayGroups.length <= AUTO_EXPAND_GROUP_LIMIT + const active = !isTerminalRunState(run.status) + const detailsContext: ScenarioRunDetailsContext = { + scenarioResultId, + operationId: run.operation_id, + plan: state.plan, + summary, + activeConversations: state.activeConversations, + hierarchyGroups: hierarchy.roots.groups, + seedObjectives, + conversationState: location.state, + } + // A run of one atomic attack shows that attack directly, from preparing through completion. + const singleGroupId = singleAtomicGroupId(summary, hierarchy.roots.groups, state.results.length) + const singleAttempt = singleGroupId + ? singleAttackAttempt(singleGroupId, state.results, hierarchy.roots.groups, state.activeConversations) + : null + const openObjectiveAttempt = (attempt: ScenarioProgressResult): void => { + attackDetails.open(attempt, objectiveTrigger.current) + setObjective(null) + } + return (
- {backLabel} + {backLinkElement} {!active ? `${run.scenario_registry_name ?? run.scenario_name}: ${run.status.toLowerCase()}.` : ''} -
-
-
- {run.scenario_registry_name ?? run.scenario_name} - - {run.status === 'IN_PROGRESS' ? 'Executing' : run.status.charAt(0) + run.status.slice(1).toLowerCase()} - -
- {run.description && {run.description}} -
-
- {active && View queue} - - {active ? - : (run.status === 'FAILED' || run.status === 'CANCELLED') && } -
-
- {controlError && - {controlError} - } -
-
Operation - {run.operation_id ? {run.operation_name ?? 'Operation'} : Unassigned} -
-
Target{run.target?.model_name ?? run.target?.target_type ?? 'Not recorded'}
-
Timing
-
Attack success - {formatProgressSuccess(overall)} - {outcomes ? `${outcomes.successes} / ${outcomes.total_decided} decided` : `${overall.succeeded} / ${overall.completed} complete`} -
-
Errors / retries{overall.errors} / {overall.retries}
-
Labels -
{Object.entries(run.labels ?? {}).map(([key, value]) => {key}: {value})}
-
-
- {(run.status === 'FAILED' || run.error || overall.errors > 0) && - {run.status === 'FAILED' ? 'Scan failed. Resume to continue remaining attacks.' - : run.status === 'CANCELLED' ? 'Scan cancelled.' - : `${overall.errors} attack${overall.errors === 1 ? '' : 's'} ended with an error.`} - {firstError && } - {run.error && - Error details - {run.error_type ? `${run.error_type}: ` : ''}{run.error} - } - - } - {state.stale && Live updates paused. {state.error} } - {notices.length > 0 && 0 || unmatched > 0 ? 'warning' : 'info'}> - - {notices.join(' ')} - {state.overloadSummaries.length > 0 && - Overload details - {state.overloadSummaries.map((overload) => ( - - {overload.component_role.replace(/_/g, ' ')}: {overload.count} - {overload.status_codes.length > 0 ? ` (HTTP ${overload.status_codes.join(', ')})` : ''} - - ))} - } - - } - {active &&
-
- {run.status === 'QUEUED' ? `Queued${run.queue_position ? ` · Position ${run.queue_position}` : ''}` : `${overall.completed}${overall.planned === null ? '' : ` of ${overall.planned}`} attacks complete`} - -
- -
} - {state.activeConversations.length > 0 &&
- Executing - {state.activeConversations.map((conversation) => ( -
- - {conversation.objective} - Watch live conversation -
- ))} -
} - {savedConversations.length > 0 &&
- Saved conversations - {savedConversations.map((conversation) =>
- {conversation.atomic_attack_name} - {conversation.objective} - Open saved conversation -
)} -
} -
-
-
- - Atomic attack groups - - -
- - {`${displayGroups.length.toLocaleString()} group${displayGroups.length === 1 ? '' : 's'}, ${executionCount.toLocaleString()} ${producerCounts ? '' : 'loaded '}execution${executionCount === 1 ? '' : 's'}`} - -
- -
-
- Datasets & objectives -
- DatasetObjectiveAttack successCompleted - {summary.seed_groups.map((seed) => { - const planSeed = planSeeds.get(seed.id) - return - {datasetsBySeed.get(seed.id) || 'Not recorded'} - - {formatProgressSuccess(seed)} - {seed.completed}{seed.planned === null ? '' : ` / ${seed.planned}`} - - })} -
-
-
- Scenario configuration - {run.scenario_identity ? - { setIdentityExpanded((value: boolean) => !value) }} /> -
: No saved scenario identity.} -
+ + + + + {singleGroupId ? ( +
+ +
+ ) : <> + + { + objectiveTrigger.current = trigger + setObjective(seed) + }} + /> + + }
- { if (!cancelling) setCancelOpen(data.open) }}> - Cancel scan? - Stop remaining attacks. Saved conversations and results stay available. - {cancelError && {cancelError}} - - - - - { - if (!data.open) { - closeAttemptDetails() - } - }} - > - - - } - aria-label="Close attack details" - onClick={closeAttemptDetails} - /> - )} - > - {selectedAttempt?.atomic_attack_name || 'Attack attempt'} - - {selectedAttempt && ( - - - - )} - - - - - - + + { setObjective(null) }} - attempts={objective ? attemptsBySeed.get(objective.id) ?? [] : []} - onOpenAttempt={openDetails} scenarioResultId={scenarioResultId} /> + attempts={objective ? attemptsBySeed.get(objective.id) ?? [] : []} techniqueNames={techniqueNames} + onOpenAttempt={openObjectiveAttempt} scenarioResultId={scenarioResultId} />
) } - -function EmptyState({ text }: { readonly text: string }) { - return {text} -} - -function DisplayGroupMetric({ label, value }: { readonly label: string; readonly value: string }) { - return ( -
- {label} - {value} -
- ) -} - -function formatCompletion(completed: number, planned: number | null): string { - return planned === null ? `${completed}/total unavailable` : `${completed}/${planned}` -} - -function formatSuccess(succeeded: number, completed: number, percentage: number | null): string { - if (percentage === null) return completed === 0 ? 'Unavailable' : `${succeeded}/${completed}` - return `${percentage}%` -} diff --git a/frontend/src/components/Scenarios/ScenarioRunProgress.tsx b/frontend/src/components/Scenarios/ScenarioRunProgress.tsx new file mode 100644 index 0000000000..72849a615e --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunProgress.tsx @@ -0,0 +1,56 @@ +import { mergeClasses, ProgressBar, Text } from '@fluentui/react-components' + +import type { ScenarioActiveConversation, ScenarioProgressCounts, ScenarioProgressHeader } from '@/types' +import { isTerminalRunState } from '@/utils/scenarioRunProgress' + +import AttackActivity from './AttackActivity' +import LiveAttacksPopover from './LiveAttacksPopover' +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' +import type { LiveAttack } from './scenarioRunLayout' +import ScenarioRunTiming from './ScenarioRunTiming' + +interface ScenarioRunProgressProps { + readonly scenarioResultId: string + readonly run: ScenarioProgressHeader + readonly overall: ScenarioProgressCounts + /** Running attacks to offer from the progress area; omitted when the page shows its only attack inline. */ + readonly liveAttacks?: readonly LiveAttack[] + /** Router state carried to a live conversation, so its back link returns here. */ + readonly conversationState: unknown + readonly onOpenAttack: (conversation: ScenarioActiveConversation, trigger: HTMLElement) => void +} + +/** + * Queue position or completed attacks, timing, and live attacks while a scan is active. + * It stays mounted and collapses when the scan finishes, so the results below do not jump. + */ +export default function ScenarioRunProgress({ + scenarioResultId, run, overall, liveAttacks, conversationState, onOpenAttack, +}: ScenarioRunProgressProps) { + const styles = useScenarioRunPageStyles() + const active = !isTerminalRunState(run.status) + const queued = run.status === 'QUEUED' + const status = queued + ? `Queued${run.queue_position ? ` · Position ${run.queue_position}` : ''}` + : `${overall.completed}${overall.planned === null ? '' : ` of ${overall.planned}`} attacks complete` + return ( +
+
+
+
+ + {status} + +
+
+ + +
+ {liveAttacks && } +
+
+
+ ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunSavedConversations.tsx b/frontend/src/components/Scenarios/ScenarioRunSavedConversations.tsx new file mode 100644 index 0000000000..1339a9e316 --- /dev/null +++ b/frontend/src/components/Scenarios/ScenarioRunSavedConversations.tsx @@ -0,0 +1,38 @@ +import { Text } from '@fluentui/react-components' +import { Link } from 'react-router' + +import type { ScenarioActiveConversation } from '@/types' +import { attackConversationRoutePath } from '@/utils/routeParams' + +import { useScenarioRunPageStyles } from './ScenarioRunPage.styles' + +interface ScenarioRunSavedConversationsProps { + readonly scenarioResultId: string + /** Conversations saved by attacks that stopped before saving a result. */ + readonly conversations: readonly ScenarioActiveConversation[] + /** Router state carried to the conversation, so its back link returns here. */ + readonly conversationState: unknown +} + +/** Interrupted attacks' saved conversations, which have no result row to open them from. */ +export default function ScenarioRunSavedConversations({ + scenarioResultId, conversations, conversationState, +}: ScenarioRunSavedConversationsProps) { + const styles = useScenarioRunPageStyles() + if (conversations.length === 0) return null + return ( +
+ Saved conversations + {conversations.map((conversation) => ( +
+ {conversation.atomic_attack_name} + {conversation.objective} + + Open saved conversation + +
+ ))} +
+ ) +} diff --git a/frontend/src/components/Scenarios/ScenarioRunTiming.tsx b/frontend/src/components/Scenarios/ScenarioRunTiming.tsx index 74bc677508..56321acb6d 100644 --- a/frontend/src/components/Scenarios/ScenarioRunTiming.tsx +++ b/frontend/src/components/Scenarios/ScenarioRunTiming.tsx @@ -23,7 +23,7 @@ export default function ScenarioRunTiming({ run, overall }: ScenarioRunTimingPro }, [active, run.started_at]) if (overall) { const eta = getEtaMilliseconds(run, overall, now) - return eta === null ? null : About {formatDuration(eta)} left + return {eta === null ? 'Estimating time remaining' : `About ${formatDuration(eta)} left`} } const elapsed = run.started_at ? formatDuration(getElapsedMilliseconds(run, now)) : run.status === 'CREATED' || run.status === 'QUEUED' ? 'Not started' : 'Not recorded' diff --git a/frontend/src/components/Scenarios/ScenarioTechniqueSection.tsx b/frontend/src/components/Scenarios/ScenarioTechniqueSection.tsx index 53aff2aea8..027a86564b 100644 --- a/frontend/src/components/Scenarios/ScenarioTechniqueSection.tsx +++ b/frontend/src/components/Scenarios/ScenarioTechniqueSection.tsx @@ -5,7 +5,7 @@ import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons' import ContextualHelpLabel from '@/components/ContextualHelpLabel' import { HELP_DEFINITIONS } from '@/components/helpDefinitions' -import { useRegistryIdentityRowStyles } from '@/components/Registry/RegistryIdentityRow.styles' +import { useIdentityDisclosureStyles } from '@/components/Registry/IdentityDisclosure.styles' import { useScenarioDetailStyles } from './ScenarioDetail.styles' import { exceedsTechniqueLimit, type SelectableTechnique } from './scenarioLaunchForm' @@ -46,7 +46,7 @@ export default function ScenarioTechniqueSection({ onRemoveToken, }: ScenarioTechniqueSectionProps) { const styles = useScenarioDetailStyles() - const identityStyles = useRegistryIdentityRowStyles() + const identityStyles = useIdentityDisclosureStyles() const identityId = useId() const [expandedIdentities, setExpandedIdentities] = useState>(new Set()) const toggleIdentity = (name: string): void => { diff --git a/frontend/src/components/Scenarios/scenarioHierarchy.ts b/frontend/src/components/Scenarios/scenarioHierarchy.ts index 5c319fa26f..5192674272 100644 --- a/frontend/src/components/Scenarios/scenarioHierarchy.ts +++ b/frontend/src/components/Scenarios/scenarioHierarchy.ts @@ -1,9 +1,9 @@ import type { - AttackResultRole, ScenarioDisplayGroupProgress, ScenarioHierarchyGroup, ScenarioHierarchyNode, ScenarioHierarchyProjection, + ScenarioProgressSummary, } from '@/types' /** One saved child-reference position under a parent: a loaded child, or a reference that did not resolve. */ @@ -110,12 +110,18 @@ export function hierarchyDisplayGroups( ] } -const ROLE_LABELS: Record = { - orchestration: 'Orchestration', - target_facing: 'Target-facing', - unknown: 'Unknown role', +interface RunExecutionCount { + readonly count: number + /** False when the server did not report totals, so only loaded results were counted. */ + readonly exact: boolean } -export function resultRoleLabel(role: AttackResultRole): string { - return ROLE_LABELS[role] ?? ROLE_LABELS.unknown +/** Attack executions in a run: the server's producer totals, otherwise the results loaded so far. */ +export function runExecutionCount(summary: ScenarioProgressSummary, loadedResults: number): RunExecutionCount { + const producerCounts = summary.overall.producer_counts + if (!producerCounts) return { count: loadedResults, exact: false } + return { + count: producerCounts.target_facing.attempts + producerCounts.orchestration.attempts + producerCounts.unknown.attempts, + exact: true, + } } diff --git a/frontend/src/components/Scenarios/scenarioRunLayout.test.ts b/frontend/src/components/Scenarios/scenarioRunLayout.test.ts new file mode 100644 index 0000000000..8dffa2ea16 --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioRunLayout.test.ts @@ -0,0 +1,110 @@ +import type { + ScenarioActiveConversation, + ScenarioHierarchyGroup, + ScenarioHierarchyNode, + ScenarioProgressCounts, + ScenarioProgressResult, + ScenarioProgressSummary, +} from '@/types' +import { INITIAL_SCENARIO_RUN_PROGRESS_STATE, type ScenarioRunProgressState } from '@/utils/scenarioRunProgress' + +import { + attackIdForConversation, + interruptedConversations, + liveAttacks, + singleAtomicGroupId, + singleAttackAttempt, +} from './scenarioRunLayout' + +const COUNTS: ScenarioProgressCounts = { completed: 0, planned: 1, succeeded: 0, success_percentage: null, errors: 0, retries: 0 } +const GROUP = { ...COUNTS, id: 'direct', display_group: 'Direct', atomic_attack_names: ['PromptSendingAttack'], atomic_group_ids: ['group'] } +const SUMMARY: ScenarioProgressSummary = { + overall: COUNTS, display_groups: [GROUP], techniques: [], seed_groups: [], atomic_groups: [], +} +const RESULT: ScenarioProgressResult = { + attack_result_id: 'attack', conversation_id: 'main', related_conversation_ids: ['pruned'], atomic_group_id: 'group', + atomic_attack_name: 'PromptSendingAttack', seed_group_id: 'seed', outcome: 'success', execution_time_ms: 1, + timestamp: '2026-01-01T00:00:00Z', total_retries: 0, retries: [], +} +const LIVE: ScenarioActiveConversation = { + attack_result_id: 'live', conversation_id: 'live-main', conversation_ids: ['live-first', 'live-main'], + atomic_attack_name: 'Crescendo', objective: 'Objective', started_at: '2026-01-01T00:00:00Z', seed_identifiers: [], +} + +function root(overrides: Partial = {}): ScenarioHierarchyNode { + return { + attack_result_id: 'root', conversation_id: 'root-main', result_role: 'target_facing', atomic_attack_name: 'PromptSendingAttack', + atomic_group_id: 'group', seed_group_id: 'seed', objective: 'Objective', outcome: 'success', execution_time_ms: 1, + executed_turns: 1, timestamp: '2026-01-01T00:00:00Z', total_retries: 0, retries: [], retries_truncated: false, + child_attack_result_ids: [], child_references_truncated: false, children: [], children_truncated: false, + child_offset: 0, unresolved_child_attack_result_ids: [], unresolved_child_references_truncated: false, + conversations: [], conversations_truncated: false, ...overrides, + } +} + +function hierarchyGroup(roots: ScenarioHierarchyNode[]): ScenarioHierarchyGroup { + return { atomic_group_id: 'group', atomic_attack_name: 'PromptSendingAttack', display_group: 'Direct', roots, counts: COUNTS } +} + +function state(overrides: Partial): ScenarioRunProgressState { + return { ...INITIAL_SCENARIO_RUN_PROGRESS_STATE, ...overrides } +} + +describe('singleAtomicGroupId', () => { + it('should find the only atomic group of a one-attack run', () => { + expect(singleAtomicGroupId(SUMMARY, [], 0)).toBe('group') + expect(singleAtomicGroupId(SUMMARY, [hierarchyGroup([root()])], 1)).toBe('group') + }) + + it.each([ + ['more than one planned attack', { ...SUMMARY, overall: { ...COUNTS, planned: 2 } }, [], 0], + ['an unknown plan', { ...SUMMARY, overall: { ...COUNTS, planned: null } }, [], 0], + ['more than one execution', SUMMARY, [], 2], + ['two display groups', { ...SUMMARY, display_groups: [GROUP, { ...GROUP, id: 'other', atomic_group_ids: ['other'] }] }, [], 0], + ['a compound root', SUMMARY, [hierarchyGroup([root({ child_attack_result_ids: ['child'] })])], 1], + ] as const)('should not treat a run with %s as a single attack', (_label, summary, groups, loaded) => { + expect(singleAtomicGroupId(summary, groups, loaded)).toBeNull() + }) +}) + +describe('singleAttackAttempt', () => { + it('should prefer the saved result, then the saved root, then the running attack', () => { + const saved = root() + expect(singleAttackAttempt('group', [RESULT], [hierarchyGroup([saved])], [LIVE])).toBe(RESULT) + expect(singleAttackAttempt('group', [], [hierarchyGroup([saved])], [LIVE])).toBe(saved) + expect(singleAttackAttempt('group', [], [], [LIVE])).toBe(LIVE) + expect(singleAttackAttempt('group', [], [], [])).toBeNull() + }) +}) + +describe('liveAttacks', () => { + it('should list each running attack once with all of its conversations counted', () => { + const attacks = liveAttacks([LIVE, { ...LIVE, conversation_id: 'live-first', conversation_ids: undefined }, { ...LIVE, attack_result_id: 'other', conversation_ids: undefined }]) + expect(attacks.map((attack) => [attack.conversation.attack_result_id, attack.conversationCount])).toEqual([['live', 2], ['other', 1]]) + }) +}) + +describe('interruptedConversations', () => { + it('should keep saved conversations that no running or completed attack shows, once each', () => { + const recorded = { ...LIVE, attack_result_id: 'stopped', conversation_id: 'stopped-main', conversation_ids: undefined } + const conversations = interruptedConversations(state({ + results: [RESULT], activeConversations: [LIVE], + recordedConversations: [recorded, recorded, { ...recorded, conversation_id: 'pruned' }, { ...recorded, conversation_id: 'live-first' }], + })) + expect(conversations).toEqual([recorded]) + }) +}) + +describe('attackIdForConversation', () => { + it('should find the attack owning a completed, pruned, running, or interrupted conversation', () => { + const run = state({ + results: [RESULT], activeConversations: [LIVE], + recordedConversations: [{ ...LIVE, attack_result_id: 'stopped', conversation_id: 'stopped-main' }], + }) + expect(attackIdForConversation(run, 'main')).toBe('attack') + expect(attackIdForConversation(run, 'pruned')).toBe('attack') + expect(attackIdForConversation(run, 'live-first')).toBe('live') + expect(attackIdForConversation(run, 'stopped-main')).toBe('stopped') + expect(attackIdForConversation(run, 'unknown')).toBeNull() + }) +}) diff --git a/frontend/src/components/Scenarios/scenarioRunLayout.ts b/frontend/src/components/Scenarios/scenarioRunLayout.ts new file mode 100644 index 0000000000..c7da1b791b --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioRunLayout.ts @@ -0,0 +1,93 @@ +import type { + ScenarioActiveConversation, + ScenarioHierarchyGroup, + ScenarioHierarchyNode, + ScenarioProgressResult, + ScenarioProgressSummary, +} from '@/types' +import type { ScenarioRunProgressState } from '@/utils/scenarioRunProgress' + +import { hasChildReferences, hierarchyDisplayGroups, runExecutionCount } from './scenarioHierarchy' + +/** + * The atomic group of a run made of exactly one atomic attack: one planned attack, at most one execution, + * one display group with one atomic group, and no compound children. Null for any other run. + */ +export function singleAtomicGroupId( + summary: ScenarioProgressSummary, + hierarchyGroups: readonly ScenarioHierarchyGroup[], + loadedResults: number, +): string | null { + const displayGroups = hierarchyDisplayGroups(summary.display_groups ?? summary.techniques, hierarchyGroups) + const roots = hierarchyGroups.flatMap((group) => group.roots) + const single = summary.overall.planned === 1 + && runExecutionCount(summary, loadedResults).count <= 1 + && displayGroups.length === 1 + && displayGroups[0].atomic_group_ids.length === 1 + && roots.length <= 1 + && (roots.length === 0 || !hasChildReferences(roots[0])) + return single ? displayGroups[0].atomic_group_ids[0] : null +} + +/** The single attack's attempt: its saved result, otherwise its saved root, otherwise the running attack. */ +export function singleAttackAttempt( + atomicGroupId: string, + results: readonly ScenarioProgressResult[], + hierarchyGroups: readonly ScenarioHierarchyGroup[], + activeConversations: readonly ScenarioActiveConversation[], +): ScenarioProgressResult | ScenarioHierarchyNode | ScenarioActiveConversation | null { + return results.find((attempt) => attempt.atomic_group_id === atomicGroupId) + ?? hierarchyGroups.find((group) => group.atomic_group_id === atomicGroupId)?.roots[0] + ?? activeConversations[0] + ?? null +} + +/** One running attack, which may have started several objective conversations. */ +export interface LiveAttack { + /** The conversation the attack is working on. */ + readonly conversation: ScenarioActiveConversation + readonly conversationCount: number +} + +/** Running attacks, one per attack, in the order they were reported. */ +export function liveAttacks(activeConversations: readonly ScenarioActiveConversation[]): LiveAttack[] { + const conversationsByAttack = new Map() + for (const conversation of activeConversations) { + const conversations = conversationsByAttack.get(conversation.attack_result_id) ?? [] + conversations.push(conversation) + conversationsByAttack.set(conversation.attack_result_id, conversations) + } + return [...conversationsByAttack.values()].map((conversations) => ({ + conversation: conversations[0], + conversationCount: new Set(conversations.flatMap((conversation) => [ + conversation.conversation_id, ...(conversation.conversation_ids ?? []), + ])).size, + })) +} + +/** Conversations saved by attacks that stopped before saving a result, excluding running and completed ones. */ +export function interruptedConversations(state: ScenarioRunProgressState): ScenarioActiveConversation[] { + const shownConversationIds = new Set([ + ...state.results.flatMap((attempt) => [attempt.conversation_id, ...(attempt.related_conversation_ids ?? [])]), + ...state.activeConversations.flatMap((conversation) => [ + conversation.conversation_id, ...(conversation.conversation_ids ?? []), + ]), + ]) + const interrupted = new Map() + for (const conversation of state.recordedConversations) { + if (!shownConversationIds.has(conversation.conversation_id)) { + interrupted.set(`${conversation.attack_result_id}:${conversation.conversation_id}`, conversation) + } + } + return [...interrupted.values()] +} + +/** The attack in a run that owns a conversation, whether it is running, completed, or was interrupted. */ +export function attackIdForConversation(state: ScenarioRunProgressState, conversationId: string): string | null { + const active = state.activeConversations.find((conversation) => + conversation.conversation_id === conversationId || conversation.conversation_ids?.includes(conversationId)) + const completed = state.results.find((attempt) => + attempt.conversation_id === conversationId || attempt.related_conversation_ids?.includes(conversationId)) + const recorded = state.recordedConversations.find((conversation) => conversation.conversation_id === conversationId) + return completed?.attack_result_id ?? active?.attack_result_id ?? recorded?.attack_result_id ?? null +} diff --git a/frontend/src/components/Scenarios/scenarioRunNavigation.test.ts b/frontend/src/components/Scenarios/scenarioRunNavigation.test.ts new file mode 100644 index 0000000000..a0a754959c --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioRunNavigation.test.ts @@ -0,0 +1,22 @@ +import { scenarioRunBackLink } from './scenarioRunNavigation' + +describe('scenarioRunBackLink', () => { + it('returns to the suite execution that opened the run', () => { + expect(scenarioRunBackLink({ suiteExecutionId: 'suite-1', suiteName: 'Nightly', fromScenarioHistory: true })) + .toEqual({ path: '/scanner/history/suites/executions/suite-1', label: 'Back to Nightly' }) + expect(scenarioRunBackLink({ suiteExecutionId: 'suite-1' }).label).toBe('Back to suite execution') + }) + + it('returns to scan history with its filters', () => { + expect(scenarioRunBackLink({ scenarioHistorySearch: '?status=FAILED' })) + .toEqual({ path: '/scanner/history?status=FAILED', label: 'Back to scans' }) + expect(scenarioRunBackLink({ fromScenarioHistory: true })) + .toEqual({ path: '/scanner/history', label: 'Back to scans' }) + }) + + it('returns to the launched scenario, or the scanner catalog', () => { + expect(scenarioRunBackLink({ scenarioName: 'garak/encoding' })) + .toEqual({ path: '/scanner/launch/garak%2Fencoding', label: 'Back to scenario' }) + expect(scenarioRunBackLink(null)).toEqual({ path: '/scanner/launch', label: 'Back to scanners' }) + }) +}) diff --git a/frontend/src/components/Scenarios/scenarioRunNavigation.ts b/frontend/src/components/Scenarios/scenarioRunNavigation.ts new file mode 100644 index 0000000000..ece7af2438 --- /dev/null +++ b/frontend/src/components/Scenarios/scenarioRunNavigation.ts @@ -0,0 +1,32 @@ +import { suiteExecutionRoutePath } from '@/components/ScenarioSuites/suiteRoutes' + +/** Where the run page was opened from, carried in router location state. */ +export interface ScenarioRunNavigationState { + fromScenarioHistory?: boolean + scenarioHistorySearch?: string + scenarioName?: string + suiteExecutionId?: string + suiteName?: string +} + +interface ScenarioRunBackLink { + readonly path: string + readonly label: string +} + +/** Return to the suite, history filters, or scanner that opened the run. */ +export function scenarioRunBackLink(state: ScenarioRunNavigationState | null): ScenarioRunBackLink { + if (state?.suiteExecutionId) { + return { + path: suiteExecutionRoutePath(state.suiteExecutionId), + label: `Back to ${state.suiteName ?? 'suite execution'}`, + } + } + if (state?.fromScenarioHistory || state?.scenarioHistorySearch) { + return { path: `/scanner/history${state.scenarioHistorySearch ?? ''}`, label: 'Back to scans' } + } + if (state?.scenarioName) { + return { path: `/scanner/launch/${encodeURIComponent(state.scenarioName)}`, label: 'Back to scenario' } + } + return { path: '/scanner/launch', label: 'Back to scanners' } +} diff --git a/frontend/src/components/Scorers/ScorerEvaluationDetails.styles.ts b/frontend/src/components/Scorers/ScorerEvaluationDetails.styles.ts new file mode 100644 index 0000000000..32de033fa0 --- /dev/null +++ b/frontend/src/components/Scorers/ScorerEvaluationDetails.styles.ts @@ -0,0 +1,22 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { mobileTouchTarget } from '@/styles/touchTargets' + +export const useScorerEvaluationDetailsStyles = makeStyles({ + trigger: { + ...mobileTouchTarget, + color: tokens.colorBrandForegroundLink, + }, + surface: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalL, + width: '28rem', + maxWidth: `calc(100vw - ${tokens.spacingHorizontalXXL} - ${tokens.spacingHorizontalXXL})`, + maxHeight: '70vh', + overflowY: 'auto', + overflowWrap: 'anywhere', + }, + heading: { margin: 0 }, + description: { color: tokens.colorNeutralForeground2 }, +}) diff --git a/frontend/src/components/Scorers/ScorerEvaluationDetails.test.tsx b/frontend/src/components/Scorers/ScorerEvaluationDetails.test.tsx new file mode 100644 index 0000000000..93c6432eb1 --- /dev/null +++ b/frontend/src/components/Scorers/ScorerEvaluationDetails.test.tsx @@ -0,0 +1,68 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import type { ScorerEvaluationMetrics } from '@/types' + +import ScorerEvaluationDetails from './ScorerEvaluationDetails' + +function TestWrapper({ children }: { children: React.ReactNode }) { + return {children} +} + +describe('ScorerEvaluationDetails', () => { + it('should disclose complete binary metrics and extra keys by keyboard without confusing identities', async () => { + const user = userEvent.setup() + render() + expect(screen.queryByRole('group', { name: 'Num responses' })).not.toBeInTheDocument() + const trigger = screen.getByRole('button', { name: 'Evaluation metrics for configured judge' }) + trigger.focus() + await user.keyboard('{Enter}') + const dialog = screen.getByRole('dialog', { name: 'Scorer evaluation metrics' }) + expect(within(dialog).getByRole('group', { name: 'Accuracy' })).toHaveTextContent('93.00%') + expect(within(dialog).getByRole('group', { name: 'Recall' })).toHaveTextContent('0.0000') + expect(within(dialog).getByRole('group', { name: 'Accuracy std error' })).toHaveTextContent('0.0200') + expect(within(dialog).getByRole('group', { name: 'Num responses' })).toHaveTextContent('100') + expect(within(dialog).getByRole('group', { name: 'Num human raters' })).toHaveTextContent('0') + expect(within(dialog).getByRole('group', { name: 'Custom value' })).toHaveTextContent('retained') + expect(within(dialog).getByRole('group', { name: 'Average score time' })).toHaveTextContent('Unavailable') + expect(dialog).toHaveTextContent('not an individual attack score or the scan success rate') + expect(within(dialog).getByRole('link', { name: 'About scorer metrics' })).toHaveAttribute( + 'href', 'https://microsoft.github.io/PyRIT/latest/code/scoring/scorer-metrics/', + ) + await user.click(within(dialog).getByRole('button', { name: 'Identity for Behavioral evaluation identity' })) + expect(within(dialog).getByRole('region', { name: 'Identity for Behavioral evaluation identity' })).toHaveTextContent('evaluation-hash') + await user.keyboard('{Escape}') + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + }) + + it('should show zero float-scale errors, agreement, extra sample counts and missing supplied values', async () => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Evaluation metrics for scale' })) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByRole('group', { name: 'Mean absolute error' })).toHaveTextContent('0.0000') + expect(within(dialog).getByRole('group', { name: 'Agreement (Krippendorff alpha)' })).toHaveTextContent('0.8000') + expect(within(dialog).getByRole('group', { name: 'Num responses' })).toHaveTextContent('25') + expect(within(dialog).getByRole('group', { name: 'Custom standard error' })).toHaveTextContent('Unavailable') + expect(within(dialog).queryByRole('group', { name: 'Accuracy' })).not.toBeInTheDocument() + }) + + it.each([null, undefined, {}, { accuracy: null }])( + 'should explain unavailable official evaluations for %p', async (metrics: ScorerEvaluationMetrics | null | undefined) => { + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Evaluation metrics for unevaluated' })) + expect(screen.getByRole('dialog')).toHaveTextContent('No official evaluation metrics are available') + expect(screen.getByRole('link', { name: 'About scorer metrics' })).toBeVisible() + expect(screen.queryByRole('group', { name: 'Accuracy' })).not.toBeInTheDocument() + }, + ) +}) diff --git a/frontend/src/components/Scorers/ScorerEvaluationDetails.tsx b/frontend/src/components/Scorers/ScorerEvaluationDetails.tsx new file mode 100644 index 0000000000..0470115bbb --- /dev/null +++ b/frontend/src/components/Scorers/ScorerEvaluationDetails.tsx @@ -0,0 +1,57 @@ +import { useId } from 'react' + +import { Button, Link, Popover, PopoverSurface, PopoverTrigger, Text } from '@fluentui/react-components' + +import IdentityDisclosure from '@/components/Registry/IdentityDisclosure' +import type { ComponentIdentifier, ScorerEvaluationMetrics } from '@/types' + +import ScorerMetricsDetails from './ScorerMetricsDetails' +import { useScorerEvaluationDetailsStyles } from './ScorerEvaluationDetails.styles' + +const SCORER_METRICS_DOCUMENTATION_URL = 'https://microsoft.github.io/PyRIT/latest/code/scoring/scorer-metrics/' + +interface ScorerEvaluationDetailsProps { + readonly name: string + readonly metrics?: ScorerEvaluationMetrics | null + readonly scoreType?: string | null + readonly evaluationIdentity?: ComponentIdentifier | null + readonly recorded?: boolean +} + +export default function ScorerEvaluationDetails({ + name, metrics, scoreType, evaluationIdentity, recorded = false, +}: ScorerEvaluationDetailsProps) { + const styles = useScorerEvaluationDetailsStyles() + const headingId = useId() + const hasMetrics = metrics && Object.values(metrics).some((value: ScorerEvaluationMetrics[string]) => value != null) + return ( + + + + + + + Scorer evaluation metrics + + + {recorded ? 'Quality of the recorded scorer configuration' : 'Quality of this scorer configuration'}, + not an individual attack score or the scan success rate. + + {hasMetrics ? ( + + ) : ( + No official evaluation metrics are available for this scorer configuration. + )} + {evaluationIdentity && ( + + )} + + About scorer metrics + + + + ) +} diff --git a/frontend/src/hooks/useAttackFindings.test.tsx b/frontend/src/hooks/useAttackFindings.test.tsx new file mode 100644 index 0000000000..7400e7f5be --- /dev/null +++ b/frontend/src/hooks/useAttackFindings.test.tsx @@ -0,0 +1,72 @@ +import { act, renderHook, waitFor } from '@testing-library/react' + +import { attacksApi } from '@/services/api' +import type { AttackFindingItem } from '@/types' + +import { useAttackFindings } from './useAttackFindings' + +jest.mock('@/services/api', () => ({ attacksApi: { getAttackFindings: jest.fn() } })) + +const FINDING = { + id: 'finding-1', operation_id: 'operation-1', title: 'Prompt leak', severity: 'high', description: '', + created_at: '2026-10-08T12:00:00Z', operation_name: 'Review', evidence_id: 'evidence-1', +} as AttackFindingItem + +describe('useAttackFindings', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should load the findings that link an attack', async () => { + jest.mocked(attacksApi.getAttackFindings).mockResolvedValue({ attack_result_id: 'attack-1', items: [FINDING] }) + const { result } = renderHook(() => useAttackFindings('attack-1')) + + expect(result.current.loading).toBe(true) + await waitFor(() => { expect(result.current.loading).toBe(false) }) + expect(result.current.findings).toEqual([FINDING]) + expect(result.current.error).toBeNull() + expect(attacksApi.getAttackFindings).toHaveBeenCalledWith('attack-1') + }) + + it('should not request findings without an attack', () => { + const { result } = renderHook(() => useAttackFindings(null)) + expect(result.current).toMatchObject({ findings: [], loading: false, error: null }) + expect(attacksApi.getAttackFindings).not.toHaveBeenCalled() + }) + + it('should keep findings visible while a refresh reloads them', async () => { + jest.mocked(attacksApi.getAttackFindings).mockResolvedValueOnce({ attack_result_id: 'attack-1', items: [FINDING] }) + const { result } = renderHook(() => useAttackFindings('attack-1')) + await waitFor(() => { expect(result.current.loading).toBe(false) }) + expect(result.current.findings).toEqual([FINDING]) + + let resolve: (value: { attack_result_id: string; items: AttackFindingItem[] }) => void = () => {} + jest.mocked(attacksApi.getAttackFindings).mockReturnValueOnce(new Promise((complete) => { resolve = complete })) + act(() => { result.current.refresh() }) + expect(result.current.loading).toBe(true) + expect(result.current.findings).toEqual([FINDING]) + + await act(async () => { resolve({ attack_result_id: 'attack-1', items: [] }) }) + expect(result.current.findings).toEqual([]) + expect(attacksApi.getAttackFindings).toHaveBeenCalledTimes(2) + }) + + it('should not show another attack\'s findings after switching attacks', async () => { + jest.mocked(attacksApi.getAttackFindings).mockResolvedValueOnce({ attack_result_id: 'attack-1', items: [FINDING] }) + const { result, rerender } = renderHook(({ id }: { id: string }) => useAttackFindings(id), { initialProps: { id: 'attack-1' } }) + await waitFor(() => { expect(result.current.findings).toEqual([FINDING]) }) + + jest.mocked(attacksApi.getAttackFindings).mockReturnValueOnce(new Promise(() => {})) + rerender({ id: 'attack-2' }) + expect(result.current.findings).toEqual([]) + expect(result.current.loading).toBe(true) + }) + + it('should report a failed request', async () => { + jest.mocked(attacksApi.getAttackFindings).mockRejectedValue(new Error('Findings unavailable')) + const { result } = renderHook(() => useAttackFindings('attack-1')) + await waitFor(() => { expect(result.current.loading).toBe(false) }) + expect(result.current.error).toBe('Findings unavailable') + expect(result.current.findings).toEqual([]) + }) +}) diff --git a/frontend/src/hooks/useAttackFindings.ts b/frontend/src/hooks/useAttackFindings.ts new file mode 100644 index 0000000000..fa022d591d --- /dev/null +++ b/frontend/src/hooks/useAttackFindings.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect, useState } from 'react' + +import { attacksApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { AttackFindingItem } from '@/types' + +export interface AttackFindingsState { + findings: AttackFindingItem[] + loading: boolean + error: string | null + refresh: () => void +} + +interface LoadedFindings { + attackResultId: string + revision: number + findings: AttackFindingItem[] + error: string | null +} + +/** Findings in any Operation that link this attack as evidence. Without an attack id, there are none. */ +export function useAttackFindings(attackResultId: string | null | undefined): AttackFindingsState { + const [revision, setRevision] = useState(0) + const [loaded, setLoaded] = useState(null) + + useEffect(() => { + if (!attackResultId) return + let ignore = false + attacksApi.getAttackFindings(attackResultId) + .then((response) => { + if (!ignore) setLoaded({ attackResultId, revision, findings: response.items, error: null }) + }) + .catch((cause: unknown) => { + if (!ignore) setLoaded({ attackResultId, revision, findings: [], error: toApiError(cause).detail }) + }) + return () => { ignore = true } + }, [attackResultId, revision]) + + const refresh = useCallback((): void => { + setRevision((current: number) => current + 1) + }, []) + + // Keep the previous answer for the same attack while a refresh is in flight, so attached findings do not flicker. + const current = attackResultId && loaded?.attackResultId === attackResultId ? loaded : null + return { + findings: current?.findings ?? [], + loading: Boolean(attackResultId) && current?.revision !== revision, + error: current?.error ?? null, + refresh, + } +} diff --git a/frontend/src/hooks/useConversationMessages.test.ts b/frontend/src/hooks/useConversationMessages.test.ts index b3d563e478..8e85423aa7 100644 --- a/frontend/src/hooks/useConversationMessages.test.ts +++ b/frontend/src/hooks/useConversationMessages.test.ts @@ -55,6 +55,54 @@ describe('useConversationMessages', () => { )) }) + it('should retain a long live window and split turn through saved paging while rejecting a late live response', async () => { + jest.mocked(backendMessagesToFrontend).mockImplementation( + jest.requireActual('@/utils/messageMapper').backendMessagesToFrontend, + ) + const full: ConversationMessagesResponse = { + conversation_id: 'main', target_response_status: null, + messages: [{ + role: 'assistant', turn_number: 1, created_at: '2026-01-01T00:00:00Z', + message_pieces: Array.from({ length: 122 }, (_value: unknown, index: number) => ({ + id: `piece-${index}`, original_value: `Piece ${index}`, converted_value: `Piece ${index}`, + original_value_data_type: 'text', converted_value_data_type: 'text', scores: [], response_error: 'none', + })), + }], + } + const lateLive = deferred() + const continuation = deferred() + getMessages.mockResolvedValueOnce(full).mockReturnValueOnce(lateLive.promise).mockResolvedValueOnce({ + ...full, messages: full.messages.map((message: BackendMessage) => ({ ...message, message_pieces: message.message_pieces.slice(0, 100) })), + pagination: { limit: 100, has_more: true, next_cursor: 'saved-next' }, partial_message_sequences: [1], + }).mockReturnValueOnce(continuation.promise) + const { result, rerender } = renderMessages() + await waitFor(() => { expect(result.current.messages[0]?.content).toContain('Piece 121') }) + let refresh = Promise.resolve() + act(() => { refresh = result.current.loadConversation('attack', 'main') }) + rerender({ messagePageSize: 100 }) + await waitFor(() => { expect(getMessages).toHaveBeenLastCalledWith('attack', 'main', { limit: 100, cursor: 'saved-next' }) }) + expect(result.current.messages[0].content).toContain('Piece 121') + expect(result.current.isTranscriptIncomplete).toBe(true) + await act(async () => { + continuation.resolve({ + ...full, messages: full.messages.map((message: BackendMessage) => ({ ...message, message_pieces: message.message_pieces.slice(100) })), + pagination: { limit: 100, has_more: false, next_cursor: null }, partial_message_sequences: [], + }) + }) + expect(result.current.loadedPieceCount).toBe(122) + expect(result.current.isTranscriptIncomplete).toBe(false) + expect(result.current.messages[0].content).toContain('Piece 121') + expect(result.current.messages[0].isPartial).toBe(false) + await act(async () => { + lateLive.resolve({ ...full, messages: [{ ...full.messages[0], message_pieces: [{ + ...full.messages[0].message_pieces[0], original_value: 'Stale live data', converted_value: 'Stale live data', + }] }] }) + await refresh + }) + expect(result.current.loadedPieceCount).toBe(122) + expect(result.current.messages[0].content).not.toContain('Stale live data') + }) + it('polls an injected saved-conversation reader without changing manual send ownership', async () => { jest.useFakeTimers() const readConversation = jest.fn().mockResolvedValue(transcript('main', [1])) diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index afaac48ef5..4bd25001a1 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -26,6 +26,12 @@ interface SavedTranscriptPage { consumedCursors: ReadonlySet } +interface LoadedFullTranscript { + readonly attackResultId: string | null + readonly conversationId: string + readonly response: ConversationMessagesResponse +} + function mergeTranscriptPage( previous: ConversationMessagesResponse | null, incoming: ConversationMessagesResponse, conversationId: string, cursor?: string, consumedCursors?: ReadonlySet, @@ -126,6 +132,7 @@ export function useConversationMessages({ const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false) const [messageContinuationIssue, setMessageContinuationIssue] = useState<{ detail: string; restartRequired: boolean } | null>(null) const savedTranscriptPageRef = useRef(null) + const fullTranscriptRef = useRef(null) const loadedAttackResultIdRef = useRef(null) const loadedConversationIdRef = useRef(null) const viewedAttackRef = useRef(attackResultId) @@ -201,6 +208,7 @@ export function useConversationMessages({ }, [viewedConversationId, attackResultId, messagePageSize]) const resetConversationMessages = useCallback((): void => { + fullTranscriptRef.current = null setMessages([]) setLoadedConversationId(null) setLoadedAttackResultId(null) @@ -235,10 +243,33 @@ export function useConversationMessages({ } // Discard superseded loads and responses invalidated by a send. if (!isCurrentLoad()) { return } - const response = messagePageSize === undefined ? incoming : mergeTranscriptPage(null, incoming, convId) + let response = messagePageSize === undefined ? incoming : mergeTranscriptPage(null, incoming, convId) + const consumedCursors = new Set() + const full = fullTranscriptRef.current + if (messagePageSize !== undefined && arId && full?.attackResultId === arId && full.conversationId === convId) { + const missing = new Set(full.response.messages.flatMap((message: BackendMessage) => message.message_pieces.map((piece: BackendMessagePiece) => piece.id))) + for (const message of response.messages) { + for (const piece of message.message_pieces) missing.delete(piece.id) + } + // Complete the already-visible live window before installing its saved paging state. + while (missing.size > 0 && response.pagination?.has_more) { + const cursor = response.pagination.next_cursor + if (!cursor) throw new Error('The saved transcript continuation is missing. Restart transcript loading.') + const next = await attacksApi.getMessages(arId, convId, { limit: messagePageSize, cursor }) + if (!isCurrentLoad()) return + response = mergeTranscriptPage(response, next, convId, cursor, consumedCursors) + consumedCursors.add(cursor) + for (const message of next.messages) { + for (const piece of message.message_pieces) missing.delete(piece.id) + } + } + } setMessageContinuationIssue(null) if (messagePageSize !== undefined && arId) { - saveTranscriptPage({ attackResultId: arId, conversationId: convId, pageSize: messagePageSize, response, consumedCursors: new Set() }) + saveTranscriptPage({ attackResultId: arId, conversationId: convId, pageSize: messagePageSize, response, consumedCursors }) + fullTranscriptRef.current = null + } else { + fullTranscriptRef.current = { attackResultId: arId, conversationId: convId, response } } setConversationLoadIssue( findingEvidenceId && response.messages.length === 0 && !response.pagination?.has_more diff --git a/frontend/src/hooks/useLiveAttackMetadata.test.tsx b/frontend/src/hooks/useLiveAttackMetadata.test.tsx new file mode 100644 index 0000000000..d4bc4c7321 --- /dev/null +++ b/frontend/src/hooks/useLiveAttackMetadata.test.tsx @@ -0,0 +1,73 @@ +import { act, renderHook } from '@testing-library/react' + +import { attacksApi } from '@/services/api' +import type { AttackMetadata } from '@/types' + +import { LIVE_ATTACK_POLL_INTERVAL_MS, useLiveAttackMetadata } from './useLiveAttackMetadata' + +jest.mock('@/services/api', () => ({ attacksApi: { getAttackMetadata: jest.fn() } })) + +const METADATA: AttackMetadata = { + attack_result_id: 'live', conversation_id: 'main', objective: 'Goal', labels: {}, execution_read_only: true, + in_progress: true, related_conversation_count: 0, last_response_message_piece_id: null, + selected_conversation_id: 'main', selected_conversation_valid: true, +} + +async function advance(): Promise { + await act(async () => { await jest.advanceTimersByTimeAsync(LIVE_ATTACK_POLL_INTERVAL_MS) }) +} + +describe('useLiveAttackMetadata', () => { + const onMetadata = jest.fn() + const onGone = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('should read until the attack is no longer in progress, retrying failed reads', async () => { + jest.mocked(attacksApi.getAttackMetadata) + .mockRejectedValueOnce({ isAxiosError: true, response: { status: 503, data: { detail: 'Busy' } } }) + .mockResolvedValueOnce(METADATA) + .mockResolvedValueOnce({ ...METADATA, in_progress: false }) + renderHook(() => useLiveAttackMetadata({ attackResultId: 'live', onMetadata, onGone })) + expect(attacksApi.getAttackMetadata).not.toHaveBeenCalled() + await advance() + expect(onMetadata).not.toHaveBeenCalled() + await advance() + expect(onMetadata).toHaveBeenLastCalledWith(METADATA) + await advance() + expect(onMetadata).toHaveBeenLastCalledWith({ ...METADATA, in_progress: false }) + await advance() + expect(attacksApi.getAttackMetadata).toHaveBeenCalledTimes(3) + expect(attacksApi.getAttackMetadata).toHaveBeenCalledWith('live') + expect(onGone).not.toHaveBeenCalled() + }) + + it('should stop and report an attack that is neither running nor saved', async () => { + jest.mocked(attacksApi.getAttackMetadata).mockRejectedValue({ isAxiosError: true, response: { status: 404, data: { detail: 'Missing' } } }) + renderHook(() => useLiveAttackMetadata({ attackResultId: 'live', onMetadata, onGone })) + await advance() + await advance() + expect(onGone).toHaveBeenCalledTimes(1) + expect(attacksApi.getAttackMetadata).toHaveBeenCalledTimes(1) + }) + + it('should not read without an attack or after it stops following', async () => { + jest.mocked(attacksApi.getAttackMetadata).mockResolvedValue(METADATA) + const view = renderHook(({ id }: { id: string | null }) => useLiveAttackMetadata({ attackResultId: id, onMetadata, onGone }), + { initialProps: { id: null as string | null } }) + await advance() + expect(attacksApi.getAttackMetadata).not.toHaveBeenCalled() + view.rerender({ id: 'live' }) + await advance() + view.rerender({ id: null }) + await advance() + expect(attacksApi.getAttackMetadata).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/hooks/useLiveAttackMetadata.ts b/frontend/src/hooks/useLiveAttackMetadata.ts new file mode 100644 index 0000000000..18cbd602c9 --- /dev/null +++ b/frontend/src/hooks/useLiveAttackMetadata.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react' + +import { SCENARIO_RUN_POLL_INTERVAL_MS } from '@/hooks/useScenarioRunProgress' +import { attacksApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { AttackMetadata } from '@/types' + +/** Running scan attacks refresh at the same pace as the scan they belong to. */ +export const LIVE_ATTACK_POLL_INTERVAL_MS = SCENARIO_RUN_POLL_INTERVAL_MS + +interface UseLiveAttackMetadataOptions { + /** The running attack to follow; null stops following. */ + readonly attackResultId: string | null + /** Receives each read, including the first one that is no longer in progress. */ + readonly onMetadata: (metadata: AttackMetadata) => void + /** The attack is neither running nor saved any more, such as after a cancelled scan. */ + readonly onGone: () => void +} + +/** + * Re-reads a running scan attack's metadata until a read says it is no longer in progress. + * A failed read keeps the current metadata and is retried on the next interval. + */ +export function useLiveAttackMetadata({ attackResultId, onMetadata, onGone }: UseLiveAttackMetadataOptions): void { + const callbacks = useRef({ onMetadata, onGone }) + useEffect(() => { + callbacks.current = { onMetadata, onGone } + }, [onMetadata, onGone]) + + useEffect(() => { + if (!attackResultId) return + let active = true + let timer: ReturnType + const schedule = (): void => { + timer = setTimeout(() => { void read() }, LIVE_ATTACK_POLL_INTERVAL_MS) + } + const read = async (): Promise => { + try { + const metadata = await attacksApi.getAttackMetadata(attackResultId) + if (!active) return + callbacks.current.onMetadata(metadata) + if (metadata.in_progress) schedule() + } catch (error: unknown) { + if (!active) return + if (toApiError(error).status === 404) callbacks.current.onGone() + else schedule() + } + } + schedule() + return () => { + active = false + clearTimeout(timer) + } + }, [attackResultId]) +} diff --git a/frontend/src/hooks/useScenarioRunAttackDetails.ts b/frontend/src/hooks/useScenarioRunAttackDetails.ts new file mode 100644 index 0000000000..481f4f50a9 --- /dev/null +++ b/frontend/src/hooks/useScenarioRunAttackDetails.ts @@ -0,0 +1,66 @@ +import { useMemo, useRef, useState } from 'react' + +import { useLocation, useNavigate } from 'react-router' + +import type { ScenarioActiveConversation, ScenarioHierarchyNode, ScenarioProgressResult } from '@/types' +import { scenarioRunAttackRoutePath, scenarioRunRoutePath } from '@/utils/routeParams' + +export type ScenarioRunAttempt = ScenarioProgressResult | ScenarioHierarchyNode | ScenarioActiveConversation + +interface UseScenarioRunAttackDetailsOptions { + readonly scenarioResultId: string + /** The attack named by the route, if any. */ + readonly attackResultId: string | null + readonly results: readonly ScenarioProgressResult[] + readonly activeConversations: readonly ScenarioActiveConversation[] + /** Receives focus on close when the control that opened the details is gone. */ + readonly fallbackFocus: () => HTMLElement | null +} + +export interface ScenarioRunAttackDetails { + readonly selectedAttempt: ScenarioRunAttempt | null + /** The hierarchy node the details were opened from, which may carry the objective. */ + readonly hierarchySelection: ScenarioHierarchyNode | null + readonly open: (attempt: ScenarioRunAttempt, trigger: HTMLElement | null) => void + readonly close: () => void +} + +/** The attack shown in the run's details dialog, addressed by route so it survives reloads and back navigation. */ +export function useScenarioRunAttackDetails({ + scenarioResultId, + attackResultId, + results, + activeConversations, + fallbackFocus, +}: UseScenarioRunAttackDetailsOptions): ScenarioRunAttackDetails { + const navigate = useNavigate() + const location = useLocation() + const trigger = useRef(null) + const [hierarchySelection, setHierarchySelection] = useState(null) + + // A hierarchy node can be opened before the paged progress feed has delivered it. + const selectedAttempt = useMemo( + () => + results.find((attempt) => attempt.attack_result_id === attackResultId) ?? + activeConversations.find((conversation) => conversation.attack_result_id === attackResultId) ?? + (hierarchySelection?.attack_result_id === attackResultId ? hierarchySelection : null), + [attackResultId, results, activeConversations, hierarchySelection], + ) + + return { + selectedAttempt, + hierarchySelection, + open: (attempt: ScenarioRunAttempt, opener: HTMLElement | null): void => { + trigger.current = opener + setHierarchySelection('result_role' in attempt ? attempt : null) + navigate(scenarioRunAttackRoutePath(scenarioResultId, attempt.attack_result_id), { state: location.state }) + }, + close: (): void => { + navigate(scenarioRunRoutePath(scenarioResultId), { replace: true, state: location.state }) + requestAnimationFrame(() => { + const focusTarget = trigger.current?.isConnected ? trigger.current : fallbackFocus() + focusTarget?.focus() + }) + }, + } +} diff --git a/frontend/src/hooks/useScenarioRunControls.ts b/frontend/src/hooks/useScenarioRunControls.ts new file mode 100644 index 0000000000..4dc257c21c --- /dev/null +++ b/frontend/src/hooks/useScenarioRunControls.ts @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from 'react' + +import { useLocation, useNavigate } from 'react-router' + +import { useScenarioRunResume } from '@/hooks/useScenarioRunResume' +import { scenariosApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { ScenarioRunSummary } from '@/types' +import { scenarioRerunRoutePath } from '@/utils/routeParams' + +interface UseScenarioRunControlsOptions { + readonly scenarioResultId: string + readonly applyRunSummary: (run: ScenarioRunSummary) => void + /** Reload run progress after a resume that did not start. */ + readonly retry: () => void + readonly refreshQueue: () => void + /** The run's own error, which the page already shows with the run. */ + readonly runError?: string | null +} + +export interface ScenarioRunControls { + readonly rerunPending: boolean + readonly rerun: () => void + readonly cancelOpen: boolean + readonly setCancelOpen: (open: boolean) => void + readonly cancelling: boolean + readonly cancelError: string | null + readonly cancel: () => void + readonly resumePending: boolean + readonly resume: () => void + /** The rerun or resume failure to show beside the controls. */ + readonly controlError: string | null +} + +/** Rerun, cancel, and resume one scan, keeping each request's pending state and errors. */ +export function useScenarioRunControls({ + scenarioResultId, + applyRunSummary, + retry, + refreshQueue, + runError, +}: UseScenarioRunControlsOptions): ScenarioRunControls { + const navigate = useNavigate() + const location = useLocation() + const [cancelOpen, setCancelOpen] = useState(false) + const [cancelling, setCancelling] = useState(false) + const [cancelError, setCancelError] = useState(null) + const [rerunPending, setRerunPending] = useState(false) + const [rerunError, setRerunError] = useState(null) + const rerunRequest = useRef(null) + const resumeRequest = useScenarioRunResume({ + onResumed: applyRunSummary, + onRefresh: (succeeded: boolean): void => { + refreshQueue() + if (!succeeded) retry() + }, + }) + + // Leaving the run, or opening an attempt on it, abandons a rerun still loading its configuration. + useEffect(() => () => { + rerunRequest.current?.abort() + rerunRequest.current = null + }, [location.pathname]) + + async function requestRerun(): Promise { + rerunRequest.current?.abort() + const controller = new AbortController() + rerunRequest.current = controller + setRerunPending(true) + setRerunError(null) + try { + const request = await scenariosApi.getLaunchConfiguration(scenarioResultId, controller.signal) + if (rerunRequest.current !== controller || controller.signal.aborted) return + navigate(scenarioRerunRoutePath(request.scenario_name, scenarioResultId)) + } catch (error: unknown) { + if (rerunRequest.current !== controller || controller.signal.aborted) return + setRerunError(toApiError(error).detail) + } finally { + if (rerunRequest.current === controller) { + rerunRequest.current = null + setRerunPending(false) + } + } + } + + async function requestCancel(): Promise { + setCancelling(true) + setCancelError(null) + try { + applyRunSummary(await scenariosApi.cancelRun(scenarioResultId)) + setCancelOpen(false) + } catch (error: unknown) { + setCancelError(toApiError(error).detail) + } finally { + setCancelling(false) + } + } + + return { + rerunPending, + rerun: (): void => { void requestRerun() }, + cancelOpen, + setCancelOpen: (open: boolean): void => { + if (open) setCancelError(null) + if (!cancelling) setCancelOpen(open) + }, + cancelling, + cancelError, + cancel: (): void => { void requestCancel() }, + resumePending: resumeRequest.pendingRunId !== null, + resume: (): void => { + setRerunError(null) + resumeRequest.requestResume(scenarioResultId) + }, + controlError: rerunError ?? resumeRequest.error + ?? (resumeRequest.executionError !== runError ? resumeRequest.executionError : null), + } +} diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 0da6db1553..8e4b98b374 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -42,7 +42,7 @@ import { import { compatibility, COMPATIBILITY_HEADER } from "./compatibility"; import { toApiError } from "./errors"; import { makeAnalyticsFacets, makeAnalyticsReport, makeAnalyticsResults } from "@/test-utils/analyticsFixtures"; -import type { FindingExportRequest } from "@/types"; +import type { AttackFindingListResponse, FindingExportRequest } from "@/types"; import { DEFAULT_ANALYTICS_VIEW } from "@/utils/attackAnalytics"; describe("api service", () => { @@ -823,6 +823,28 @@ describe("api service", () => { }); }); + it("should get the findings linked to an attack", async () => { + const data: AttackFindingListResponse = { + attack_result_id: "attack/id", + items: [{ + id: "finding", operation_id: "operation", operation_name: "Red team", evidence_id: "evidence", + title: "Protected system instructions disclosed", description: "", severity: "important", + created_at: "2026-11-06T00:00:00Z", + }], + }; + (apiClient.get as jest.Mock).mockResolvedValueOnce({ data }); + + expect(await attacksApi.getAttackFindings("attack/id")).toEqual(data); + expect(apiClient.get).toHaveBeenCalledTimes(1); + expect(apiClient.get).toHaveBeenCalledWith("/attacks/attack%2Fid/findings"); + }); + + it("should propagate attack finding errors", async () => { + const error = new Error("Findings unavailable"); + (apiClient.get as jest.Mock).mockRejectedValueOnce(error); + await expect(attacksApi.getAttackFindings("attack")).rejects.toBe(error); + }); + it("should get attack messages", async () => { const mockResponse = { data: { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 95f9725efc..795c5b794a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -7,6 +7,7 @@ import { getGraphScopes } from '../auth/msalConfig' import type { ApplyChatScorerRequest, ApplyChatScorerResponse, + AttackFindingListResponse, TargetInstance, UnregisteredTarget, UnregisteredConverter, @@ -502,6 +503,12 @@ export const attacksApi = { return response.data }, + /** Findings in any Operation that link this attack as evidence; empty for an unsaved running attack. */ + getAttackFindings: async (attackResultId: string): Promise => { + const response = await apiClient.get(`/attacks/${encodeURIComponent(attackResultId)}/findings`) + return response.data + }, + updateAttack: async (attackResultId: string, request: UpdateAttackRequest): Promise => { const response = await apiClient.patch(`/attacks/${encodeURIComponent(attackResultId)}`, request) return response.data diff --git a/frontend/src/services/recordedScanConversations.test.ts b/frontend/src/services/recordedScanConversations.test.ts new file mode 100644 index 0000000000..df2e774533 --- /dev/null +++ b/frontend/src/services/recordedScanConversations.test.ts @@ -0,0 +1,51 @@ +import { scenariosApi } from './api' +import { readRecordedScanConversations } from './recordedScanConversations' + +jest.mock('./api', () => ({ scenariosApi: { getRunProgress: jest.fn(), getRunConversation: jest.fn() } })) + +const record = { + attack_result_id: 'interrupted', conversation_id: 'recorded', atomic_attack_name: 'direct', + objective: 'Stored objective', started_at: '2026-01-01T00:00:00Z', seed_identifiers: [], +} +const progress = { + run: { scenario_result_id: 'scan', scenario_name: 'Scenario', scenario_version: 1, + status: 'CANCELLED' as const, created_at: '2026-01-01T00:00:00Z' }, + plan: null, results: [], has_more: false, plan_complete: true, recorded_conversations: [record], + summary: { overall: { completed: 0, planned: 1, succeeded: 0, success_percentage: null, errors: 0, retries: 0 }, + techniques: [], seed_groups: [], atomic_groups: [] }, +} + +describe('readRecordedScanConversations', () => { + beforeEach(() => { + jest.resetAllMocks() + jest.mocked(scenariosApi.getRunProgress).mockResolvedValue(progress) + jest.mocked(scenariosApi.getRunConversation).mockResolvedValue({ conversation_id: 'recorded', messages: [], target_response_status: null }) + }) + + it('should verify SDK run, attack and selected conversation ownership without inventing results', async () => { + jest.mocked(scenariosApi.getRunProgress).mockResolvedValue({ + ...progress, recorded_conversations: [record, record, { ...record, conversation_id: 'another-branch' }], + }) + expect(await readRecordedScanConversations('scan', 'interrupted', 'recorded')).toEqual([ + record, { ...record, conversation_id: 'another-branch' }, + ]) + expect(scenariosApi.getRunConversation).toHaveBeenCalledWith('scan', 'recorded') + expect(progress.results).toEqual([]) + expect(progress.summary.overall.completed).toBe(0) + }) + + it.each([['unrelated-attack', 'recorded'], ['interrupted', 'unrelated-conversation']])( + 'should reject unrelated ownership %s/%s', async (attackId: string, conversationId: string) => { + expect(await readRecordedScanConversations('scan', attackId, conversationId)).toBeNull() + expect(scenariosApi.getRunConversation).not.toHaveBeenCalled() + }, + ) + + it('should reject a mismatched run response and propagate scoped read failures rather than showing empty evidence', async () => { + jest.mocked(scenariosApi.getRunProgress).mockResolvedValueOnce({ ...progress, run: { ...progress.run, scenario_result_id: 'other' } }) + await expect(readRecordedScanConversations('scan', 'interrupted', 'recorded')).rejects.toThrow('different scan') + const unavailable = new Error('Stored conversation is unavailable') + jest.mocked(scenariosApi.getRunConversation).mockRejectedValueOnce(unavailable) + await expect(readRecordedScanConversations('scan', 'interrupted', 'recorded')).rejects.toBe(unavailable) + }) +}) diff --git a/frontend/src/services/recordedScanConversations.ts b/frontend/src/services/recordedScanConversations.ts new file mode 100644 index 0000000000..f2f9e4c542 --- /dev/null +++ b/frontend/src/services/recordedScanConversations.ts @@ -0,0 +1,18 @@ +import type { ScenarioActiveConversation } from '@/types' + +import { scenariosApi } from './api' + +/** Resolve SDK-recorded ownership without creating an attack result or inferring an outcome. */ +export async function readRecordedScanConversations( + scenarioResultId: string, attackResultId: string, conversationId: string, +): Promise { + const progress = await scenariosApi.getRunProgress(scenarioResultId) + if (progress.run.scenario_result_id !== scenarioResultId) { + throw new Error('The server returned a different scan while loading recorded conversation evidence.') + } + const records = (progress.recorded_conversations ?? []).filter((record: ScenarioActiveConversation) => record.attack_result_id === attackResultId) + if (!records.some((record: ScenarioActiveConversation) => record.conversation_id === conversationId)) return null + // The scoped read also verifies stored conversation metadata against the SDK-owned run provenance. + await scenariosApi.getRunConversation(scenarioResultId, conversationId) + return [...new Map(records.map((record: ScenarioActiveConversation) => [record.conversation_id, record])).values()] +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2a151f454b..deccebfac5 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -786,6 +786,9 @@ export type AttackTargetResolutionStatus = export type AttackOutcome = 'undetermined' | 'success' | 'failure' | 'error' +/** An attack's saved outcome, or where it is before one exists. */ +export type AttackDisplayOutcome = AttackOutcome | 'in_progress' | 'preparing' + // Wire contracts from pyrit.models.analytics. The SDK selects and counts the cohort. export type AttackAnalyticsDimensionName = | 'operation' @@ -987,6 +990,10 @@ export interface AttackSummary { chat_scorer?: RegisteredComponentReference | null chat_scorer_inherited?: boolean execution_read_only?: boolean + /** The attack is still running in a scan; its result is not saved yet. */ + in_progress?: boolean + /** The scan run that produced this attack; null outside a scan. */ + scenario_result_id?: string | null manual_continuation?: ManualContinuation | null last_score?: BackendScore | null last_response?: BackendMessagePiece | null @@ -1012,7 +1019,7 @@ export interface AttackSummary { export interface AttackMetadata extends Pick { readonly execution_read_only: boolean readonly last_response_message_piece_id: string | null @@ -1185,6 +1192,20 @@ export interface DisplayScore extends BackendScore { sourceLabel: string } +/** + * The fields a score popover shows, independent of where the score was recorded, + * so scan results do not need to fabricate a message-piece score. + */ +export interface ScoreSummary { + scorer: string + scoreType: string + value?: string | null + status?: string + rationale?: string | null + categories?: string[] | null + isObjective?: boolean +} + export interface BackendMessagePiece { id: string sequence?: number @@ -1336,14 +1357,15 @@ export interface AttackListResponse { export interface ConversationSummary { conversation_id: string conversation_type?: 'main' | 'pruned' - message_count: number + /** Null when a recorded-only scan reference has no counted attack-result projection. */ + message_count: number | null last_message_preview?: string | null created_at?: string | null } export interface AttackConversationsResponse { attack_result_id: string - main_conversation_id: string + main_conversation_id: string | null conversations: ConversationSummary[] pagination?: PaginationInfo | null total_count?: number | null @@ -2082,13 +2104,14 @@ export interface ScenarioQueueSnapshot { queued: ScenarioQueueEntry[] } -/** One persisted attack attempt in ascending progress order. */ +/** The objective score of one persisted scan attack attempt. */ export interface ScenarioProgressScore { scorer_name: string score_type: 'true_false' | 'float_scale' | 'unknown' status: 'complete' | 'undetermined' score_value?: string | null score_rationale?: string | null + score_category?: string[] } export type ScenarioIdentityValue = @@ -2311,7 +2334,7 @@ export interface ScenarioHierarchyProjection { truncated: boolean } -export interface ScenarioObjectiveScorerMetrics { +export interface ScenarioObjectiveScorerMetrics extends ScorerEvaluationMetrics { accuracy: number accuracy_standard_error?: number | null f1_score?: number | null @@ -2338,7 +2361,10 @@ export interface ScenarioProgressSummary { export interface ScenarioActiveConversation { attack_result_id: string + /** The conversation the attack is working on, or the recorded objective conversation. */ conversation_id: string + /** Every objective conversation the running attack has started so far, in first-use order. */ + conversation_ids?: string[] atomic_attack_name: string seed_group_id?: string | null objective: string @@ -2446,6 +2472,20 @@ export interface FindingListItem extends Finding { evidence_count: number } +/** A finding that links an attack as evidence. */ +export interface AttackFindingItem extends Finding { + /** Current name of the finding's Operation. */ + operation_name: string + /** The evidence link; detach it through the Operation evidence route. */ + evidence_id: string +} + +/** Every finding that links an attack, highest severity first, then newest first. */ +export interface AttackFindingListResponse { + attack_result_id: string + items: AttackFindingItem[] +} + export interface FindingEvidenceCreateRequest { attack_result_id: string } diff --git a/frontend/src/utils/routeParams.ts b/frontend/src/utils/routeParams.ts index 19eddd0339..af59b725a8 100644 --- a/frontend/src/utils/routeParams.ts +++ b/frontend/src/utils/routeParams.ts @@ -66,10 +66,6 @@ export function scenarioRunAttackRoutePath(scenarioResultId: string, attackResul return `${scenarioRunRoutePath(scenarioResultId)}/${encodeURIComponent(attackResultId)}` } -export function scenarioConversationRoutePath(scenarioResultId: string, conversationId: string): string { - return `${scenarioRunRoutePath(scenarioResultId)}/conversations/${encodeURIComponent(conversationId)}` -} - export function scenarioRerunRoutePath(scenarioName: string, scenarioResultId: string): string { return `/scanner/launch/${encodeURIComponent(scenarioName)}?rerun=${encodeURIComponent(scenarioResultId)}` } diff --git a/frontend/src/utils/scoreSummary.test.ts b/frontend/src/utils/scoreSummary.test.ts new file mode 100644 index 0000000000..fc01d0b0ea --- /dev/null +++ b/frontend/src/utils/scoreSummary.test.ts @@ -0,0 +1,34 @@ +import type { BackendScore, ScenarioProgressScore } from '@/types' + +import { scoreSummaryFromBackendScore, scoreSummaryFromProgressScore, scoreSummaryValue } from './scoreSummary' + +describe('scoreSummary', () => { + it('maps a message-piece score without losing its role', () => { + const score: BackendScore = { + id: 'score-1', message_piece_id: 'piece-1', scorer_type: 'Judge', score_type: 'true_false', + score_value: 'false', status: 'complete', is_objective_score: false, score_category: ['harm'], + score_rationale: 'Refused.', timestamp: '2026-01-01T00:00:00Z', + } + expect(scoreSummaryFromBackendScore(score)).toEqual({ + scorer: 'Judge', scoreType: 'true_false', value: 'false', status: 'complete', + rationale: 'Refused.', categories: ['harm'], isObjective: false, + }) + }) + + it('treats a scan result score as the objective score', () => { + const score: ScenarioProgressScore = { + scorer_name: 'objective', score_type: 'true_false', status: 'complete', score_value: 'true', + score_rationale: 'Met.', score_category: ['jailbreak'], + } + expect(scoreSummaryFromProgressScore(score)).toEqual({ + scorer: 'objective', scoreType: 'true_false', value: 'true', status: 'complete', + rationale: 'Met.', categories: ['jailbreak'], isObjective: true, + }) + }) + + it('names the status of a score without a value', () => { + expect(scoreSummaryValue({ scorer: 'Judge', scoreType: 'true_false', value: 'true', status: 'complete' })).toBe('true') + expect(scoreSummaryValue({ scorer: 'Judge', scoreType: 'true_false', value: null, status: 'undetermined' })).toBe('undetermined') + expect(scoreSummaryValue({ scorer: 'Judge', scoreType: 'true_false' })).toBe('') + }) +}) diff --git a/frontend/src/utils/scoreSummary.ts b/frontend/src/utils/scoreSummary.ts new file mode 100644 index 0000000000..342eb674e4 --- /dev/null +++ b/frontend/src/utils/scoreSummary.ts @@ -0,0 +1,31 @@ +import type { BackendScore, ScenarioProgressScore, ScoreSummary } from '@/types' + +export function scoreSummaryFromBackendScore(score: BackendScore): ScoreSummary { + return { + scorer: score.scorer_type, + scoreType: score.score_type, + value: score.score_value, + status: score.status, + rationale: score.score_rationale, + categories: score.score_category, + isObjective: score.is_objective_score, + } +} + +/** A scan result's score is the attack's objective score. */ +export function scoreSummaryFromProgressScore(score: ScenarioProgressScore): ScoreSummary { + return { + scorer: score.scorer_name, + scoreType: score.score_type, + value: score.score_value, + status: score.status, + rationale: score.score_rationale, + categories: score.score_category, + isObjective: true, + } +} + +/** An undetermined score has no value, so name its status instead. */ +export function scoreSummaryValue(score: ScoreSummary): string { + return score.value ?? score.status ?? '' +} diff --git a/pyrit/backend/README.md b/pyrit/backend/README.md index ced275448d..9c0cce11bf 100644 --- a/pyrit/backend/README.md +++ b/pyrit/backend/README.md @@ -112,6 +112,42 @@ Both paged and unpaged reads expose only main and pruned conversations. Adversarial and preparation contexts are excluded from lists, reference totals, and transcript reads, including hierarchy node expansion. +### Running scan attacks + +A scan saves each `AttackResult` only when its attack finishes. While an active scan +is still running an attack, `GET /api/attacks/{attack_result_id}`, `/metadata`, +`/conversations`, and `/messages` (paged or not) resolve it from the live run instead +of returning 404. The attack summary and metadata responses set `in_progress: true`, +report outcome `undetermined`, and set `execution_read_only: true`. Read these +endpoints for running state; conversation and message responses do not include +the running-state flags. Write routes still require a saved result. The +conversation the attack is working on is the main conversation, +and every other objective conversation started so far is listed as a pruned +reference. Messages are read from memory as they are saved. A saved result always +wins, so once the attack finishes the same IDs resolve to its saved result; restart +reference paging if a cursor becomes stale at that transition. An attack that has +not started an objective conversation yet still returns 404. + +Attack summaries and metadata also include `scenario_result_id`, the scan run that +produced the attack (`null` for attacks outside a scan). Scan progress +`active_conversations` entries list every objective conversation so far in +`conversation_ids`, alongside the current `conversation_id`. Each scan progress +result and hierarchy node `score` summarizes the objective score: `scorer_name`, +`score_type`, `status`, `score_value`, `score_rationale`, and `score_category`. + +The attack service reads running attacks through a read-only lookup +(`LiveAttackLookup`) that the scenario run service provides; it does not import +scenario scheduling. + +### Findings linked to an attack + +`GET /api/attacks/{attack_result_id}/findings` lists every finding, across all +Operations, that links the attack as evidence: highest severity first, then +newest. Each item is the finding plus `operation_name` and the `evidence_id` that +unlinks it through the Operation evidence route. An attack with no linked +findings, including one that is not saved yet, returns an empty list; a non-UUID +ID returns 422. + The saved Scenario Chat viewer consumes these bounded reads with independent reference/message continuation controls. It merges stable piece IDs into their ordered turns and reconciles page-local partial-sequence markers against the diff --git a/pyrit/backend/mappers/attack_mappers.py b/pyrit/backend/mappers/attack_mappers.py index cad7701e40..4a539066f3 100644 --- a/pyrit/backend/mappers/attack_mappers.py +++ b/pyrit/backend/mappers/attack_mappers.py @@ -232,6 +232,7 @@ async def attack_result_to_summary_async( operation_name: str | None = None, scoring_inputs: AttackScoringInputs | None = None, execution_read_only: bool = False, + in_progress: bool = False, ) -> AttackSummary: """ Build an AttackSummary view from an AttackResult. @@ -248,6 +249,7 @@ async def attack_result_to_summary_async( Legacy attacks without an Operation ID show their historical name instead. scoring_inputs: Effective inputs resolved against the current registry by the service. execution_read_only: Whether execution changes require a manual continuation. + in_progress: Whether ``ar`` projects an attack still running in a scan. Returns: AttackSummary view ready for the API response. @@ -264,6 +266,8 @@ async def attack_result_to_summary_async( chat_scorer=scoring_inputs.scorer if scoring_inputs else None, chat_scorer_inherited=scoring_inputs.inherited if scoring_inputs else False, execution_read_only=execution_read_only, + in_progress=in_progress, + scenario_result_id=ar.attribution_parent_id, last_response=await _summary_last_response_async(ar.last_response), automated_score=( ScoreView.from_domain(ar.automated_score, is_objective_score=True) if ar.automated_score else None diff --git a/pyrit/backend/models/attacks.py b/pyrit/backend/models/attacks.py index 63eb8ab0a9..0692643a94 100644 --- a/pyrit/backend/models/attacks.py +++ b/pyrit/backend/models/attacks.py @@ -262,6 +262,10 @@ class AttackSummary(AttackResult[MessagePieceView, ScoreView]): ) chat_scorer_inherited: bool = Field(default=False, description="Uses the scan's registered original scorer") execution_read_only: bool = Field(default=False, description="Execution changes require a manual continuation") + in_progress: bool = Field( + default=False, description="The attack is still running in a scan; its result is not saved yet" + ) + scenario_result_id: str | None = Field(default=None, description="The scan run that produced this attack") @computed_field @property @@ -360,6 +364,10 @@ class AttackMetadata(BaseModel): chat_scorer: RegisteredComponentReference | None = None chat_scorer_inherited: bool = False execution_read_only: bool = False + in_progress: bool = Field( + default=False, description="The attack is still running in a scan; its result is not saved yet" + ) + scenario_result_id: str | None = Field(default=None, description="The scan run that produced this attack") manual_continuation: ManualContinuation | None = None last_response_message_piece_id: str | None = None related_conversation_count: int = Field(..., ge=0, description="Number of pruned conversations, excluding main") diff --git a/pyrit/backend/models/operations.py b/pyrit/backend/models/operations.py index 723d5b0468..590689f05a 100644 --- a/pyrit/backend/models/operations.py +++ b/pyrit/backend/models/operations.py @@ -70,6 +70,22 @@ class FindingListItem(Finding): evidence_count: int = Field(ge=0) +class AttackFindingItem(Finding): + """A finding that links an attack as evidence, with its Operation's current name and the evidence link.""" + + operation_name: str + evidence_id: UUID = Field(description="The evidence link, which unlinks the attack from the finding") + + +class AttackFindingListResponse(BaseModel): + """Every finding that links an attack, highest severity first, then newest first.""" + + model_config = ConfigDict(extra="forbid") + + attack_result_id: UUID + items: list[AttackFindingItem] + + class FindingListResponse(BaseModel): """One page of an operation's findings.""" diff --git a/pyrit/backend/routes/attacks.py b/pyrit/backend/routes/attacks.py index 70e38b65a7..73bfd66f81 100644 --- a/pyrit/backend/routes/attacks.py +++ b/pyrit/backend/routes/attacks.py @@ -42,6 +42,7 @@ LabelFilterStr, ProblemDetail, ) +from pyrit.backend.models.operations import AttackFindingListResponse from pyrit.backend.models.scores import ApplyChatScorerRequest, ApplyChatScorerResponse from pyrit.backend.routes.common import parse_label_query_params from pyrit.backend.services.attack_service import AttackObjectiveConflictError, get_attack_service @@ -49,6 +50,7 @@ from pyrit.backend.services.history_replay import HistoryReplayError from pyrit.backend.services.manual_send_scheduler import ManualSendConflictError, ManualSendQueueFullError from pyrit.backend.services.media_persistence import MediaAccessDeniedError +from pyrit.backend.services.operation_service import OperationService from pyrit.common.deprecation import print_deprecation_message from pyrit.memory.memory_interface import AttackStateConflictError @@ -380,6 +382,20 @@ async def get_attack_metadata_async( return metadata +@router.get("/{attack_result_id}/findings", response_model=AttackFindingListResponse) +async def get_attack_findings_async(attack_result_id: uuid.UUID) -> AttackFindingListResponse: + """ + List the findings that link this attack as evidence, across all Operations. + + An attack with no linked findings, including a running scan attack that is not saved yet, + returns an empty list rather than 404. + + Returns: + AttackFindingListResponse: The linked findings with their Operation names and evidence links. + """ + return await OperationService().list_attack_findings_async(attack_result_id) + + @router.patch( "/{attack_result_id}", response_model=AttackSummary, diff --git a/pyrit/backend/services/attack_service.py b/pyrit/backend/services/attack_service.py index a97e6931fc..246d3b7af7 100644 --- a/pyrit/backend/services/attack_service.py +++ b/pyrit/backend/services/attack_service.py @@ -22,7 +22,7 @@ from collections.abc import Mapping, Sequence from datetime import UTC, datetime from functools import lru_cache -from typing import Any, Literal +from typing import Any, Literal, NamedTuple from pyrit.backend.mappers import ( attack_result_to_summary_async, @@ -60,6 +60,7 @@ from pyrit.backend.services.chat_scoring_service import get_chat_scoring_service from pyrit.backend.services.component_lifecycle import release_component_async from pyrit.backend.services.history_replay import validate_history_replay +from pyrit.backend.services.live_attack_reader import LiveAttackLookup from pyrit.backend.services.media_persistence import persist_message_pieces_async from pyrit.backend.services.message_send_service import ( MessageSendService, @@ -83,7 +84,7 @@ data_serializer_factory, set_message_piece_sha256_async, ) -from pyrit.memory.memory_interface import AttackStateConflictError +from pyrit.memory.memory_interface import AttackConversationPage, AttackStateConflictError from pyrit.models import ( AtomicAttackIdentifier, AttackIdentifier, @@ -132,19 +133,35 @@ class AttackObjectiveConflictError(Exception): """The shared objective changed while it was being edited.""" +class _ReadableAttack(NamedTuple): + """A saved result, or the projection of an attack still running in a scan.""" + + result: AttackResult + in_progress: bool + + class AttackService: """ Service for managing attacks. - Uses PyRIT memory (database) as the source of truth via AttackResult. + Uses PyRIT memory (database) as the source of truth via AttackResult. Read APIs also + resolve scan attacks that are still running through an optional read-only lookup; + a saved result always wins over that live projection. """ def __init__( - self, *, message_send_service: MessageSendService | None = None, memory: MemoryInterface | None = None + self, + *, + message_send_service: MessageSendService | None = None, + memory: MemoryInterface | None = None, + live_attack_lookup: LiveAttackLookup | None = None, ) -> None: """Initialize the attack service.""" self._memory = memory if memory is not None else CentralMemory.get_memory_instance() - self._saved_conversation_reader = SavedConversationReadService(memory=self._memory) + self._live_attack_lookup = live_attack_lookup + self._saved_conversation_reader = SavedConversationReadService( + memory=self._memory, read_references=self._read_conversation_page_async + ) self._message_send_service = ( message_send_service if message_send_service is not None else get_message_send_service() ) @@ -362,16 +379,16 @@ async def get_attack_async(self, *, attack_result_id: str) -> AttackSummary | No """ Get attack details (high-level metadata, no messages). - Queries the AttackResult from the database by its primary key. + Reads the saved AttackResult by its primary key, or projects a scan attack that is still running. Returns: AttackSummary if found, None otherwise. """ - results = await self._memory.get_attack_results_async(attack_result_ids=[attack_result_id]) - if not results: + attack = await self._read_attack_async(attack_result_id=attack_result_id) + if attack is None: return None - return await self._summarize_attack_async(ar=results[0]) + return await self._summarize_attack_async(ar=attack.result, in_progress=attack.in_progress) async def get_attack_metadata_async( self, *, attack_result_id: str, conversation_id: str | None = None @@ -385,24 +402,25 @@ async def get_attack_metadata_async( Returns: AttackMetadata | None: Metadata without reference arrays, or None for an absent attack. """ - results = await self._memory.get_attack_results_async(attack_result_ids=[attack_result_id]) - if not results: + attack = await self._read_attack_async(attack_result_id=attack_result_id) + if attack is None: return None - ar = results[0] - references = await self._memory.get_attack_conversation_page_async(attack_result_id=attack_result_id, limit=1) + ar = attack.result + references = await self._read_conversation_page_async(attack_result_id=attack_result_id, limit=1) if references is None: return None selected_id = conversation_id if conversation_id is not None else ar.conversation_id selected_valid = bool(selected_id) and selected_id == references.main_conversation_id if not selected_valid: - selected = await self._memory.get_attack_conversation_page_async( + selected = await self._read_conversation_page_async( attack_result_id=attack_result_id, conversation_id=selected_id, limit=1 ) if selected is None: return None selected_valid = bool(selected.conversations) summary = await self._summarize_attack_async( - ar=ar.model_copy(update={"related_conversations": set(), "last_response": None}) + ar=ar.model_copy(update={"related_conversations": set(), "last_response": None}), + in_progress=attack.in_progress, ) return AttackMetadata( attack_result_id=summary.attack_result_id, @@ -418,6 +436,8 @@ async def get_attack_metadata_async( chat_scorer=summary.chat_scorer, chat_scorer_inherited=summary.chat_scorer_inherited, execution_read_only=summary.execution_read_only, + in_progress=summary.in_progress, + scenario_result_id=summary.scenario_result_id, manual_continuation=summary.manual_continuation, last_response_message_piece_id=str(ar.last_response.id) if ar.last_response is not None else None, related_conversation_count=references.total_count - int(bool(references.main_conversation_id)), @@ -425,7 +445,42 @@ async def get_attack_metadata_async( selected_conversation_valid=selected_valid, ) - async def _summarize_attack_async(self, *, ar: AttackResult) -> AttackSummary: + async def _read_attack_async(self, *, attack_result_id: str) -> _ReadableAttack | None: + """ + Read the saved result, or project the attack while a scan is still running it. + + The live snapshot is taken before the storage read, so an attack that finishes in between + resolves to its saved result. + + Returns: + _ReadableAttack | None: The readable attack, or None when it is neither saved nor running. + """ + live = self._live_attack_lookup(attack_result_id) if self._live_attack_lookup else None + results = await self._memory.get_attack_results_async(attack_result_ids=[attack_result_id]) + if results: + return _ReadableAttack(result=results[0], in_progress=False) + if live is None: + return None + return _ReadableAttack(result=live.to_attack_result(), in_progress=True) + + async def _read_conversation_page_async( + self, *, attack_result_id: str, offset: int = 0, limit: int = 50, conversation_id: str | None = None + ) -> AttackConversationPage | None: + """ + Page saved conversation references, or a running scan attack's live references. + + Returns: + AttackConversationPage | None: The reference page, or None when the attack is neither saved nor running. + """ + live = self._live_attack_lookup(attack_result_id) if self._live_attack_lookup else None + page = await self._memory.get_attack_conversation_page_async( + attack_result_id=attack_result_id, offset=offset, limit=limit, conversation_id=conversation_id + ) + if page is not None or live is None: + return page + return live.conversation_page(offset=offset, limit=limit, conversation_id=conversation_id) + + async def _summarize_attack_async(self, *, ar: AttackResult, in_progress: bool = False) -> AttackSummary: """ Map one loaded result with the shared presentation and scoring policy. @@ -440,7 +495,8 @@ async def _summarize_attack_async(self, *, ar: AttackResult) -> AttackSummary: stats=stats, operation_name=operation_names.get(ar.operation_id) if ar.operation_id else None, scoring_inputs=get_chat_scoring_service().inputs(ar), - execution_read_only=get_chat_scoring_service().execution_read_only(ar), + execution_read_only=in_progress or get_chat_scoring_service().execution_read_only(ar), + in_progress=in_progress, ) async def _get_operation_names_async(self, *, results: Sequence[AttackResult]) -> dict[uuid.UUID, str]: @@ -487,12 +543,12 @@ async def get_conversation_messages_async( raise ValueError("A message-page cursor requires a limit.") # Check attack exists - results = await self._memory.get_attack_results_async(attack_result_ids=[attack_result_id]) - if not results: + attack = await self._read_attack_async(attack_result_id=attack_result_id) + if attack is None: return None # Verify the conversation belongs to this attack - ar = results[0] + ar = attack.result if conversation_id not in ar.get_active_conversation_ids(): raise ValueError(f"Conversation '{conversation_id}' is not part of attack '{attack_result_id}'") @@ -1276,12 +1332,11 @@ async def get_conversations_async( ) if cursor is not None: raise ValueError("A conversation-page cursor requires a limit.") - results = await self._memory.get_attack_results_async(attack_result_ids=[attack_result_id]) - if not results: + attack = await self._read_attack_async(attack_result_id=attack_result_id) + if attack is None: return None - # attack_result_id is a unique primary key, so at most one result is returned. - ar = results[0] + ar = attack.result # Collect all conversation IDs (main + PRUNED related) and fetch stats in one query. active_conv_ids = list(ar.get_active_conversation_ids()) @@ -1597,7 +1652,12 @@ def get_attack_service() -> AttackService: """ Get the global attack service instance. + Wires the scenario run service's read-only lookup for scan attacks that are still running. + Returns: The singleton AttackService instance. """ - return AttackService() + # Composition root: AttackService depends only on the lookup type, not on scenario scheduling. + from pyrit.backend.services.scenario_run_service import find_live_attack + + return AttackService(live_attack_lookup=find_live_attack) diff --git a/pyrit/backend/services/live_attack_reader.py b/pyrit/backend/services/live_attack_reader.py new file mode 100644 index 0000000000..739054b58b --- /dev/null +++ b/pyrit/backend/services/live_attack_reader.py @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Read-only port for scan attacks that are still running. + +A scan saves each ``AttackResult`` only when its attack finishes. Until then, the attack read APIs +project the live execution through this port. The scenario run service provides the lookup; the +attack service depends only on these types, so it never imports scenario scheduling logic. +""" + +from collections.abc import Callable +from dataclasses import dataclass + +from pyrit.executor.attack.core.attack_executor import ActiveAttackExecution +from pyrit.memory.memory_interface import AttackConversationPage +from pyrit.models import AttackOutcome, AttackResult, ConversationReference, ConversationType + + +@dataclass(frozen=True, slots=True) +class LiveAttack: + """An attack still running in a scan, with at least one objective conversation.""" + + scenario_result_id: str + execution: ActiveAttackExecution + + @classmethod + def from_execution(cls, *, scenario_result_id: str, execution: ActiveAttackExecution) -> "LiveAttack | None": + """ + Wrap an execution once it has an objective conversation to read. + + Returns: + LiveAttack | None: The live attack, or None before its first objective conversation starts. + """ + if not execution.conversation_ids and execution.current_conversation_id is None: + return None + return cls(scenario_result_id=scenario_result_id, execution=execution) + + @property + def main_conversation_id(self) -> str: + """The conversation the attack is working on, otherwise its first objective conversation.""" + return self.execution.current_conversation_id or self.execution.conversation_ids[0] + + @property + def other_conversation_ids(self) -> list[str]: + """The other objective conversations started so far, in first-use order.""" + main = self.main_conversation_id + return [conversation_id for conversation_id in self.execution.conversation_ids if conversation_id != main] + + def to_attack_result(self) -> AttackResult: + """ + Project the unsaved result the attack read APIs show while the attack runs. + + Other objective conversations are user-visible references, like the pruned conversations of a saved result. + + Returns: + AttackResult: An undetermined, unsaved result owned by the running scan. + """ + execution = self.execution + return AttackResult( + attack_result_id=execution.attack_result_id, + conversation_id=self.main_conversation_id, + objective=execution.objective, + atomic_attack_identifier=execution.atomic_attack_identifier, + outcome=AttackOutcome.UNDETERMINED, + timestamp=execution.started_at, + related_conversations={ + ConversationReference(conversation_id=conversation_id, conversation_type=ConversationType.PRUNED) + for conversation_id in self.other_conversation_ids + }, + attribution_parent_id=self.scenario_result_id, + ) + + def conversation_page( + self, *, offset: int = 0, limit: int = 50, conversation_id: str | None = None + ) -> AttackConversationPage: + """ + Page the live conversation references the same way saved references are paged. + + Returns: + AttackConversationPage: The main conversation first, then the others in first-use order. + """ + return AttackConversationPage.from_references( + main_conversation_id=self.main_conversation_id, + pruned_conversation_ids=self.other_conversation_ids, + timestamp=self.execution.started_at, + offset=offset, + limit=limit, + conversation_id=conversation_id, + ) + + +#: Finds a running scan attack by its ``attack_result_id``; None when no active scan is running it. +LiveAttackLookup = Callable[[str], LiveAttack | None] diff --git a/pyrit/backend/services/operation_service.py b/pyrit/backend/services/operation_service.py index 8fa55438f0..5417c16285 100644 --- a/pyrit/backend/services/operation_service.py +++ b/pyrit/backend/services/operation_service.py @@ -6,6 +6,8 @@ from pyrit.backend.models.attacks import ScoreView from pyrit.backend.models.operations import ( + AttackFindingItem, + AttackFindingListResponse, EvidenceAvailability, FindingCreateRequest, FindingEvidenceAttachResponse, @@ -382,6 +384,30 @@ async def detach_finding_evidence_async(self, *, operation_id: UUID, finding_id: except LookupError as exc: raise FindingEvidenceNotFoundError(str(exc)) from exc + async def list_attack_findings_async(self, attack_result_id: UUID) -> AttackFindingListResponse: + """ + Read every finding that links an attack as evidence, across all Operations. + + An attack with no linked findings, including one that is not saved yet, has an empty list. + + Returns: + AttackFindingListResponse: The linked findings with their Operation names and evidence links. + """ + memory = CentralMemory.get_memory_instance() + linked = await memory.get_findings_for_attack_result_async(attack_result_id=attack_result_id) + operation_ids = list({finding.operation_id for finding, _ in linked}) + operations = await memory.get_operations_async(operation_ids=operation_ids) if operation_ids else [] + names = {operation.id: operation.name for operation in operations} + return AttackFindingListResponse( + attack_result_id=attack_result_id, + items=[ + AttackFindingItem( + **finding.model_dump(), operation_name=names.get(finding.operation_id, ""), evidence_id=evidence.id + ) + for finding, evidence in linked + ], + ) + async def _require_finding_async(self, *, operation_id: UUID, finding_id: UUID) -> Finding: finding = await CentralMemory.get_memory_instance().get_finding_async( operation_id=operation_id, finding_id=finding_id diff --git a/pyrit/backend/services/saved_conversation_read_service.py b/pyrit/backend/services/saved_conversation_read_service.py index a78bff901d..1558cd79bd 100644 --- a/pyrit/backend/services/saved_conversation_read_service.py +++ b/pyrit/backend/services/saved_conversation_read_service.py @@ -4,6 +4,7 @@ """Bounded, read-only saved conversation views shared by attack and scenario APIs.""" import uuid +from typing import Protocol from pyrit.backend.mappers._preview import format_last_message_preview from pyrit.backend.mappers.attack_mappers import pyrit_messages_to_dto_async @@ -14,16 +15,34 @@ from pyrit.backend.models.common import PaginationInfo from pyrit.common.pagination import decode_keyset_cursor, encode_keyset_cursor, fingerprint_filters from pyrit.memory import MemoryInterface -from pyrit.memory.memory_interface import MessagePieceKeysetCursor +from pyrit.memory.memory_interface import AttackConversationPage, MessagePieceKeysetCursor from pyrit.models import ConversationStats, ConversationSummary +class ConversationReferenceReader(Protocol): + """Reads one page of an attack's main and pruned conversation references.""" + + async def __call__( + self, *, attack_result_id: str, offset: int = 0, limit: int = 50, conversation_id: str | None = None + ) -> AttackConversationPage | None: + """Return the page, or None for an absent attack.""" + ... + + class SavedConversationReadService: """Read canonical saved references/messages without constructing an executable attack.""" - def __init__(self, *, memory: MemoryInterface) -> None: - """Bind all saved reads to the caller's memory instance.""" + def __init__(self, *, memory: MemoryInterface, read_references: ConversationReferenceReader | None = None) -> None: + """ + Bind all saved reads to the caller's memory instance. + + Args: + memory (MemoryInterface): Memory that holds the saved messages. + read_references (ConversationReferenceReader | None): Where attack conversation references come from. + Defaults to the saved references in memory. + """ self._memory = memory + self._read_references = read_references or self._read_saved_references_async async def list_conversations_async( self, *, attack_result_id: str, limit: int = 50, cursor: str | None = None @@ -37,7 +56,7 @@ async def list_conversations_async( Raises: ValueError: If the cursor belongs to another attack or an obsolete reference ordering. """ - page = await self._memory.get_attack_conversation_page_async(attack_result_id=attack_result_id, limit=limit) + page = await self._read_references(attack_result_id=attack_result_id, limit=limit) if page is None: return None fingerprint = fingerprint_filters( @@ -60,9 +79,7 @@ async def list_conversations_async( offset = anchor.position if anchor is not None and anchor.position is not None else 0 if offset: revision = page.reference_revision - page = await self._memory.get_attack_conversation_page_async( - attack_result_id=attack_result_id, offset=offset, limit=limit - ) + page = await self._read_references(attack_result_id=attack_result_id, offset=offset, limit=limit) if page is None: return None if page.reference_revision != revision: @@ -120,7 +137,7 @@ async def get_messages_async( Raises: ValueError: If the conversation is unrelated or the continuation cursor is invalid. """ - references = await self._memory.get_attack_conversation_page_async( + references = await self._read_references( attack_result_id=attack_result_id, conversation_id=conversation_id, limit=1 ) if references is None: @@ -165,3 +182,10 @@ async def get_messages_async( pagination=PaginationInfo(limit=limit, has_more=next_cursor is not None, next_cursor=next_cursor), partial_message_sequences=list(page.partial_message_sequences), ) + + async def _read_saved_references_async( + self, *, attack_result_id: str, offset: int = 0, limit: int = 50, conversation_id: str | None = None + ) -> AttackConversationPage | None: + return await self._memory.get_attack_conversation_page_async( + attack_result_id=attack_result_id, offset=offset, limit=limit, conversation_id=conversation_id + ) diff --git a/pyrit/backend/services/scenario_run_service.py b/pyrit/backend/services/scenario_run_service.py index b21ff17b33..9540622d08 100644 --- a/pyrit/backend/services/scenario_run_service.py +++ b/pyrit/backend/services/scenario_run_service.py @@ -36,6 +36,7 @@ from pyrit.backend.models.attacks import ConversationMessagesResponse from pyrit.backend.models.common import PaginationInfo, filter_sensitive_fields from pyrit.backend.models.scenarios import ScenarioRunListResponse +from pyrit.backend.services.live_attack_reader import LiveAttack from pyrit.backend.services.pagination import ( decode_keyset_cursor, encode_keyset_cursor, @@ -2562,6 +2563,21 @@ def snapshot_active_run(self, *, scenario_result_id: str) -> _ActiveRunSnapshot: active_scenario_result_id=active_scenario_result_id, ) + def find_live_attack(self, *, attack_result_id: str) -> LiveAttack | None: + """ + Find an attack that an active scan is still running. + + Returns: + LiveAttack | None: The running attack and its scan, or None when no active scan is running it + or it has not started an objective conversation yet. + """ + for scenario_result_id in list(self._active_tasks): + snapshot = self.snapshot_active_run(scenario_result_id=scenario_result_id) + for execution in snapshot.executions: + if execution.attack_result_id == attack_result_id: + return LiveAttack.from_execution(scenario_result_id=scenario_result_id, execution=execution) + return None + def _load_run_plan(self, *, scenario_result: ScenarioResult) -> ScenarioRunPlan | None: """ Load a validated plan from scenario metadata. @@ -2701,6 +2717,7 @@ def _active_conversations( ScenarioActiveConversation( attack_result_id=execution.attack_result_id, conversation_id=conversation_id, + conversation_ids=list(execution.conversation_ids), atomic_attack_name=attribution.parent_collection, seed_group_id=attribution.seed_group_id, objective=execution.objective, @@ -3258,6 +3275,19 @@ def peek_scenario_run_service() -> ScenarioRunService | None: return _service_instance +def find_live_attack(attack_result_id: str) -> LiveAttack | None: + """ + Find a running scan attack in the current service, without constructing one. + + Resolves the service on every call, so the lookup stays valid across service resets. + + Returns: + LiveAttack | None: The running attack, or None when no active scan is running it. + """ + service = peek_scenario_run_service() + return service.find_live_attack(attack_result_id=attack_result_id) if service is not None else None + + async def reset_scenario_run_service_async() -> None: """Close and discard the drained singleton.""" global _service_instance diff --git a/pyrit/memory/alembic/versions/b5d7f9a1c3e6_index_finding_evidence_attack_result.py b/pyrit/memory/alembic/versions/b5d7f9a1c3e6_index_finding_evidence_attack_result.py new file mode 100644 index 0000000000..0d09bafd12 --- /dev/null +++ b/pyrit/memory/alembic/versions/b5d7f9a1c3e6_index_finding_evidence_attack_result.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Index finding evidence by attack result so an attack's findings are a direct lookup. + +``attack_result_id`` is a bounded UUID column on every backend, so SQL Server accepts it as an index key. + +Revision ID: b5d7f9a1c3e6 +Revises: e7c4a2d9f013 +Create Date: 2026-11-06 17:30:00.000000 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "b5d7f9a1c3e6" +down_revision: str | None = "e7c4a2d9f013" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add the attack-result lookup index.""" + op.create_index("ix_FindingEvidenceEntries_attack_result_id", "FindingEvidenceEntries", ["attack_result_id"]) + + +def downgrade() -> None: + """Remove the attack-result lookup index.""" + op.drop_index("ix_FindingEvidenceEntries_attack_result_id", table_name="FindingEvidenceEntries") diff --git a/pyrit/memory/alembic/versions/f8a6c2d4e901_merge_finding_index_and_http_fingerprint.py b/pyrit/memory/alembic/versions/f8a6c2d4e901_merge_finding_index_and_http_fingerprint.py new file mode 100644 index 0000000000..eb876aeee3 --- /dev/null +++ b/pyrit/memory/alembic/versions/f8a6c2d4e901_merge_finding_index_and_http_fingerprint.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Merge the finding-evidence index and HTTP request fingerprint heads. + +Revision ID: f8a6c2d4e901 +Revises: b5d7f9a1c3e6, d6de1045cca4 +Create Date: 2026-11-07 08:40:00.000000 +""" + +from collections.abc import Sequence + +revision: str = "f8a6c2d4e901" +down_revision: str | Sequence[str] | None = ("b5d7f9a1c3e6", "d6de1045cca4") +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Apply this schema upgrade.""" + + +def downgrade() -> None: + """Revert this schema upgrade.""" diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py index a992790775..9d92f99a73 100644 --- a/pyrit/memory/memory_interface.py +++ b/pyrit/memory/memory_interface.py @@ -503,6 +503,58 @@ class AttackConversationPage: reference_revision: str objective_score_id: str | None = None + @classmethod + def from_references( + cls, + *, + main_conversation_id: str, + pruned_conversation_ids: Sequence[str], + timestamp: datetime, + offset: int = 0, + limit: int = 50, + conversation_id: str | None = None, + objective_score_id: str | None = None, + ) -> "AttackConversationPage": + """ + Page main and pruned references in stored order: main first, then pruned in their recorded order. + + The revision fingerprints the full ordering, so continuation cursors detect reference changes. + + Returns: + AttackConversationPage: The selected or paged references and their ordering revision. + """ + references: dict[str, AttackConversationRecord] = {} + if main_conversation_id: + references[main_conversation_id] = AttackConversationRecord(conversation_id=main_conversation_id) + for identifier in pruned_conversation_ids: + if identifier: + references.setdefault( + identifier, + AttackConversationRecord(conversation_id=identifier, conversation_type=ConversationType.PRUNED), + ) + ordered = tuple(references.values()) + revision = hashlib.sha256( + json.dumps( + [ + ( + reference.conversation_id, + reference.conversation_type.value if reference.conversation_type is not None else None, + ) + for reference in ordered + ] + ).encode("utf-8") + ).hexdigest() + if conversation_id is not None: + ordered = tuple(reference for reference in ordered if reference.conversation_id == conversation_id) + return cls( + main_conversation_id=main_conversation_id, + conversations=ordered[offset : offset + limit], + total_count=len(ordered), + timestamp=timestamp, + reference_revision=revision, + objective_score_id=objective_score_id, + ) + @dataclass(frozen=True, slots=True, kw_only=True) class _AttackResultQuery: @@ -1211,6 +1263,25 @@ async def get_finding_evidence_by_source_async( ) return entry.get_evidence() if entry else None + async def get_findings_for_attack_result_async( + self, *, attack_result_id: uuid.UUID + ) -> list[tuple[Finding, FindingEvidence]]: + """ + Read every finding that links an attack result as evidence, highest severity first, then newest first. + + Returns: + list[tuple[Finding, FindingEvidence]]: Each linked finding with the evidence that links it. + """ + statement = ( + select(FindingEntry, FindingEvidenceEntry) + .join(FindingEvidenceEntry, FindingEvidenceEntry.finding_id == FindingEntry.id) + .where(FindingEvidenceEntry.attack_result_id == attack_result_id) + .order_by(*self._finding_order()) + ) + async with await self.get_session_async() as session: + rows = (await session.execute(statement)).all() + return [(finding.get_finding(), evidence.get_evidence()) for finding, evidence in rows] + async def delete_finding_evidence_async(self, *, finding_id: uuid.UUID, evidence_id: uuid.UUID) -> None: """ Unlink evidence from a finding. The attack result is kept. @@ -7985,6 +8056,7 @@ def _row_to_scenario_attack_result_delta(row: Mapping[str, Any]) -> ScenarioAtta status=ScoreStatus(row["score_status"]), score_value=row["score_value"], score_rationale=row["score_rationale"], + score_category=list(row["score_category"] or []), ) related_cids: list[str] = [] for col in ( @@ -8043,6 +8115,7 @@ def _scenario_attack_result_delta_statement() -> Select: ScoreEntry.score_type, ScoreEntry.status.label("score_status"), ScoreEntry.score_rationale, + ScoreEntry.score_category, ScoreEntry.scorer_class_identifier, ).outerjoin( ScoreEntry, @@ -8490,38 +8563,16 @@ def _execute_get_attack_conversation_page( row = session.execute(statement).mappings().one_or_none() if row is None: return None - references: dict[str, AttackConversationRecord] = {} - main_id = row["conversation_id"] or "" - if main_id: - references[main_id] = AttackConversationRecord(conversation_id=main_id) values = row["pruned_conversation_ids"] - if isinstance(values, list): - for identifier in values: - if isinstance(identifier, str) and identifier: - references.setdefault( - identifier, - AttackConversationRecord(conversation_id=identifier, conversation_type=ConversationType.PRUNED), - ) - ordered = tuple(references.values()) - revision = hashlib.sha256( - json.dumps( - [ - ( - reference.conversation_id, - reference.conversation_type.value if reference.conversation_type is not None else None, - ) - for reference in ordered - ] - ).encode("utf-8") - ).hexdigest() - if conversation_id is not None: - ordered = tuple(reference for reference in ordered if reference.conversation_id == conversation_id) - return AttackConversationPage( - main_conversation_id=main_id, - conversations=ordered[offset : offset + limit], - total_count=len(ordered), + return AttackConversationPage.from_references( + main_conversation_id=row["conversation_id"] or "", + pruned_conversation_ids=[value for value in values if isinstance(value, str)] + if isinstance(values, list) + else [], timestamp=row["timestamp"], - reference_revision=revision, + offset=offset, + limit=limit, + conversation_id=conversation_id, objective_score_id=str(row["objective_score_id"]) if row["objective_score_id"] is not None else None, ) diff --git a/pyrit/memory/memory_models.py b/pyrit/memory/memory_models.py index eb3f9f04a8..9fa77cd7d4 100644 --- a/pyrit/memory/memory_models.py +++ b/pyrit/memory/memory_models.py @@ -374,6 +374,7 @@ class FindingEvidenceEntry(Base): __tablename__ = "FindingEvidenceEntries" __table_args__ = ( Index("ix_FindingEvidenceEntries_finding_id", "finding_id"), + Index("ix_FindingEvidenceEntries_attack_result_id", "attack_result_id"), UniqueConstraint("finding_id", "attack_result_id", name="uq_finding_evidence_source"), ) diff --git a/pyrit/models/scenario_progress.py b/pyrit/models/scenario_progress.py index ed36364e69..57d3006a6e 100644 --- a/pyrit/models/scenario_progress.py +++ b/pyrit/models/scenario_progress.py @@ -156,6 +156,7 @@ class ScenarioProgressScore(BaseModel): status: ScoreStatus score_value: str | None = None score_rationale: str | None = None + score_category: list[str] = Field(default_factory=list) class ScenarioComponentIdentity(BaseModel): @@ -365,7 +366,10 @@ class ScenarioActiveConversation(BaseModel): """A saved objective conversation and the scan execution that produced it.""" attack_result_id: str + #: The conversation the attack is working on, or the recorded objective conversation. conversation_id: str + #: Every objective conversation the attack has started so far, in first-use order. + conversation_ids: list[str] = Field(default_factory=list) atomic_attack_name: str seed_group_id: str | None = None objective: str diff --git a/tests/unit/backend/test_live_attack_reads.py b/tests/unit/backend/test_live_attack_reads.py new file mode 100644 index 0000000000..9de299f023 --- /dev/null +++ b/tests/unit/backend/test_live_attack_reads.py @@ -0,0 +1,292 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Attack reads for scan attacks that are still running, and findings linked to an attack.""" + +import uuid +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from pyrit.backend.main import app +from pyrit.backend.models.attacks import AttackSummary +from pyrit.backend.models.operations import AttackFindingItem, AttackFindingListResponse +from pyrit.backend.services import attack_service as attack_service_module +from pyrit.backend.services import scenario_run_service as scenario_run_service_module +from pyrit.backend.services.attack_service import AttackService +from pyrit.backend.services.live_attack_reader import LiveAttack +from pyrit.backend.services.message_send_service import MessageSendService +from pyrit.backend.services.operation_service import OperationService +from pyrit.backend.services.scenario_run_service import ScenarioRunService, _ActiveTask, find_live_attack +from pyrit.executor.attack.core.attack_executor import ActiveAttackExecution +from pyrit.executor.attack.core.attack_result_attribution import AttackResultAttribution +from pyrit.memory import SQLiteMemory +from pyrit.models import ( + AtomicAttackIdentifier, + AttackOutcome, + AttackResult, + ComponentIdentifier, + ConversationType, + Finding, + FindingEvidence, + Operation, +) +from pyrit.scenario.core import Scenario + +RUN_ID = str(uuid.UUID(int=100)) +ATTACK_ID = str(uuid.UUID(int=200)) +STARTED_AT = datetime(2026, 11, 6, 12, tzinfo=UTC) + + +def _execution( + *, conversation_ids: tuple[str, ...] = ("first", "current"), current: str | None = "current" +) -> ActiveAttackExecution: + return ActiveAttackExecution( + attack_result_id=ATTACK_ID, + objective="Reveal the system prompt", + conversation_ids=conversation_ids, + current_conversation_id=current, + started_at=STARTED_AT, + attribution=AttackResultAttribution(parent_id=RUN_ID, parent_collection="live"), + atomic_attack_identifier=AtomicAttackIdentifier.build( + attack_identifier=ComponentIdentifier(class_name="CrescendoAttack", class_module="pyrit.executor.attack") + ), + ) + + +def _live_attack(**execution: object) -> LiveAttack: + live = LiveAttack.from_execution(scenario_result_id=RUN_ID, execution=_execution(**execution)) + assert live is not None + return live + + +def _attack_service(*, memory: SQLiteMemory, live: LiveAttack | None) -> AttackService: + return AttackService( + message_send_service=MagicMock(spec=MessageSendService), + memory=memory, + live_attack_lookup=lambda attack_result_id: live if attack_result_id == ATTACK_ID else None, + ) + + +def test_live_attack_requires_an_objective_conversation() -> None: + assert ( + LiveAttack.from_execution(scenario_result_id=RUN_ID, execution=_execution(conversation_ids=(), current=None)) + is None + ) + + +@pytest.mark.parametrize( + "conversation_ids, current, expected_main, expected_others", + [ + (("first", "current"), "current", "current", ["first"]), + (("first", "second"), None, "first", ["second"]), + ((), "current", "current", []), + ], +) +def test_live_attack_main_is_current_otherwise_first( + conversation_ids: tuple[str, ...], current: str | None, expected_main: str, expected_others: list[str] +) -> None: + live = _live_attack(conversation_ids=conversation_ids, current=current) + assert live.main_conversation_id == expected_main + assert live.other_conversation_ids == expected_others + + +def test_live_attack_projects_an_undetermined_unsaved_result() -> None: + result = _live_attack().to_attack_result() + assert result.attack_result_id == ATTACK_ID + assert result.conversation_id == "current" + assert result.objective == "Reveal the system prompt" + assert result.outcome == AttackOutcome.UNDETERMINED + assert result.timestamp == STARTED_AT + assert result.attribution_parent_id == RUN_ID + assert result.get_active_conversation_ids() == {"first", "current"} + assert {ref.conversation_type for ref in result.related_conversations} == {ConversationType.PRUNED} + assert result.get_attack_strategy_identifier().class_name == "CrescendoAttack" + + +def test_live_attack_pages_references_like_saved_references() -> None: + live = _live_attack(conversation_ids=("first", "current", "third")) + page = live.conversation_page(limit=2) + assert [reference.conversation_id for reference in page.conversations] == ["current", "first"] + assert page.total_count == 3 + assert page.timestamp == STARTED_AT + assert live.conversation_page(offset=2, limit=2).conversations[0].conversation_id == "third" + assert live.conversation_page(conversation_id="first").conversations[0].conversation_type == ConversationType.PRUNED + assert live.conversation_page(conversation_id="unrelated").conversations == () + assert live.conversation_page().reference_revision == page.reference_revision + + +@pytest.mark.usefixtures("patch_central_database") +def test_scenario_run_service_finds_only_attacks_its_active_runs_are_running() -> None: + service = ScenarioRunService() + scenario = MagicMock(spec=Scenario) + scenario.active_executions = (_execution(),) + scenario.active_atomic_group_ids = set() + assert service.find_live_attack(attack_result_id=ATTACK_ID) is None + + service._active_tasks[RUN_ID] = _ActiveTask(scenario_result_id=RUN_ID, scenario=scenario) + live = service.find_live_attack(attack_result_id=ATTACK_ID) + assert live is not None + assert (live.scenario_result_id, live.main_conversation_id) == (RUN_ID, "current") + assert service.find_live_attack(attack_result_id=str(uuid.uuid4())) is None + + scenario.active_executions = (_execution(conversation_ids=(), current=None),) + assert service.find_live_attack(attack_result_id=ATTACK_ID) is None + + scenario.active_executions = () + assert service.find_live_attack(attack_result_id=ATTACK_ID) is None + + +def test_module_lookup_does_not_construct_a_scenario_run_service() -> None: + with patch.object(scenario_run_service_module, "_service_instance", None): + assert find_live_attack(ATTACK_ID) is None + assert scenario_run_service_module._service_instance is None + + +def test_module_lookup_reads_the_current_scenario_run_service() -> None: + service = MagicMock(spec=ScenarioRunService) + service.find_live_attack.return_value = _live_attack() + with patch.object(scenario_run_service_module, "_service_instance", service): + assert find_live_attack(ATTACK_ID) is service.find_live_attack.return_value + assert service.find_live_attack.call_args.kwargs == {"attack_result_id": ATTACK_ID} + + +def test_attack_service_singleton_reads_running_attacks_through_the_scenario_lookup() -> None: + attack_service_module.get_attack_service.cache_clear() + try: + with patch.object(attack_service_module, "AttackService") as constructor: + attack_service_module.get_attack_service() + assert constructor.call_args.kwargs == {"live_attack_lookup": find_live_attack} + finally: + attack_service_module.get_attack_service.cache_clear() + + +@pytest.mark.usefixtures("patch_central_database") +async def test_attack_service_reads_running_attack_when_unsaved_async(sqlite_instance: SQLiteMemory) -> None: + service = _attack_service(memory=sqlite_instance, live=_live_attack()) + + summary = await service.get_attack_async(attack_result_id=ATTACK_ID) + metadata = await service.get_attack_metadata_async(attack_result_id=ATTACK_ID, conversation_id="first") + conversations = await service.get_conversations_async(attack_result_id=ATTACK_ID, limit=50) + + assert summary is not None and metadata is not None and conversations is not None + data = summary.model_dump(mode="json") + assert (data["in_progress"], data["execution_read_only"], data["outcome"]) == (True, True, "undetermined") + assert data["scenario_result_id"] == RUN_ID + assert data["related_conversation_ids"] == ["first"] + assert (metadata.in_progress, metadata.scenario_result_id) == (True, RUN_ID) + assert (metadata.related_conversation_count, metadata.selected_conversation_valid) == (1, True) + assert conversations.main_conversation_id == "current" + assert [item.conversation_id for item in conversations.conversations] == ["current", "first"] + with pytest.raises(ValueError, match="not part of attack"): + await service.get_conversation_messages_async(attack_result_id=ATTACK_ID, conversation_id="other") + + +@pytest.mark.usefixtures("patch_central_database") +async def test_attack_service_saved_result_wins_over_running_attack_async(sqlite_instance: SQLiteMemory) -> None: + saved = AttackResult( + attack_result_id=ATTACK_ID, + conversation_id="saved-main", + objective="Saved objective", + outcome=AttackOutcome.SUCCESS, + ) + await sqlite_instance.add_attack_results_to_memory_async(attack_results=[saved]) + service = _attack_service(memory=sqlite_instance, live=_live_attack()) + + summary = await service.get_attack_async(attack_result_id=ATTACK_ID) + metadata = await service.get_attack_metadata_async(attack_result_id=ATTACK_ID) + conversations = await service.get_conversations_async(attack_result_id=ATTACK_ID, limit=50) + + assert summary is not None and metadata is not None and conversations is not None + assert (summary.in_progress, summary.outcome, summary.conversation_id) == ( + False, + AttackOutcome.SUCCESS, + "saved-main", + ) + assert summary.scenario_result_id is None + assert (metadata.in_progress, metadata.conversation_id) == (False, "saved-main") + assert [item.conversation_id for item in conversations.conversations] == ["saved-main"] + + +@pytest.mark.usefixtures("patch_central_database") +@pytest.mark.parametrize("with_lookup", [True, False]) +async def test_attack_service_unknown_attack_is_not_found_async( + sqlite_instance: SQLiteMemory, with_lookup: bool +) -> None: + service = ( + _attack_service(memory=sqlite_instance, live=None) + if with_lookup + else AttackService(message_send_service=MagicMock(spec=MessageSendService), memory=sqlite_instance) + ) + assert await service.get_attack_async(attack_result_id=ATTACK_ID) is None + assert await service.get_attack_metadata_async(attack_result_id=ATTACK_ID) is None + assert await service.get_conversations_async(attack_result_id=ATTACK_ID) is None + assert await service.get_conversations_async(attack_result_id=ATTACK_ID, limit=50) is None + assert await service.get_conversation_messages_async(attack_result_id=ATTACK_ID, conversation_id="current") is None + assert ( + await service.get_conversation_messages_async(attack_result_id=ATTACK_ID, conversation_id="current", limit=100) + is None + ) + + +def test_attack_route_serializes_in_progress_attack(compatibility_headers: dict[str, str]) -> None: + summary = AttackSummary( + attack_result_id=ATTACK_ID, + conversation_id="current", + objective="Running", + in_progress=True, + execution_read_only=True, + scenario_result_id=RUN_ID, + ) + service = MagicMock(spec=AttackService) + service.get_attack_async = AsyncMock(return_value=summary) + with patch("pyrit.backend.routes.attacks.get_attack_service", return_value=service): + response = TestClient(app, headers=compatibility_headers).get(f"/api/attacks/{ATTACK_ID}") + assert response.status_code == 200 + body = response.json() + assert (body["in_progress"], body["scenario_result_id"], body["outcome"]) == (True, RUN_ID, "undetermined") + + +def test_attack_findings_route_returns_linked_findings(compatibility_headers: dict[str, str]) -> None: + finding = Finding(operation_id=uuid.uuid4(), title="Leak", severity="important") + listed = AttackFindingListResponse( + attack_result_id=uuid.UUID(ATTACK_ID), + items=[AttackFindingItem(**finding.model_dump(), operation_name="Red team", evidence_id=uuid.uuid4())], + ) + with patch.object( + OperationService, "list_attack_findings_async", new_callable=AsyncMock, return_value=listed + ) as list_findings: + response = TestClient(app, headers=compatibility_headers).get(f"/api/attacks/{ATTACK_ID}/findings") + assert response.status_code == 200 + assert response.json() == listed.model_dump(mode="json") + assert list_findings.call_args.args == (uuid.UUID(ATTACK_ID),) + + +def test_attack_findings_route_rejects_non_uuid_ids(compatibility_headers: dict[str, str]) -> None: + with patch.object(OperationService, "list_attack_findings_async", new_callable=AsyncMock) as list_findings: + response = TestClient(app, headers=compatibility_headers).get("/api/attacks/not-a-uuid/findings") + assert response.status_code == 422 + list_findings.assert_not_awaited() + + +@pytest.mark.usefixtures("patch_central_database") +async def test_list_attack_findings_names_operations_and_evidence_async(sqlite_instance: SQLiteMemory) -> None: + operation = await sqlite_instance.add_operation_async(Operation(name="Red team")) + finding = await sqlite_instance.add_finding_async( + finding=Finding(operation_id=operation.id, title="Leak", severity="critical") + ) + attack_result_id = uuid.uuid4() + evidence = await sqlite_instance.add_finding_evidence_async( + evidence=FindingEvidence(finding_id=finding.id, attack_result_id=attack_result_id) + ) + + listed = await OperationService().list_attack_findings_async(attack_result_id) + empty = await OperationService().list_attack_findings_async(uuid.uuid4()) + + assert listed.attack_result_id == attack_result_id + assert [item.model_dump(mode="json") for item in listed.items] == [ + {**finding.model_dump(mode="json"), "operation_name": "Red team", "evidence_id": str(evidence.id)} + ] + assert empty.items == [] diff --git a/tests/unit/backend/test_scenario_resume.py b/tests/unit/backend/test_scenario_resume.py index 0d4cb1ec1b..9db290614a 100644 --- a/tests/unit/backend/test_scenario_resume.py +++ b/tests/unit/backend/test_scenario_resume.py @@ -15,6 +15,7 @@ from pyrit.backend.models.attacks import ConversationMessagesResponse from pyrit.backend.services.attack_service import AttackService +from pyrit.backend.services.message_send_service import MessageSendService from pyrit.backend.services.scenario_run_service import ( _LAUNCH_REFERENCES_METADATA_KEY, _LAUNCH_REQUEST_FIELDS, @@ -342,6 +343,113 @@ async def build_async(self: Scenario, *, context: ScenarioContext) -> list[Atomi release.set() +async def test_running_scan_attack_reads_live_until_its_saved_result_wins_async( + resume_environment: tuple[ScenarioRunService, MockPromptTarget], +) -> None: + service, target = resume_environment + entered, release = asyncio.Event(), asyncio.Event() + conversations: list[str] = [] + original_send = target._send_prompt_to_target_async + + async def send_async(*, normalized_conversation: list[Message]) -> list[Message]: + conversations.append(normalized_conversation[-1].get_piece().conversation_id) + if len(conversations) == 2: + entered.set() + await release.wait() + return await original_send(normalized_conversation=normalized_conversation) + + async def build_async(self: Scenario, *, context: ScenarioContext) -> list[AtomicAttack]: + strategy = PromptSendingAttack( + objective_target=context.objective_target, + attack_scoring_config=AttackScoringConfig( + objective_scorer=SubStringScorer(substring="never matches the response") + ), + max_attempts_on_failure=1, + ) + return [ + AtomicAttack( + atomic_attack_name="live", + attack_technique=AttackTechnique(attack=strategy), + seed_groups=[context.seed_groups[0]], + memory_labels=context.memory_labels, + ) + ] + + attacks = AttackService( + message_send_service=MagicMock(spec=MessageSendService), + memory=service._memory, + live_attack_lookup=lambda attack_result_id: service.find_live_attack(attack_result_id=attack_result_id), + ) + with ( + patch.object(_OfflineResumeScenario, "_build_atomic_attacks_async", new=build_async), + patch.object(target, "_send_prompt_to_target_async", side_effect=send_async), + ): + response = await service.start_run_async( + request=RunScenarioRequest( + scenario_name=_SCENARIO_NAME, + target_name=_TARGET_NAME, + include_baseline=False, + max_concurrency=1, + max_retries=0, + ) + ) + run_id = response.scenario_result_id + try: + await asyncio.wait_for(entered.wait(), TEST_WATCHDOG_SECONDS) + first, current = conversations + progress = await service.get_run_progress_async(scenario_result_id=run_id, since=None, limit=500) + assert progress is not None + active = progress.active_conversations[0] + assert active.conversation_id == current + assert active.conversation_ids == [first, current] + attack_id = active.attack_result_id + + live = await attacks.get_attack_async(attack_result_id=attack_id) + assert live is not None + assert live.in_progress is True + assert live.execution_read_only is True + assert live.outcome == AttackOutcome.UNDETERMINED + assert live.scenario_result_id == run_id + assert live.objective == active.objective + assert live.conversation_id == current + assert live.related_conversation_ids == [first] + + metadata = await attacks.get_attack_metadata_async(attack_result_id=attack_id, conversation_id=first) + assert metadata is not None + assert (metadata.in_progress, metadata.scenario_result_id) == (True, run_id) + assert metadata.related_conversation_count == 1 + assert metadata.selected_conversation_valid is True + + unpaged = await attacks.get_conversations_async(attack_result_id=attack_id) + assert unpaged is not None and unpaged.main_conversation_id == current + assert {conversation.conversation_id for conversation in unpaged.conversations} == {first, current} + paged = await attacks.get_conversations_async(attack_result_id=attack_id, limit=50) + assert paged is not None + assert [conversation.conversation_id for conversation in paged.conversations] == [current, first] + + for limit in (None, 100): + messages = await attacks.get_conversation_messages_async( + attack_result_id=attack_id, conversation_id=first, limit=limit + ) + assert messages is not None + assert [message.role for message in messages.messages] == ["user", "assistant"] + with pytest.raises(ValueError, match="not part of attack"): + await attacks.get_conversation_messages_async( + attack_result_id=attack_id, conversation_id="unrelated", limit=100 + ) + + release.set() + await _wait_for_run_completion_async(service=service, scenario_result_id=run_id) + assert service.find_live_attack(attack_result_id=attack_id) is None + saved = await attacks.get_attack_async(attack_result_id=attack_id) + assert saved is not None + assert saved.in_progress is False + assert saved.scenario_result_id == run_id + assert saved.outcome == AttackOutcome.FAILURE + finally: + release.set() + + async def test_cancelled_provenance_preserves_mixed_outcome_denominators_and_resume_async( resume_environment: tuple[ScenarioRunService, MockPromptTarget], ) -> None: diff --git a/tests/unit/memory/memory_interface/test_interface_scenario_progress.py b/tests/unit/memory/memory_interface/test_interface_scenario_progress.py index 5169e3dcbb..54ffe4c103 100644 --- a/tests/unit/memory/memory_interface/test_interface_scenario_progress.py +++ b/tests/unit/memory/memory_interface/test_interface_scenario_progress.py @@ -83,6 +83,7 @@ async def test_scenario_progress_deltas_page_equal_timestamps_by_id( score = Score( score_value="true", score_type="true_false", + score_category=["jailbreak"], score_rationale="The objective was achieved.", scorer_class_identifier=ComponentIdentifier( class_name="TestScorer", @@ -112,6 +113,7 @@ async def test_scenario_progress_deltas_page_equal_timestamps_by_id( assert first_page[0].score is not None assert first_page[0].score.scorer_name == "TestScorer" assert first_page[0].score.score_rationale == "The objective was achieved." + assert first_page[0].score.score_category == ["jailbreak"] assert has_more is True assert [row.attack_result_id for row in second_page] == [str(second_id)] assert second_has_more is False @@ -151,6 +153,7 @@ async def test_scenario_progress_delta_uses_unknown_for_empty_scorer_identifier( assert has_more is False assert deltas[0].score is not None assert deltas[0].score.scorer_name == "Unknown" + assert deltas[0].score.score_category == [] async def test_scenario_result_header_does_not_hydrate_attack_results( diff --git a/tests/unit/memory/test_finding_evidence_attack_index_migration.py b/tests/unit/memory/test_finding_evidence_attack_index_migration.py new file mode 100644 index 0000000000..3bdfad7cf8 --- /dev/null +++ b/tests/unit/memory/test_finding_evidence_attack_index_migration.py @@ -0,0 +1,206 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import importlib +from datetime import UTC, datetime +from io import StringIO +from pathlib import Path +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from alembic import command +from alembic.config import Config +from alembic.migration import MigrationContext +from alembic.operations import Operations +from alembic.script import ScriptDirectory + +from pyrit.memory.memory_models import FindingEntry, FindingEvidenceEntry, OperationEntry +from pyrit.memory.migration import check_schema_migrations, run_schema_migrations + +PREVIOUS_REVISION = "e7c4a2d9f013" +REVISION = "b5d7f9a1c3e6" +HTTP_REVISION = "d6de1045cca4" +MERGE_REVISION = "f8a6c2d4e901" +INDEX = "ix_FindingEvidenceEntries_attack_result_id" + + +def _config(connection: sa.Connection | None = None) -> Config: + config = Config() + config.set_main_option("script_location", str(Path(__file__).resolve().parents[3] / "pyrit" / "memory" / "alembic")) + if connection is not None: + config.attributes["connection"] = connection + config.attributes["version_table"] = "pyrit_memory_alembic_version" + return config + + +def _evidence_indexes(connection: sa.Connection) -> dict[str, list[str]]: + return { + str(index["name"]): [str(column) for column in index["column_names"]] + for index in sa.inspect(connection).get_indexes("FindingEvidenceEntries") + } + + +def test_migration_branches_have_one_merge_head_without_rewriting_history() -> None: + scripts = ScriptDirectory.from_config(_config()) + assert scripts.get_heads() == [MERGE_REVISION] + assert set(scripts.get_revision(MERGE_REVISION).down_revision) == {REVISION, HTTP_REVISION} + assert scripts.get_revision(REVISION).down_revision == PREVIOUS_REVISION + assert scripts.get_revision(HTTP_REVISION).down_revision == PREVIOUS_REVISION + + +def test_migration_adds_and_drops_attack_result_index() -> None: + engine = sa.create_engine("sqlite:///:memory:") + try: + with engine.begin() as connection: + config = _config(connection) + command.upgrade(config, PREVIOUS_REVISION) + assert INDEX not in _evidence_indexes(connection) + command.upgrade(config, REVISION) + assert _evidence_indexes(connection)[INDEX] == ["attack_result_id"] + command.downgrade(config, PREVIOUS_REVISION) + assert INDEX not in _evidence_indexes(connection) + command.upgrade(config, "head") + check_schema_migrations(engine=engine) + finally: + engine.dispose() + + +def test_migration_compiles_for_sql_server() -> None: + migration = importlib.import_module( + "pyrit.memory.alembic.versions.b5d7f9a1c3e6_index_finding_evidence_attack_result" + ) + output = StringIO() + context = MigrationContext.configure(dialect_name="mssql", opts={"as_sql": True, "output_buffer": output}) + with Operations.context(context): + migration.upgrade() + assert f"CREATE INDEX [{INDEX}] ON [FindingEvidenceEntries] (attack_result_id)" in output.getvalue() + + +def test_model_declares_attack_result_index() -> None: + indexes = { + index.name: [column.name for column in index.columns] for index in FindingEvidenceEntry.__table__.indexes + } + assert indexes[INDEX] == ["attack_result_id"] + + +def _assert_merged_schema(connection: sa.Connection) -> None: + context = MigrationContext.configure(connection, opts={"version_table": "pyrit_memory_alembic_version"}) + assert context.get_current_heads() == (MERGE_REVISION,) + assert _evidence_indexes(connection)[INDEX] == ["attack_result_id"] + columns = {column["name"]: column for column in sa.inspect(connection).get_columns("TargetIdentifiers")} + assert columns["http_request_fingerprint"]["nullable"] is True + + +def _seed_upgrade_rows(connection: sa.Connection) -> None: + operation_id, finding_id = uuid4(), uuid4() + timestamp = datetime.now(UTC) + connection.execute( + sa.insert(OperationEntry).values( + id=operation_id, name="Preserved", name_key="preserved", created_at=timestamp, description="" + ) + ) + connection.execute( + sa.insert(FindingEntry).values( + id=finding_id, + operation_id=operation_id, + title="Preserved finding", + description="", + severity="important", + created_at=timestamp, + ) + ) + connection.execute( + sa.insert(FindingEvidenceEntry).values( + id=uuid4(), finding_id=finding_id, attack_result_id=uuid4(), attached_at=timestamp + ) + ) + targets = sa.Table("TargetIdentifiers", sa.MetaData(), autoload_with=connection) + connection.execute( + targets.insert().values(hash="a" * 64, class_name="HTTPTarget", identifier_json={"marker": "retained"}) + ) + + +@pytest.mark.parametrize( + "revisions", + [(PREVIOUS_REVISION,), (REVISION,), (HTTP_REVISION,), (REVISION, HTTP_REVISION)], + ids=["common-parent", "finding-index", "http-fingerprint", "both-branches"], +) +def test_startup_upgrades_either_migration_branch_without_losing_data(revisions: tuple[str, ...]) -> None: + engine = sa.create_engine("sqlite:///:memory:") + try: + with engine.begin() as connection: + config = _config(connection) + command.upgrade(config, PREVIOUS_REVISION) + _seed_upgrade_rows(connection) + evidence = dict(connection.execute(sa.select(FindingEvidenceEntry)).mappings().one()) + for revision in revisions: + command.upgrade(config, revision) + context = MigrationContext.configure(connection, opts={"version_table": "pyrit_memory_alembic_version"}) + assert set(context.get_current_heads()) == set(revisions) + fingerprint = "recorded-fingerprint" if HTTP_REVISION in revisions else None + if fingerprint is not None: + connection.execute( + sa.text("UPDATE TargetIdentifiers SET http_request_fingerprint = :value"), + {"value": fingerprint}, + ) + + run_schema_migrations(engine=engine, silent=True) + run_schema_migrations(engine=engine, silent=True) + with engine.connect() as connection: + _assert_merged_schema(connection) + assert dict(connection.execute(sa.select(FindingEvidenceEntry)).mappings().one()) == evidence + assert connection.scalar(sa.select(FindingEntry.title)) == "Preserved finding" + assert connection.scalar(sa.select(OperationEntry.name)) == "Preserved" + targets = sa.Table("TargetIdentifiers", sa.MetaData(), autoload_with=connection) + row = connection.execute(sa.select(targets)).mappings().one() + assert row["hash"] == "a" * 64 + assert row["identifier_json"] == {"marker": "retained"} + assert row["http_request_fingerprint"] == fingerprint + check_schema_migrations(engine=engine) + finally: + engine.dispose() + + +def test_startup_creates_merged_schema_from_empty_database() -> None: + engine = sa.create_engine("sqlite:///:memory:") + try: + run_schema_migrations(engine=engine, silent=True) + with engine.connect() as connection: + _assert_merged_schema(connection) + check_schema_migrations(engine=engine) + finally: + engine.dispose() + + +def test_merge_downgrades_both_branches_and_upgrades_again() -> None: + engine = sa.create_engine("sqlite:///:memory:") + try: + with engine.begin() as connection: + config = _config(connection) + command.upgrade(config, "head") + _assert_merged_schema(connection) + command.downgrade(config, PREVIOUS_REVISION) + assert INDEX not in _evidence_indexes(connection) + assert "http_request_fingerprint" not in { + column["name"] for column in sa.inspect(connection).get_columns("TargetIdentifiers") + } + run_schema_migrations(engine=engine, silent=True) + with engine.connect() as connection: + _assert_merged_schema(connection) + check_schema_migrations(engine=engine) + finally: + engine.dispose() + + +@pytest.mark.parametrize("dialect_name", ["sqlite", "mssql"]) +def test_merge_revision_adds_no_dialect_specific_ddl(dialect_name: str) -> None: + migration = importlib.import_module( + "pyrit.memory.alembic.versions.f8a6c2d4e901_merge_finding_index_and_http_fingerprint" + ) + output = StringIO() + context = MigrationContext.configure(dialect_name=dialect_name, opts={"as_sql": True, "output_buffer": output}) + with Operations.context(context): + migration.upgrade() + migration.downgrade() + assert output.getvalue() == "" diff --git a/tests/unit/memory/test_findings.py b/tests/unit/memory/test_findings.py index 94d45c0e7a..be2eccb636 100644 --- a/tests/unit/memory/test_findings.py +++ b/tests/unit/memory/test_findings.py @@ -411,6 +411,40 @@ async def test_one_attack_result_can_support_multiple_findings_and_counts(sqlite assert await sqlite_instance.get_finding_evidence_counts_async(finding_ids=[]) == {} +async def test_get_findings_for_attack_result_spans_operations_in_dashboard_order( + sqlite_instance: SQLiteMemory, +) -> None: + first = await sqlite_instance.add_operation_async(Operation(name="First")) + second = await sqlite_instance.add_operation_async(Operation(name="Second")) + low = await sqlite_instance.add_finding_async(finding=Finding(operation_id=first.id, title="Low", severity="low")) + critical = await sqlite_instance.add_finding_async( + finding=Finding(operation_id=second.id, title="Critical", severity="critical") + ) + unlinked = await sqlite_instance.add_finding_async( + finding=Finding(operation_id=first.id, title="Unlinked", severity="critical") + ) + attack_result_id, other_attack_id = uuid4(), uuid4() + evidence = { + finding.id: await sqlite_instance.add_finding_evidence_async( + evidence=FindingEvidence(finding_id=finding.id, attack_result_id=attack_result_id) + ) + for finding in (low, critical) + } + await sqlite_instance.add_finding_evidence_async( + evidence=FindingEvidence(finding_id=unlinked.id, attack_result_id=other_attack_id) + ) + + linked = await sqlite_instance.get_findings_for_attack_result_async(attack_result_id=attack_result_id) + + assert [(finding.id, item.id) for finding, item in linked] == [ + (critical.id, evidence[critical.id].id), + (low.id, evidence[low.id].id), + ] + assert {finding.operation_id for finding, _ in linked} == {first.id, second.id} + assert all(item.attack_result_id == attack_result_id for _, item in linked) + assert await sqlite_instance.get_findings_for_attack_result_async(attack_result_id=uuid4()) == [] + + async def test_get_findings_by_ids_follows_dashboard_order_and_scope(sqlite_instance: SQLiteMemory) -> None: operation = await sqlite_instance.add_operation_async(Operation(name="Selected")) other = await sqlite_instance.add_operation_async(Operation(name="Other"))