Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
77825c6
FEAT: Add ScenarioPreset model, storage, and registry
Copilot Sep 30, 2026
ed0fa4b
FIX: Harden preset storage names, versions, and registry reads
Copilot Oct 1, 2026
f15fc7a
REFACTOR: Defer built-in scenario presets and drop the preset registry
Copilot Oct 1, 2026
ac86b02
Merge branch 'main' into scenario-preset-model-and-storage
hannahwestra25 Oct 1, 2026
6107369
Merge branch 'main' into scenario-preset-model-and-storage
hannahwestra25 Oct 1, 2026
246a7c7
FIX: Skip unaddressable stored document names and harden preset docum…
Copilot Oct 1, 2026
3086224
FIX: Read stored documents as bytes and replace them atomically
Copilot Oct 2, 2026
cbe97dd
FEAT: Add scenario preset CRUD and launch-resolution API
Copilot Oct 2, 2026
78bcb94
Add scenario preset UI: library, editor, and launch dialog
Copilot Oct 2, 2026
dbd5197
Summarize preset configuration in the launch dialog
Copilot Oct 2, 2026
8e6b879
Share one conditional-write lock between document stores
Copilot Oct 7, 2026
d7e26df
Keep a preset tracking the scenario defaults it never pinned
Copilot Oct 7, 2026
e055160
Merge origin/main into scenario-preset-model-and-storage
Copilot Oct 7, 2026
b574cff
Hold presets to the same request limits a run is held to
Copilot Oct 7, 2026
cbf7a88
Compare technique selections as sets, not sequences
Copilot Oct 7, 2026
494512e
Let only one writer break a stale lock
Copilot Oct 7, 2026
1040707
Merge branch 'main' into scenario-preset-model-and-storage
hannahwestra25 Oct 7, 2026
838dd8b
Address review: technique tokens, OS locks, launch preconditions
Copilot Oct 8, 2026
8959c7b
Move scenario presets into the registry, with author and run size
Copilot Oct 8, 2026
ce20e1e
Merge branch 'main' into scenario-preset-model-and-storage
hannahwestra25 Oct 9, 2026
9a812aa
Fix registry layout clipping and remove technique tag buttons
Copilot Oct 9, 2026
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
6 changes: 6 additions & 0 deletions .pyrit_conf_example
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ allow_custom_initializers: false
# ~/.pyrit/custom_initializers.
# custom_initializers_source: https://account.blob.core.windows.net/container/custom_initializers

# Optional storage for scenario presets, the saved scenario configurations the
# scanner UI launches from. This may be a local directory or an Azure Blob
# container URI with an optional blob prefix. Container URIs may include a SAS;
# otherwise DefaultAzureCredential is used. Defaults to ~/.pyrit/scenario_presets.
# scenario_presets_source: https://account.blob.core.windows.net/container/scenario_presets

# Local Backend Server
# --------------------
# Client settings used by pyrit_scan when connecting to or launching a backend.
Expand Down
10 changes: 10 additions & 0 deletions doc/getting_started/pyrit_conf.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ custom_initializers_source: https://account.blob.core.windows.net/pyrit-storage/

With this configuration, PyRIT reads and writes scripts directly under the `custom-initializers/` prefix in the `pyrit-storage` container. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/custom_initializers`.

### `scenario_presets_source`

Stores scenario presets in a local directory or Azure Blob container. An Azure URI may include a blob-name prefix, which behaves like a folder:

```yaml
scenario_presets_source: https://account.blob.core.windows.net/pyrit-storage/scenario-presets
```

Point several deployments at the same container to share presets between them. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/scenario_presets`.

### `initialization_scripts`

Local paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory.
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,32 @@ jest.mock("./components/Scenarios/ScenarioCatalog", () => {
};
});

jest.mock("./components/ScenarioPresets/ScenarioPresetLibrary", () => {
const MockScenarioPresetLibrary = () => <div data-testid="scenario-preset-library" />;
MockScenarioPresetLibrary.displayName = "MockScenarioPresetLibrary";
return {
__esModule: true,
default: MockScenarioPresetLibrary,
};
});

