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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/code/framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ If you are contributing to PyRIT, that work will most likely land in one of the
- `core` stays deliberately small so a default run doesn't print 200 techniques or take forever; the wider catalog lives in `extra` and is selected on demand. Users pick subsets by passing initializer tags (e.g. `core`, `extra`, `all`) or writing their own initializer, so different runs — including from the CLI — can register different technique sets without changing the catalog.
- A technique tied to one scenario is fine; if it's pinned and non-reusable it can stay local to that scenario, but if another scenario could reuse it, promote it to a catalog module and tag it.
- Tags describe a technique (behavioral tags like `single_turn`/`multi_turn`, owner tags like `airt`); they don't decide what a scenario runs. There is deliberately **no global `default` tag** — a default is scenario-relative, declared per scenario via `build_technique_class_from_factories` (the `factories` list is the pool, catalog tags become named aggregate presets, and `default_tags` / `default_names` set what runs when nothing is chosen).
- Factories opt into additive request-converter composition with `supports_additional_request_converters=True`. This is a semantic capability, not just constructor-signature detection; the factory validates that opted-in attacks accept `attack_converter_config`.
- **Does not own**: the conversation algorithm itself. Branching, turn management, and scoring decisions live in the executor it wraps — a technique only selects and configures existing components, and shouldn't implement new sending, scoring, or branching logic.