jest.mock("./components/ScenarioPresets/ScenarioPresetEditor", () => {
const { useParams } = jest.requireActual("react-router");
const MockScenarioPresetEditor = ({ mode }: { mode: string }) => {
const { presetName } = useParams();
return (
<div data-testid="scenario-preset-editor" data-mode={mode}>
{presetName ?? ""}
</div>
);
};
MockScenarioPresetEditor.displayName = "MockScenarioPresetEditor";
return {
__esModule: true,
default: MockScenarioPresetEditor,
};
});

jest.mock("./components/Scenarios/ScenarioDetail", () => {
const MockScenarioDetail = ({
defaultObjectiveTarget,
Expand Down Expand Up @@ -604,6 +630,46 @@ describe("App", () => {
expect(screen.getByLabelText("Current URL")).toHaveTextContent(/^\/chat$/);
});

it("renders the preset library as a registry section", () => {
renderApp("/registry/scenario-presets");

expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-current-view",
"registry"
);
expect(screen.getByTestId("scenario-preset-library")).toBeInTheDocument();
});

it("redirects a bookmarked /scanner/presets to the registry section", async () => {
render(
<MemoryRouter initialEntries={["/scanner/presets"]}>
<App />
<RouterProbe />
</MemoryRouter>
);

expect(await screen.findByTestId("scenario-preset-library")).toBeInTheDocument();
expect(screen.getByLabelText("Current URL")).toHaveTextContent(
/^\/registry\/scenario-presets$/
);
});

it("redirects a bookmarked preset editor URL, keeping the preset it named", async () => {
render(
<MemoryRouter initialEntries={["/scanner/presets/nightly_probe/edit"]}>
<App />
<RouterProbe />
</MemoryRouter>
);

const editor = await screen.findByTestId("scenario-preset-editor");
expect(editor).toHaveAttribute("data-mode", "edit");
expect(editor).toHaveTextContent("nightly_probe");
expect(screen.getByLabelText("Current URL")).toHaveTextContent(
/^\/registry\/scenario-presets\/nightly_probe\/edit$/
);
});

it("renders the converter registry from its direct URL", async () => {
renderApp("/registry/converters");

Expand Down
24 changes: 24 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ 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 ScenarioPresetEditor from './components/ScenarioPresets/ScenarioPresetEditor'
import ScenarioPresetLibrary from './components/ScenarioPresets/ScenarioPresetLibrary'
import { presetEditorRoutePath } from './components/ScenarioPresets/presetRoutes'
import FeedbackDialog from './components/Feedback/FeedbackDialog'
import type { HistoryFilters } from './components/History/historyFilters'
import { ConnectionBanner } from './components/ConnectionBanner'
Expand Down Expand Up @@ -102,6 +105,11 @@ function LegacyScenarioRunRedirect() {
return <Navigate replace to={scenarioRunRoutePath(routerPathParamValue(scenarioResultId))} />
}

function LegacyPresetEditorRedirect() {
const { presetName } = useParams<{ presetName: string }>()
return <Navigate replace to={presetEditorRoutePath(routerPathParamValue(presetName))} />
}

function LegacyScenarioHistoryRedirect() {
const location = useLocation()
return <Navigate replace to={`${HISTORY_SCANNER_PATH}${location.search}`} />
Expand Down Expand Up @@ -730,9 +738,25 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) {
}
/>
<Route path="converters" element={<ConverterRegistry />} />
<Route
path="scenario-presets"
element={
<ScenarioPresetLibrary
targets={registry.targets}
defaultObjectiveTarget={targetDefaults.objectiveTarget}
defaultAdversarialTarget={targetDefaults.adversarialTarget}
labels={globalLabels}
/>
}
/>
<Route path="scenario-presets/new" element={<ScenarioPresetEditor mode="create" />} />
<Route path="scenario-presets/:presetName/edit" element={<ScenarioPresetEditor mode="edit" />} />
</Route>
<Route path="/targets" element={<Navigate to="/registry/targets" replace />} />
<Route path="/scanner" element={<ScenarioCatalog />} />
<Route path="/scanner/presets" element={<Navigate to="/registry/scenario-presets" replace />} />
<Route path="/scanner/presets/new" element={<Navigate to="/registry/scenario-presets/new" replace />} />
<Route path="/scanner/presets/:presetName/edit" element={<LegacyPresetEditorRedirect />} />
<Route
path="/scanner/:scenarioName"
element={
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/Registry/Registry.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export const useRegistryLayoutStyles = makeStyles({
display: 'flex',
flexDirection: 'column',
height: '100%',
// Without this the tall content of a nested route (the preset editor form)
// raises the automatic minimum size, so the layout cannot shrink to the
// space the labels bar leaves and its bottom is clipped by `main`.
minHeight: 0,
minWidth: 0,
},
tabs: {
Expand Down
22 changes: 21 additions & 1 deletion frontend/src/components/Registry/RegistryLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ function renderLayout(initialPath = '/registry/targets') {
<Route path="/registry" element={<RegistryLayout />}>
<Route path="targets" element={<div>Target registry content</div>} />
<Route path="converters" element={<div>Converter registry content</div>} />
<Route path="scenario-presets" element={<div>Preset library content</div>} />
<Route path="scenario-presets/new" element={<div>Preset editor content</div>} />
</Route>
</Routes>
</MemoryRouter>
Expand All @@ -21,11 +23,12 @@ function renderLayout(initialPath = '/registry/targets') {
}

describe('RegistryLayout', () => {
it('shows target and converter registry tabs', () => {
it('shows target, converter and preset registry tabs', () => {
renderLayout()

expect(screen.getByRole('tab', { name: 'Targets' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: 'Converters' })).toHaveAttribute('aria-selected', 'false')
expect(screen.getByRole('tab', { name: 'Scenario presets' })).toHaveAttribute('aria-selected', 'false')
expect(screen.getByText('Target registry content')).toBeInTheDocument()
})

Expand All @@ -38,4 +41,21 @@ describe('RegistryLayout', () => {
expect(screen.getByRole('tab', { name: 'Converters' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('Converter registry content')).toBeInTheDocument()
})

it('opens the preset library from its tab', async () => {
const user = userEvent.setup()
renderLayout()

await user.click(screen.getByRole('tab', { name: 'Scenario presets' }))

expect(screen.getByRole('tab', { name: 'Scenario presets' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('Preset library content')).toBeInTheDocument()
})

it('keeps the preset tab selected on a nested preset route', () => {
renderLayout('/registry/scenario-presets/new')

expect(screen.getByRole('tab', { name: 'Scenario presets' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('Preset editor content')).toBeInTheDocument()
})
})
17 changes: 16 additions & 1 deletion frontend/src/components/Registry/RegistryLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,25 @@ import { Outlet, useLocation, useNavigate } from 'react-router'

import { useRegistryLayoutStyles } from './Registry.styles'

const REGISTRY_TABS = ['targets', 'converters', 'scenario-presets'] as const

/**
* Keeps the tab selected while a section owns nested routes, which the preset
* editor does — `/registry/scenario-presets/new` is still the presets tab.
*/
function selectedTabFromPath(pathname: string): string {
return (
REGISTRY_TABS.find(
(tab) => pathname === `/registry/${tab}` || pathname.startsWith(`/registry/${tab}/`),
) ?? 'targets'
)
}

export default function RegistryLayout() {
const styles = useRegistryLayoutStyles()
const location = useLocation()
const navigate = useNavigate()
const selectedTab = location.pathname.endsWith('/converters') ? 'converters' : 'targets'
const selectedTab = selectedTabFromPath(location.pathname)

return (
<div className={styles.root}>
Expand All @@ -20,6 +34,7 @@ export default function RegistryLayout() {
>
<Tab value="targets">Targets</Tab>
<Tab value="converters">Converters</Tab>
<Tab value="scenario-presets">Scenario presets</Tab>
</TabList>
<div className={styles.content}>
<Outlet />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { makeStyles, tokens } from '@fluentui/react-components'

export const useLaunchPresetDialogStyles = makeStyles({
body: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalM,
},
summary: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalXXS,
},
scenarioLine: {
color: tokens.colorNeutralForeground3,
},
numberInput: {
maxWidth: '10rem',
},
})
Loading
Loading