**Framework Plans**:
Expand Down
197 changes: 197 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ jest.mock("./components/Layout/MainLayout", () => {
<button onClick={() => onNavigate("history")} data-testid="nav-history">
History
</button>
<button onClick={() => onNavigate("scenarios")} data-testid="nav-scenarios">
Scenarios
</button>
{children}
</div>
);
Expand All @@ -121,6 +124,7 @@ jest.mock("./components/Layout/MainLayout", () => {
});

jest.mock("./components/Chat/ChatWindow", () => {
const { useLocation } = jest.requireActual("react-router") as typeof import("react-router");
const MockChatWindow = ({
onNewAttack,
activeTarget,
Expand All @@ -133,6 +137,7 @@ jest.mock("./components/Chat/ChatWindow", () => {
onConversationCreated,
onSelectConversation,
labels,
scenarioResultId,
}: {
onNewAttack: () => void;
activeTarget: unknown;
Expand All @@ -145,7 +150,9 @@ jest.mock("./components/Chat/ChatWindow", () => {
onConversationCreated: (attackResultId: string, conversationId: string) => void;
onSelectConversation: (convId: string) => void;
labels: Record<string, string>;
scenarioResultId?: string | null;
}) => {
const location = useLocation();
return (
<div data-testid="chat-window">
<span data-testid="attack-result-id">{attackResultId ?? "none"}</span>
Expand All @@ -159,6 +166,8 @@ jest.mock("./components/Chat/ChatWindow", () => {
<span data-testid="target-resolution-status">{targetResolutionStatus ?? "none"}</span>
<span data-testid="labels-operator">{labels.operator ?? ""}</span>
<span data-testid="labels-json">{JSON.stringify(labels)}</span>
<span data-testid="scenario-result-id">{scenarioResultId ?? "none"}</span>
<span data-testid="route-location">{`${location.pathname}${location.search}`}</span>
<button onClick={onNewAttack} data-testid="new-attack">
New Attack
</button>
Expand Down Expand Up @@ -315,6 +324,51 @@ jest.mock("./components/Home/Home", () => {
};
});

jest.mock("./components/Scenarios/ScenarioCatalog", () => {
const MockScenarioCatalog = () => <div data-testid="scenario-catalog" />;
MockScenarioCatalog.displayName = "MockScenarioCatalog";
return {
__esModule: true,
default: MockScenarioCatalog,
};
});

jest.mock("./components/Scenarios/ScenarioDetail", () => {
const MockScenarioDetail = ({
activeTarget,
labels,
onNavigate,
}: {
activeTarget: unknown;
labels: Record<string, string>;
onNavigate: (view: string) => void;
}) => {
return (
<div data-testid="scenario-detail">
<span data-testid="scenario-detail-has-target">{activeTarget ? "yes" : "no"}</span>
<span data-testid="scenario-detail-labels-json">{JSON.stringify(labels)}</span>
<button onClick={() => onNavigate("config")} data-testid="scenario-detail-go-config">
Configure target
</button>
</div>
);
};
MockScenarioDetail.displayName = "MockScenarioDetail";
return {
__esModule: true,
default: MockScenarioDetail,
};
});

jest.mock("./components/Scenarios/ScenarioRunPage", () => {
const MockScenarioRunPage = () => <div data-testid="scenario-run-page" />;
MockScenarioRunPage.displayName = "MockScenarioRunPage";
return {
__esModule: true,
default: MockScenarioRunPage,
};
});

describe("App", () => {
// App reads the active view from the URL, so every render needs a router.
// initialPath lets a test deep-link straight to a view (e.g. "/config").
Expand Down Expand Up @@ -375,6 +429,67 @@ describe("App", () => {
expect(screen.getByTestId("attack-history")).toBeInTheDocument();
});

it("renders the scenario catalog when deep-linked to /scenarios", () => {
renderApp("/scenarios");

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"scenarios"
);
expect(screen.getByTestId("scenario-catalog")).toBeInTheDocument();
});

it("renders the scenario detail view and marks the sidebar current when deep-linked to /scenarios/:name", () => {
renderApp("/scenarios/foundry.red_team_agent");

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"scenarios"
);
expect(screen.getByTestId("scenario-detail")).toBeInTheDocument();
});

it("renders the scenario run dashboard and marks the sidebar current when deep-linked to /scenario-history/:id", () => {
renderApp("/scenario-history/sr-123");

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"scenarios"
);
expect(screen.getByTestId("scenario-run-page")).toBeInTheDocument();
});

it("switches to the scenarios view via the sidebar", () => {
renderApp();

fireEvent.click(screen.getByTestId("nav-scenarios"));

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"scenarios"
);
expect(screen.getByTestId("scenario-catalog")).toBeInTheDocument();
});

it("passes the active target and labels to the scenario detail view", () => {
renderApp("/scenarios/foundry.red_team_agent");

expect(screen.getByTestId("scenario-detail-has-target")).toHaveTextContent("no");
expect(screen.getByTestId("scenario-detail-labels-json")).toHaveTextContent("operator");
});

it("navigates from scenario detail to config when it requests it", () => {
renderApp("/scenarios/foundry.red_team_agent");

fireEvent.click(screen.getByTestId("scenario-detail-go-config"));

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"config"
);
expect(screen.getByTestId("target-config")).toBeInTheDocument();
});

it("redirects an unknown path back to home", () => {
renderApp("/does-not-exist");

Expand Down Expand Up @@ -763,6 +878,70 @@ describe("App", () => {
expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main")
);
expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-main");
expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none");
});

it("hydrates validated scenario provenance on a direct attack reload", async () => {
const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000";
mockGetAttack.mockResolvedValue({
attack_result_id: "ar-1",
conversation_id: "conv-main",
labels: {},
related_conversation_ids: [],
});

renderApp(`/attacks/ar-1?scenarioResultId=${scenarioResultId}`);

await waitFor(() =>
expect(screen.getByTestId("scenario-result-id")).toHaveTextContent(scenarioResultId)
);
expect(screen.getByTestId("route-location")).toHaveTextContent(
`/attacks/ar-1?scenarioResultId=${scenarioResultId}`
);
});

it.each([
"/attacks/ar-1?scenarioResultId=run-1",
"/attacks/ar-1?scenarioResultId=https%3A%2F%2Fevil.example",
"/attacks/ar-1?scenarioResultId=123e4567-e89b-12d3-a456-426614174000&scenarioResultId=123e4567-e89b-12d3-a456-426614174000",
])("ignores unsafe or ambiguous scenario provenance on %s", async (path: string) => {
mockGetAttack.mockResolvedValue({
attack_result_id: "ar-1",
conversation_id: "conv-main",
labels: {},
related_conversation_ids: [],
});

renderApp(path);

await waitFor(() =>
expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main")
);
expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none");
});

it("preserves validated provenance within an attack and clears it for a new attack", async () => {
const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000";
mockGetAttack.mockResolvedValue({
attack_result_id: "ar-1",
conversation_id: "conv-main",
labels: {},
related_conversation_ids: ["conv-456"],
});
renderApp(`/attacks/ar-1?scenarioResultId=${scenarioResultId}`);
await waitFor(() =>
expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main")
);

fireEvent.click(screen.getByTestId("select-conversation"));
expect(screen.getByTestId("route-location")).toHaveTextContent(
`/attacks/ar-1/conversations/conv-456?scenarioResultId=${scenarioResultId}`
);
expect(screen.getByTestId("scenario-result-id")).toHaveTextContent(scenarioResultId);

fireEvent.click(screen.getByTestId("new-attack"));
expect(screen.getByTestId("route-location")).toHaveTextContent("/chat");
expect(screen.getByTestId("scenario-result-id")).toHaveTextContent("none");
});

it("uses the conversation from a deep link when it belongs to the attack", async () => {
Expand Down Expand Up @@ -794,6 +973,24 @@ describe("App", () => {
);
});

it("retains validated provenance while canonicalizing an unknown conversation route", async () => {
const scenarioResultId = "123e4567-e89b-12d3-a456-426614174000";
mockGetAttack.mockResolvedValue({
attack_result_id: "ar-1",
conversation_id: "conv-main",
labels: {},
related_conversation_ids: [],
});
renderApp(`/attacks/ar-1/conversations/bogus?scenarioResultId=${scenarioResultId}`);

await waitFor(() =>
expect(screen.getByTestId("route-location")).toHaveTextContent(
`/attacks/ar-1?scenarioResultId=${scenarioResultId}`
)
);
expect(screen.getByTestId("scenario-result-id")).toHaveTextContent(scenarioResultId);
});

it("hydrates history filters from the URL query string", () => {
renderApp("/history?outcome=success&attackType=PromptSendingAttack");

Expand Down
52 changes: 41 additions & 11 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import Home from './components/Home/Home'
import TargetConfig from './components/Config/TargetConfig'
import Initializers from './components/Initializers/Initializers'
import AttackHistory from './components/History/AttackHistory'
import ScenarioCatalog from './components/Scenarios/ScenarioCatalog'
import ScenarioDetail from './components/Scenarios/ScenarioDetail'
import ScenarioRunPage from './components/Scenarios/ScenarioRunPage'
import FeedbackDialog from './components/Feedback/FeedbackDialog'
import type { HistoryFilters } from './components/History/historyFilters'
import { ConnectionBanner } from './components/ConnectionBanner'
Expand All @@ -29,6 +32,11 @@ import {
import { attacksApi, versionApi } from './services/api'
import { toApiError } from './services/errors'
import { useTour } from './hooks/useTour'
import {
attackConversationRoutePath,
attackRoutePath,
scenarioRunProvenance,
} from './utils/routeParams'

const AUTO_DISMISS_MS = 5_000

Expand All @@ -39,10 +47,19 @@ const VIEW_PATHS: Record<ViewName, string> = {
history: '/history',
config: '/config',
initializers: '/initializers',
scenarios: '/scenarios',
}

/** Resolves the active view from a URL path, defaulting to home for unknown paths. */
/**
* Resolves the active view from a URL path, defaulting to home for unknown
* paths. Scenario routes are prefix-matched (`/scenarios/...` and
* `/scenario-history/...`) since they carry a path parameter rather than a
* single canonical `VIEW_PATHS` entry.
*/
function viewFromPath(pathname: string): ViewName {
if (pathname === VIEW_PATHS.scenarios || pathname.startsWith(`${VIEW_PATHS.scenarios}/`) || pathname.startsWith('/scenario-history/')) {
return 'scenarios'
}
const match = (Object.entries(VIEW_PATHS) as [ViewName, string][]).find(
([, path]) => path === pathname,
)
Expand All @@ -64,10 +81,6 @@ interface LoadedAttack {
status: AttackLoadStatus
}

const attackPath = (attackId: string) => `/attacks/${attackId}`
const conversationPath = (attackId: string, conversationId: string) =>
`/attacks/${attackId}/conversations/${conversationId}`

function ConnectionBannerContainer() {
const { status, reconnectCount } = useConnectionHealth()
// Track how many reconnects the user has already had the banner dismissed for.
Expand Down Expand Up @@ -112,6 +125,10 @@ function App() {
// the History nav button can restore filters after visiting another view.
const [searchParams, setSearchParams] = useSearchParams()
const historyFilters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams])
const scenarioResultId = useMemo(
() => scenarioRunProvenance(searchParams),
[searchParams],
)
const lastHistorySearch = useRef('')
useEffect(() => {
if (location.pathname === VIEW_PATHS.history) {
Expand Down Expand Up @@ -271,10 +288,10 @@ function App() {
routeConversationId === readyAttack.mainConversationId ||
readyAttack.relatedConversationIds.includes(routeConversationId)
if (!isKnown) {
navigate(attackPath(readyAttack.id), { replace: true })
navigate(attackRoutePath(readyAttack.id, scenarioResultId), { replace: true })
}
}
}, [readyAttack, routeConversationId, navigate])
}, [readyAttack, routeConversationId, navigate, scenarioResultId])

const handleNavigate = useCallback((view: ViewName) => {
// Re-attach the last filter query so returning to history restores filters.
Expand Down Expand Up @@ -322,16 +339,16 @@ function App() {
})
// Replace when promoting an empty /chat to its attack url (first message);
// push when branching from an existing attack so Back returns to the source.
navigate(attackPath(arId), { replace: routeAttackId === null })
navigate(attackRoutePath(arId), { replace: routeAttackId === null })
}, [activeTarget, handleSetActiveTarget, routeAttackId, navigate])

const handleSelectConversation = useCallback((convId: string) => {
if (!routeAttackId) return
navigate(conversationPath(routeAttackId, convId))
}, [routeAttackId, navigate])
navigate(attackConversationRoutePath(routeAttackId, convId, scenarioResultId))
}, [routeAttackId, navigate, scenarioResultId])

const handleOpenAttack = useCallback((openAttackResultId: string) => {
navigate(attackPath(openAttackResultId))
navigate(attackRoutePath(openAttackResultId))
}, [navigate])

const chatElement = isAttackNotFound || isAttackError ? (
Expand Down Expand Up @@ -359,6 +376,7 @@ function App() {
onRetryTargetResolution={retryTargetResolution}
isLoadingAttack={isLoadingAttack}
relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0}
scenarioResultId={readyAttack ? scenarioResultId : null}
/>
)

Expand Down Expand Up @@ -418,6 +436,18 @@ function App() {
}
/>
<Route path="/initializers" element={<Initializers />} />
<Route path="/scenarios" element={<ScenarioCatalog />} />
<Route
path="/scenarios/:scenarioName"
element={
<ScenarioDetail
activeTarget={activeTarget}
labels={globalLabels}
onNavigate={handleNavigate}
/>
}
/>
<Route path="/scenario-history/:scenarioResultId" element={<ScenarioRunPage />} />
<Route
path="/history"
element={
Expand Down
Loading