diff --git a/frontend/src/components/ScenarioPresets/LaunchPresetDialog.styles.ts b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.styles.ts
new file mode 100644
index 0000000000..7f11684d49
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.styles.ts
@@ -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',
+ },
+})
diff --git a/frontend/src/components/ScenarioPresets/LaunchPresetDialog.test.tsx b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.test.tsx
new file mode 100644
index 0000000000..4b1d7f0cbf
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.test.tsx
@@ -0,0 +1,308 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter } from 'react-router'
+
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import { makeTarget } from '@/test-utils/targetFixtures'
+import type { ScenarioPreset, TargetInstance } from '@/types'
+
+import LaunchPresetDialog from './LaunchPresetDialog'
+
+jest.mock('@/services/api', () => ({
+ scenarioPresetsApi: {
+ resolve: jest.fn(),
+ },
+ scenariosApi: {
+ startRun: jest.fn(),
+ },
+}))
+
+const mockResolve = scenarioPresetsApi.resolve as jest.Mock
+const mockStartRun = scenariosApi.startRun as jest.Mock
+
+const mockNavigate = jest.fn()
+
+jest.mock('react-router', () => ({
+ ...jest.requireActual('react-router'),
+ useNavigate: () => mockNavigate,
+}))
+
+const PRESET: ScenarioPreset = {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ techniques: ['crescendo'],
+}
+
+const OBJECTIVE_TARGET = makeTarget({ target_registry_name: 'gpt4o' })
+const ADVERSARIAL_TARGET: TargetInstance = makeTarget({
+ target_registry_name: 'adversary',
+ capabilities: {
+ supports_multi_turn: true,
+ supports_json_schema: false,
+ supports_json_output: false,
+ supports_system_prompt: true,
+ supported_input_modalities: ['text'],
+ supported_output_modalities: ['text'],
+ },
+})
+
+interface RenderOptions {
+ preset?: ScenarioPreset
+ version?: string
+ defaultObjectiveTarget?: TargetInstance | null
+ defaultAdversarialTarget?: TargetInstance | null
+ labels?: Record
+}
+
+const onDismiss = jest.fn()
+const onPresetChanged = jest.fn()
+
+function renderDialog({
+ preset = PRESET,
+ version = 'v1',
+ defaultObjectiveTarget = OBJECTIVE_TARGET,
+ defaultAdversarialTarget = null,
+ labels = {},
+}: RenderOptions = {}) {
+ return render(
+
+
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockResolve.mockResolvedValue({ scenario_name: 'foundry.red_team_agent', target_name: 'gpt4o' })
+ mockStartRun.mockResolvedValue({ scenario_result_id: 'run-1' })
+})
+
+describe('LaunchPresetDialog', () => {
+ it('reports scenario defaults for the fields the preset leaves unset', () => {
+ renderDialog()
+
+ expect(screen.getByTestId('launch-preset-summary')).toHaveTextContent(
+ '1 technique · scenario default datasets',
+ )
+ })
+
+ it('summarizes the configuration the preset pins', () => {
+ renderDialog({
+ preset: {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ techniques: ['crescendo', 'flip'],
+ dataset_names: ['harmbench', 'advbench'],
+ max_dataset_size: 25,
+ include_baseline: true,
+ },
+ })
+
+ expect(screen.getByTestId('launch-preset-summary')).toHaveTextContent(
+ '2 techniques · harmbench, advbench · max dataset size 25 · baseline included',
+ )
+ })
+
+ it('distinguishes an excluded baseline from an included one', () => {
+ renderDialog({ preset: { ...PRESET, include_baseline: false } })
+
+ expect(screen.getByTestId('launch-preset-summary')).toHaveTextContent('baseline excluded')
+ })
+
+ it('resolves the preset server-side and navigates to the started run', async () => {
+ const user = userEvent.setup()
+ renderDialog({ labels: { op: 'nightly' } })
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith('nightly_probe', {
+ expected_version: 'v1',
+ target_name: 'gpt4o',
+ max_concurrency: 10,
+ max_retries: 0,
+ labels: { op: 'nightly' },
+ }))
+ expect(mockStartRun).toHaveBeenCalledWith({
+ scenario_name: 'foundry.red_team_agent',
+ target_name: 'gpt4o',
+ })
+ expect(mockNavigate).toHaveBeenCalledWith('/scanner-history/run-1', {
+ state: { scenarioName: 'foundry.red_team_agent' },
+ })
+ })
+
+ it('omits the adversarial target and labels when neither is set', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith('nightly_probe', {
+ expected_version: 'v1',
+ target_name: 'gpt4o',
+ max_concurrency: 10,
+ max_retries: 0,
+ }))
+ })
+
+ it('hands a preset edited mid-launch back to the library instead of running it', async () => {
+ const user = userEvent.setup()
+ mockResolve.mockRejectedValue({
+ isAxiosError: true,
+ response: { status: 409, data: { detail: "Scenario preset 'nightly_probe' changed since it was read" } },
+ })
+
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(onPresetChanged).toHaveBeenCalled())
+ expect(mockStartRun).not.toHaveBeenCalled()
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ it('sends the default adversarial target when one is configured', async () => {
+ const user = userEvent.setup()
+ renderDialog({ defaultAdversarialTarget: ADVERSARIAL_TARGET })
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ adversarial_target_name: 'adversary' }),
+ ))
+ })
+
+ it('blocks launching until a target is chosen', () => {
+ renderDialog({ defaultObjectiveTarget: null })
+
+ expect(screen.getByTestId('confirm-launch-preset')).toBeDisabled()
+ })
+
+ it('ignores a default target that this deployment does not have', () => {
+ renderDialog({ defaultObjectiveTarget: makeTarget({ target_registry_name: 'retired' }) })
+
+ expect(screen.getByTestId('confirm-launch-preset')).toBeDisabled()
+ })
+
+ it('reports a resolve failure and does not start a run', async () => {
+ const user = userEvent.setup()
+ mockResolve.mockRejectedValue(new Error('Unknown scenario "foundry.retired".'))
+
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ expect(await screen.findByText('Unknown scenario "foundry.retired".')).toBeInTheDocument()
+ expect(mockStartRun).not.toHaveBeenCalled()
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ it('re-enables launch after a failure so the operator can retry', async () => {
+ const user = userEvent.setup()
+ mockStartRun.mockRejectedValueOnce(new Error('Target is unavailable.'))
+
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ expect(await screen.findByText('Target is unavailable.')).toBeInTheDocument()
+ expect(screen.getByTestId('confirm-launch-preset')).toBeEnabled()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled())
+ })
+
+ it('dismisses without launching', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.click(screen.getByRole('button', { name: 'Cancel' }))
+
+ expect(onDismiss).toHaveBeenCalled()
+ expect(mockResolve).not.toHaveBeenCalled()
+ })
+
+ it('dismisses when the dialog itself is closed', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.keyboard('{Escape}')
+
+ expect(onDismiss).toHaveBeenCalled()
+ })
+
+ it('sends the target the operator picked instead of the default', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.selectOptions(screen.getByRole('combobox', { name: 'Target' }), 'adversary')
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ target_name: 'adversary' }),
+ ))
+ })
+
+ it('sends the adversarial target the operator picked', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ await user.selectOptions(screen.getByRole('combobox', { name: 'Adversarial Target' }), 'adversary')
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ adversarial_target_name: 'adversary' }),
+ ))
+ })
+
+ it('sends the concurrency and retry limits the operator set', async () => {
+ const user = userEvent.setup()
+ renderDialog()
+
+ const concurrency = screen.getByTestId('preset-max-concurrency-input')
+ await user.clear(concurrency)
+ await user.type(concurrency, '4')
+ const retries = screen.getByTestId('preset-max-retries-input')
+ await user.clear(retries)
+ await user.type(retries, '2')
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+
+ await waitFor(() => expect(mockResolve).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ max_concurrency: 4, max_retries: 2 }),
+ ))
+ })
+
+ it('does not resolve twice while a launch is in flight', async () => {
+ const user = userEvent.setup()
+ let release: () => void = () => {}
+ mockResolve.mockReturnValue(new Promise((resolve) => {
+ release = () => resolve({ scenario_name: 'foundry.red_team_agent', target_name: 'gpt4o' })
+ }))
+
+ renderDialog()
+
+ await user.click(screen.getByTestId('confirm-launch-preset'))
+ expect(screen.getByTestId('confirm-launch-preset')).toBeDisabled()
+
+ release()
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled())
+ expect(mockResolve).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/LaunchPresetDialog.tsx b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.tsx
new file mode 100644
index 0000000000..7a36ce9439
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/LaunchPresetDialog.tsx
@@ -0,0 +1,235 @@
+import { type FormEvent, useState } from 'react'
+
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogBody,
+ DialogContent,
+ DialogSurface,
+ DialogTitle,
+ Field,
+ MessageBar,
+ MessageBarBody,
+ Text,
+} from '@fluentui/react-components'
+import { useNavigate } from 'react-router'
+
+import TargetSelect from '@/components/Config/TargetSelect'
+import SingleStepSpinButton from '@/components/Parameters/SingleStepSpinButton'
+import {
+ DEFAULT_MAX_CONCURRENCY,
+ DEFAULT_MAX_RETRIES,
+ MAX_MAX_CONCURRENCY,
+ MAX_MAX_RETRIES,
+ MIN_MAX_CONCURRENCY,
+ MIN_MAX_RETRIES,
+ resolveSpinButtonValue,
+} from '@/components/Scenarios/scenarioRunLimits'
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { ScenarioPreset, TargetInstance } from '@/types'
+import { scenarioRunRoutePath } from '@/utils/routeParams'
+
+import { useLaunchPresetDialogStyles } from './LaunchPresetDialog.styles'
+
+interface LaunchPresetDialogProps {
+ preset: ScenarioPreset
+ version: string
+ targets: TargetInstance[]
+ defaultObjectiveTarget: TargetInstance | null
+ defaultAdversarialTarget: TargetInstance | null
+ labels: Record
+ onDismiss: () => void
+ onPresetChanged: () => void
+}
+
+function initialTargetName(
+ candidate: TargetInstance | null,
+ available: TargetInstance[],
+): string {
+ if (!candidate) {
+ return ''
+ }
+ const match = available.some(
+ (target) => target.target_registry_name === candidate.target_registry_name,
+ )
+ return match ? candidate.target_registry_name : ''
+}
+
+/**
+ * Restates what the preset pins. The dialog covers the library card, so without
+ * this the operator commits to a run knowing only the preset and scenario name.
+ * Omitted fields read as scenario defaults because that is what the server does
+ * with them.
+ */
+function presetSummary(preset: ScenarioPreset): string {
+ const techniques = preset.techniques ?? []
+ const datasets = preset.dataset_names ?? []
+ const parts = [
+ techniques.length === 0
+ ? 'Scenario default techniques'
+ : `${techniques.length} technique${techniques.length === 1 ? '' : 's'}`,
+ datasets.length === 0 ? 'scenario default datasets' : datasets.join(', '),
+ ]
+ if (preset.max_dataset_size != null) {
+ parts.push(`max dataset size ${preset.max_dataset_size}`)
+ }
+ if (preset.include_baseline != null) {
+ parts.push(preset.include_baseline ? 'baseline included' : 'baseline excluded')
+ }
+ return parts.join(' · ')
+}
+
+/**
+ * Collects the launch-owned fields a preset deliberately omits, then asks the
+ * server to merge them with the stored preset. Resolution stays server-side so
+ * the browser never reimplements the preset-to-run mapping.
+ *
+ * The summary above describes the preset as it was read, so the launch sends that
+ * version back. An edit landing in between is reported rather than launched, which
+ * is the one case where a run would otherwise differ from what was confirmed.
+ */
+export default function LaunchPresetDialog({
+ preset,
+ version,
+ targets,
+ defaultObjectiveTarget,
+ defaultAdversarialTarget,
+ labels,
+ onDismiss,
+ onPresetChanged,
+}: LaunchPresetDialogProps) {
+ const styles = useLaunchPresetDialogStyles()
+ const navigate = useNavigate()
+ const adversarialTargets = targets.filter(
+ (target) => target.capabilities?.supports_multi_turn === true,
+ )
+ const [targetName, setTargetName] = useState(
+ () => initialTargetName(defaultObjectiveTarget, targets),
+ )
+ const [adversarialTargetName, setAdversarialTargetName] = useState(
+ () => initialTargetName(defaultAdversarialTarget, adversarialTargets),
+ )
+ const [maxConcurrency, setMaxConcurrency] = useState(DEFAULT_MAX_CONCURRENCY)
+ const [maxRetries, setMaxRetries] = useState(DEFAULT_MAX_RETRIES)
+ const [submitting, setSubmitting] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault()
+ if (targetName === '' || submitting) {
+ return
+ }
+ setSubmitting(true)
+ setError(null)
+ try {
+ const request = await scenarioPresetsApi.resolve(preset.name, {
+ expected_version: version,
+ target_name: targetName,
+ ...(adversarialTargetName === '' ? {} : { adversarial_target_name: adversarialTargetName }),
+ max_concurrency: maxConcurrency,
+ max_retries: maxRetries,
+ ...(Object.keys(labels).length > 0 ? { labels } : {}),
+ })
+ const summary = await scenariosApi.startRun(request)
+ navigate(scenarioRunRoutePath(summary.scenario_result_id), {
+ state: { scenarioName: preset.scenario_name },
+ })
+ } catch (err) {
+ const apiError = toApiError(err)
+ if (apiError.status === 409) {
+ // The summary on screen no longer describes the preset, so re-reading is the
+ // only way forward; keeping the dialog open would invite a confirm-and-retry loop.
+ onPresetChanged()
+ return
+ }
+ setError(apiError.detail)
+ setSubmitting(false)
+ }
+ }
+
+ return (
+ { if (!data.open) onDismiss() }}>
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.styles.ts b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.styles.ts
new file mode 100644
index 0000000000..d48adda1f6
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.styles.ts
@@ -0,0 +1,70 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import { mobileTouchTarget, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets'
+import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground'
+
+export const useScenarioPresetEditorStyles = makeStyles({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ width: '100%',
+ minWidth: 0,
+ padding: tokens.spacingVerticalXXL,
+ overflowX: 'hidden',
+ overflowY: 'auto',
+ backgroundColor: WORKSPACE_CANVAS_BACKGROUND,
+ [NARROW_VIEWPORT_QUERY]: {
+ padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`,
+ },
+ },
+ headerText: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ marginBottom: tokens.spacingVerticalL,
+ },
+ subtitle: {
+ color: tokens.colorNeutralForeground3,
+ },
+ form: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalL,
+ maxWidth: '60rem',
+ },
+ section: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingVerticalL,
+ borderRadius: tokens.borderRadiusMedium,
+ border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke2}`,
+ backgroundColor: tokens.colorNeutralBackground1,
+ },
+ control: {
+ maxWidth: '32rem',
+ },
+ dynamicParameters: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ },
+ actions: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalS,
+ alignItems: 'center',
+ },
+ touchTarget: {
+ ...mobileTouchTarget,
+ },
+ centeredState: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingVerticalXXL,
+ textAlign: 'center',
+ },
+})
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.test.tsx b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.test.tsx
new file mode 100644
index 0000000000..2d919608c8
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.test.tsx
@@ -0,0 +1,524 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter, Route, Routes } from 'react-router'
+
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import { makeScenario } from '@/test-utils/scenarioFixtures'
+import type { ScenarioPreset } from '@/types'
+
+import ScenarioPresetEditor from './ScenarioPresetEditor'
+
+jest.mock('@/services/api', () => ({
+ scenarioPresetsApi: {
+ get: jest.fn(),
+ create: jest.fn(),
+ update: jest.fn(),
+ },
+ scenariosApi: {
+ listCatalog: jest.fn(),
+ getScenario: jest.fn(),
+ },
+}))
+
+const mockGet = scenarioPresetsApi.get as jest.Mock
+const mockCreate = scenarioPresetsApi.create as jest.Mock
+const mockUpdate = scenarioPresetsApi.update as jest.Mock
+const mockListCatalog = scenariosApi.listCatalog as jest.Mock
+const mockGetScenario = scenariosApi.getScenario as jest.Mock
+
+const mockNavigate = jest.fn()
+
+jest.mock('react-router', () => ({
+ ...jest.requireActual('react-router'),
+ useNavigate: () => mockNavigate,
+}))
+
+const SCENARIO = makeScenario()
+
+const STORED_PRESET: ScenarioPreset = {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ description: 'Nightly smoke test.',
+ techniques: ['crescendo'],
+ include_baseline: false,
+}
+
+function apiError(status: number, detail: string): unknown {
+ return {
+ isAxiosError: true,
+ response: { status, data: { detail } },
+ }
+}
+
+function renderCreate() {
+ return render(
+
+
+
+ } />
+
+
+ ,
+ )
+}
+
+function renderEdit(name = 'nightly_probe') {
+ return render(
+
+
+
+ }
+ />
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockListCatalog.mockResolvedValue({
+ items: [SCENARIO],
+ pagination: { has_more: false, next_cursor: null },
+ })
+ mockGetScenario.mockResolvedValue(SCENARIO)
+ mockGet.mockResolvedValue({ preset: STORED_PRESET, version: 'v1', issues: [] })
+ mockCreate.mockResolvedValue({ preset: STORED_PRESET, version: 'v1', issues: [] })
+ mockUpdate.mockResolvedValue({ preset: STORED_PRESET, version: 'v2', issues: [] })
+})
+
+describe('ScenarioPresetEditor create mode', () => {
+ it('cannot save before a scenario is chosen', async () => {
+ renderCreate()
+
+ expect(await screen.findByTestId('scenario-preset-editor')).toBeInTheDocument()
+ expect(screen.getByTestId('save-preset-btn')).toBeDisabled()
+ })
+
+ it('creates a preset that pins nothing the operator left at the scenario default', async () => {
+ const user = userEvent.setup()
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockCreate).toHaveBeenCalledWith({
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ }))
+ expect(mockUpdate).not.toHaveBeenCalled()
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets')
+ })
+
+ it('pins a technique selection the operator moved off the scenario default', async () => {
+ const user = userEvent.setup()
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await user.click(await screen.findByTestId('technique-crescendo'))
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockCreate).toHaveBeenCalledWith({
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ techniques: ['default_technique', 'crescendo'],
+ }))
+ })
+
+ it('rejects a name the server pattern would reject, without calling the API', async () => {
+ const user = userEvent.setup()
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'Nightly-Probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ expect(await screen.findByText(/Use lowercase letters/)).toBeInTheDocument()
+ expect(mockCreate).not.toHaveBeenCalled()
+ })
+
+ it('reports a duplicate name returned by the server', async () => {
+ const user = userEvent.setup()
+ mockCreate.mockRejectedValue(apiError(409, 'A preset named "nightly_probe" already exists.'))
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ expect(
+ await screen.findByText('A preset named "nightly_probe" already exists.'),
+ ).toBeInTheDocument()
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ it('reports a missing admin permission', async () => {
+ const user = userEvent.setup()
+ mockCreate.mockRejectedValue(apiError(403, 'Admin access required.'))
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ expect(await screen.findByText('Admin access required.')).toBeInTheDocument()
+ })
+
+ it('reports an unselectable technique set rather than saving an empty one', async () => {
+ const user = userEvent.setup()
+ mockGetScenario.mockResolvedValue(makeScenario({
+ default_techniques: [],
+ default_technique: 'all',
+ aggregate_techniques: ['all'],
+ }))
+ renderCreate()
+
+ await user.type(await screen.findByTestId('preset-name-input'), 'nightly_probe')
+ await user.click(screen.getByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ await waitFor(() => expect(screen.getByTestId('save-preset-btn')).toBeEnabled())
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ expect(await screen.findByText('Select at least one technique.')).toBeInTheDocument()
+ expect(mockCreate).not.toHaveBeenCalled()
+ })
+
+ it('reports a failure to load the scenario the operator picked', async () => {
+ const user = userEvent.setup()
+ mockGetScenario.mockRejectedValue(apiError(503, 'Scenario registry is unavailable.'))
+ renderCreate()
+
+ await user.click(await screen.findByTestId('preset-scenario-select'))
+ await user.click(await screen.findByRole('option', { name: 'foundry.red_team_agent' }))
+
+ expect(await screen.findByText('Scenario registry is unavailable.')).toBeInTheDocument()
+ expect(screen.getByTestId('save-preset-btn')).toBeDisabled()
+ })
+})
+
+describe('ScenarioPresetEditor edit mode', () => {
+ it('loads the stored preset and pins its name and scenario', async () => {
+ renderEdit()
+
+ expect(await screen.findByTestId('scenario-preset-editor')).toBeInTheDocument()
+ expect(screen.getByTestId('preset-name-input')).toBeDisabled()
+ expect(screen.getByTestId('preset-name-input')).toHaveValue('nightly_probe')
+ expect(screen.getByTestId('preset-description-input')).toHaveValue('Nightly smoke test.')
+ })
+
+ it('updates with the version it read so a concurrent edit is not overwritten', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await user.click(await screen.findByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ name: 'nightly_probe', techniques: ['crescendo'] }),
+ 'v1',
+ ))
+ expect(mockCreate).not.toHaveBeenCalled()
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets')
+ })
+
+ it('reports a version conflict instead of navigating away', async () => {
+ const user = userEvent.setup()
+ mockUpdate.mockRejectedValue(apiError(409, 'The preset changed since it was loaded.'))
+ renderEdit()
+
+ await user.click(await screen.findByTestId('save-preset-btn'))
+
+ expect(await screen.findByText('The preset changed since it was loaded.')).toBeInTheDocument()
+ expect(mockNavigate).not.toHaveBeenCalled()
+ })
+
+ it('keeps pinned techniques this deployment does not offer instead of rewriting them', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, techniques: ['crescendo', 'retired_attack'] },
+ version: 'v1',
+ issues: [],
+ })
+
+ renderEdit()
+
+ expect(await screen.findByTestId('dropped-techniques-warning')).toHaveTextContent(
+ 'retired_attack',
+ )
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ techniques: ['crescendo', 'retired_attack'] }),
+ 'v1',
+ ))
+ })
+
+ it('keeps a converter-qualified technique through an unrelated edit', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, techniques: ['crescendo:converter.translation_spanish'] },
+ version: 'v1',
+ issues: [],
+ })
+
+ renderEdit()
+
+ await user.type(await screen.findByTestId('preset-description-input'), ' Updated.')
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ techniques: ['crescendo:converter.translation_spanish'] }),
+ 'v1',
+ ))
+ })
+
+ it('shows a pinned aggregate technique as a tag the operator can remove', async () => {
+ const user = userEvent.setup()
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, techniques: ['all'] },
+ version: 'v1',
+ issues: [],
+ })
+
+ renderEdit()
+
+ expect(await screen.findByTestId('techniques-without-checkbox')).toHaveTextContent('all')
+ expect(screen.getByTestId('technique-crescendo')).not.toBeChecked()
+ expect(screen.queryByTestId('dropped-techniques-warning')).not.toBeInTheDocument()
+
+ await user.click(screen.getByTestId('technique-crescendo'))
+ await user.click(screen.getByRole('button', { name: 'Remove all' }))
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ techniques: ['crescendo'] }),
+ 'v1',
+ ))
+ })
+
+ it('reports a missing preset as not found', async () => {
+ mockGet.mockRejectedValue(apiError(404, 'No such preset.'))
+
+ renderEdit()
+
+ expect(await screen.findByTestId('editor-error-state')).toHaveTextContent(
+ 'No preset named "nightly_probe"',
+ )
+ })
+
+ it('reports an unavailable scenario as unavailable, not as a missing preset', async () => {
+ mockGetScenario.mockRejectedValue(apiError(404, 'Unknown scenario "foundry.red_team_agent".'))
+
+ renderEdit()
+
+ expect(await screen.findByTestId('scenario-unavailable-warning')).toBeInTheDocument()
+ expect(screen.queryByTestId('editor-error-state')).not.toBeInTheDocument()
+ expect(screen.getByTestId('save-preset-btn')).toBeDisabled()
+ })
+
+ it('decodes a preset name that was escaped into the route', async () => {
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, name: 'a_b' },
+ version: 'v1',
+ issues: [],
+ })
+
+ renderEdit('a_b')
+
+ await waitFor(() => expect(mockGet).toHaveBeenCalledWith('a_b'))
+ })
+
+ it('saves the edited description', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ const description = await screen.findByTestId('preset-description-input')
+ await user.clear(description)
+ await user.type(description, 'Weekly smoke test.')
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ description: 'Weekly smoke test.' }),
+ 'v1',
+ ))
+ })
+
+ it('saves the techniques and baseline choice the operator changed', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await user.click(await screen.findByTestId('technique-default_technique'))
+ await user.click(screen.getByTestId('baseline-checkbox'))
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({
+ techniques: ['crescendo', 'default_technique'],
+ include_baseline: true,
+ }),
+ 'v1',
+ ))
+ })
+
+ it('saves the dataset overrides the operator entered', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await user.type(await screen.findByTestId('dataset-override-input'), 'harmbench, xstest')
+ await user.type(screen.getByTestId('max-dataset-size-input'), '25')
+ await user.type(screen.getByTestId('harm-categories-filter-input'), 'violence')
+ await user.type(screen.getByTestId('data-types-filter-input'), 'text')
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({
+ dataset_names: ['harmbench', 'xstest'],
+ max_dataset_size: 25,
+ dataset_filters: { harm_categories: ['violence'], data_types: ['text'] },
+ }),
+ 'v1',
+ ))
+ })
+
+ it('rejects a non-positive dataset size before calling the server', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await user.type(await screen.findByTestId('max-dataset-size-input'), '0')
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ expect(await screen.findByText('Max dataset size must be a positive integer.')).toBeInTheDocument()
+ expect(mockUpdate).not.toHaveBeenCalled()
+ })
+
+ it('cannot pin a dataset cap on a scenario that sizes itself by prompt generation', async () => {
+ const generativeScenario = makeScenario({
+ default_run_size: {
+ dataset_size: { kind: 'indeterminate', detail: 'Generated at run time.' },
+ dataset_limit: { state: 'not_applicable' },
+ estimated_attack_count: null,
+ components: [],
+ datasets: [],
+ note: null,
+ },
+ })
+ mockGetScenario.mockResolvedValue(generativeScenario)
+ renderEdit()
+
+ expect(await screen.findByTestId('max-dataset-size-input')).toBeDisabled()
+ expect(
+ screen.getByText('This scenario uses prompt-generation limits instead of a dataset size limit.'),
+ ).toBeInTheDocument()
+ })
+
+ it('leaves the editor without saving when cancelled', async () => {
+ const user = userEvent.setup()
+ renderEdit()
+
+ await user.click(await screen.findByRole('button', { name: 'Cancel' }))
+
+ expect(mockUpdate).not.toHaveBeenCalled()
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets')
+ })
+
+ it('returns to the library from the not-found state', async () => {
+ const user = userEvent.setup()
+ mockGet.mockRejectedValue(apiError(404, 'No such preset.'))
+
+ renderEdit()
+
+ await user.click(await screen.findByRole('button', { name: 'Back to presets' }))
+
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets')
+ })
+
+ it('reports a catalog failure that is not a missing preset as an error', async () => {
+ mockListCatalog.mockRejectedValue(apiError(503, 'Preset storage is not configured.'))
+
+ renderEdit()
+
+ expect(await screen.findByTestId('editor-error-state')).toHaveTextContent(
+ 'Preset storage is not configured.',
+ )
+ })
+})
+
+describe('ScenarioPresetEditor dynamic parameters', () => {
+ const SCENARIO_WITH_PARAM = makeScenario({
+ supported_parameters: [
+ {
+ name: 'max_turns',
+ type_name: 'int',
+ required: false,
+ default: '5',
+ description: 'Turn budget.',
+ },
+ ],
+ })
+
+ it('stores a scenario-specific parameter the operator changed', async () => {
+ const user = userEvent.setup()
+ mockGetScenario.mockResolvedValue(SCENARIO_WITH_PARAM)
+ renderEdit()
+
+ const field = await screen.findByTestId('preset-param-max_turns')
+ await user.clear(field)
+ await user.type(field, '9')
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ scenario_params: { max_turns: 9 } }),
+ 'v1',
+ ))
+ })
+
+ it('preserves and surfaces stored parameters this editor renders no field for', async () => {
+ const user = userEvent.setup()
+ mockGetScenario.mockResolvedValue(SCENARIO_WITH_PARAM)
+ mockGet.mockResolvedValue({
+ preset: { ...STORED_PRESET, scenario_params: { max_turns: 5, max_concurrency: 4 } },
+ version: 'v1',
+ issues: [],
+ })
+
+ renderEdit()
+
+ expect(await screen.findByTestId('carried-params-notice')).toHaveTextContent('max_concurrency')
+
+ await user.click(screen.getByTestId('save-preset-btn'))
+
+ await waitFor(() => expect(mockUpdate).toHaveBeenCalledWith(
+ 'nightly_probe',
+ expect.objectContaining({ scenario_params: { max_turns: 5, max_concurrency: 4 } }),
+ 'v1',
+ ))
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.tsx b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.tsx
new file mode 100644
index 0000000000..38d04b8c1e
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetEditor.tsx
@@ -0,0 +1,436 @@
+import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
+
+import {
+ Button,
+ Field,
+ Input,
+ MessageBar,
+ MessageBarBody,
+ Option,
+ Spinner,
+ Combobox,
+ Text,
+ Textarea,
+} from '@fluentui/react-components'
+import { useNavigate, useParams } from 'react-router'
+
+import ParameterField from '@/components/Parameters/ParameterField'
+import type { ParameterFormValue } from '@/components/Parameters/parameterForm'
+import ScenarioDatasetFields from '@/components/Scenarios/ScenarioDatasetFields'
+import ScenarioTechniqueSelector from '@/components/Scenarios/ScenarioTechniqueSelector'
+import {
+ buildScenarioConfig,
+ defaultMaxDatasetSize,
+ dynamicScenarioParameters,
+ uniqueTechniqueOptions,
+ type ScenarioConfigFormState,
+} from '@/components/Scenarios/scenarioConfigForm'
+import { scenarioPresetsApi, scenariosApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { RegisteredScenario, ScenarioPreset } from '@/types'
+import { fetchAllPages } from '@/utils/fetchAllPages'
+import { routerPathParamValue } from '@/utils/routeParams'
+
+import { useScenarioPresetEditorStyles } from './ScenarioPresetEditor.styles'
+import { PRESETS_ROUTE } from './presetRoutes'
+import {
+ configToPreset,
+ initialPresetConfigState,
+ presetToConfigState,
+ uneditableScenarioParams,
+ unknownPresetTechniques,
+ validatePresetName,
+} from './scenarioPresetForm'
+
+/** Items requested per catalog page while paging the scenario picker's options. */
+const CATALOG_PAGE_SIZE = 200
+
+type LoadStatus = 'loading' | 'ready' | 'not-found' | 'error'
+
+interface LoadedPreset {
+ preset: ScenarioPreset
+ version: string
+}
+
+export default function ScenarioPresetEditor({ mode }: { mode: 'create' | 'edit' }) {
+ const { presetName } = useParams<{ presetName: string }>()
+ // Remount on navigation between two presets so every field resets to the
+ // newly loaded document rather than retaining the previous one's edits.
+ return
+}
+
+interface ScenarioPresetEditorContentProps {
+ mode: 'create' | 'edit'
+ presetName: string | undefined
+}
+
+function ScenarioPresetEditorContent({ mode, presetName }: ScenarioPresetEditorContentProps) {
+ const styles = useScenarioPresetEditorStyles()
+ const navigate = useNavigate()
+ const decodedName = presetName === undefined ? '' : routerPathParamValue(presetName)
+
+ const [status, setStatus] = useState('loading')
+ const [loadError, setLoadError] = useState(null)
+ const [catalog, setCatalog] = useState([])
+ const [loaded, setLoaded] = useState(null)
+
+ const [name, setName] = useState(mode === 'edit' ? decodedName : '')
+ const [description, setDescription] = useState('')
+ const [scenario, setScenario] = useState(null)
+ const [scenarioLoading, setScenarioLoading] = useState(false)
+ const [config, setConfig] = useState(null)
+ const [droppedTechniques, setDroppedTechniques] = useState([])
+ const [scenarioUnavailable, setScenarioUnavailable] = useState(null)
+
+ const [saving, setSaving] = useState(false)
+ const [validationError, setValidationError] = useState(null)
+ const [saveError, setSaveError] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+
+ const load = async (): Promise => {
+ try {
+ const scenarios = await fetchAllPages(
+ (cursor) => scenariosApi.listCatalog(CATALOG_PAGE_SIZE, cursor, false),
+ undefined,
+ (entry) => entry.scenario_name,
+ )
+ if (cancelled) return
+ setCatalog(scenarios)
+
+ if (mode === 'create') {
+ setStatus('ready')
+ return
+ }
+
+ const response = await scenarioPresetsApi.get(decodedName)
+ if (cancelled) return
+ setLoaded({ preset: response.preset, version: response.version })
+ setName(response.preset.name)
+ setDescription(response.preset.description ?? '')
+ setStatus('ready')
+
+ // The list endpoint omits run-size estimates, so the preset's scenario is
+ // re-fetched in full to get the configured dataset caps the form shows.
+ // A preset may legitimately name a scenario this deployment lacks; that
+ // is an unavailable scenario, not a missing preset, so it must not be
+ // reported as a 404 on the preset itself.
+ try {
+ const full = await scenariosApi.getScenario(response.preset.scenario_name)
+ if (cancelled) return
+ setScenario(full)
+ setConfig(presetToConfigState(full, response.preset))
+ setDroppedTechniques(unknownPresetTechniques(full, response.preset))
+ } catch (scenarioErr) {
+ if (cancelled) return
+ setScenarioUnavailable(toApiError(scenarioErr).detail)
+ }
+ } catch (err) {
+ if (cancelled) return
+ const apiError = toApiError(err)
+ setLoadError(apiError.detail)
+ setStatus(apiError.status === 404 ? 'not-found' : 'error')
+ }
+ }
+
+ void load()
+ return () => {
+ cancelled = true
+ }
+ }, [decodedName, mode])
+
+ const handleScenarioChange = useCallback(async (nextScenarioName: string): Promise => {
+ setScenarioLoading(true)
+ setSaveError(null)
+ setValidationError(null)
+ setDroppedTechniques([])
+ try {
+ const full = await scenariosApi.getScenario(nextScenarioName)
+ setScenario(full)
+ setConfig(initialPresetConfigState(full))
+ } catch (err) {
+ setSaveError(toApiError(err).detail)
+ } finally {
+ setScenarioLoading(false)
+ }
+ }, [])
+
+ const dynamicParameters = useMemo(
+ () => (scenario ? dynamicScenarioParameters(scenario) : []),
+ [scenario],
+ )
+ const techniqueOptions = useMemo(
+ () => (scenario ? uniqueTechniqueOptions(scenario).techniques : []),
+ [scenario],
+ )
+ const carriedScenarioParams = useMemo(
+ () => (scenario ? Object.keys(uneditableScenarioParams(scenario, loaded?.preset ?? null)) : []),
+ [loaded, scenario],
+ )
+
+ const updateConfig = (patch: Partial): void => {
+ setConfig((current) => (current ? { ...current, ...patch } : current))
+ setValidationError(null)
+ }
+
+ const updateScenarioParam = useCallback((parameterName: string, value: ParameterFormValue) => {
+ setConfig((current) => (current
+ ? { ...current, scenarioParamValues: { ...current.scenarioParamValues, [parameterName]: value } }
+ : current))
+ setValidationError(null)
+ }, [])
+
+ const handleSubmit = async (event: FormEvent): Promise => {
+ event.preventDefault()
+ if (saving) {
+ return
+ }
+ setSaveError(null)
+
+ const trimmedName = name.trim()
+ const nameError = validatePresetName(trimmedName)
+ if (nameError) {
+ setValidationError(nameError)
+ return
+ }
+ if (!scenario || !config) {
+ setValidationError('Select a scenario.')
+ return
+ }
+ const built = buildScenarioConfig({
+ techniques: config.techniques,
+ dynamicParameters,
+ scenarioParamValues: config.scenarioParamValues,
+ datasetOverride: config.datasetOverride,
+ maxDatasetSize: config.maxDatasetSize,
+ harmCategoriesFilter: config.harmCategoriesFilter,
+ dataTypesFilter: config.dataTypesFilter,
+ includeBaseline: config.includeBaseline,
+ })
+ if (!built.ok) {
+ setValidationError(built.error)
+ return
+ }
+ setValidationError(null)
+
+ const preset = configToPreset(
+ { name: trimmedName, scenarioName: scenario.scenario_name, description },
+ built.config,
+ { scenario, previous: loaded?.preset ?? null },
+ )
+
+ setSaving(true)
+ try {
+ if (loaded) {
+ await scenarioPresetsApi.update(loaded.preset.name, preset, loaded.version)
+ } else {
+ await scenarioPresetsApi.create(preset)
+ }
+ navigate(PRESETS_ROUTE)
+ } catch (err) {
+ setSaveError(toApiError(err).detail)
+ setSaving(false)
+ }
+ }
+
+ if (status === 'loading') {
+ return (
+
+ )
+ }
+
+ if (status !== 'ready') {
+ return (
+
+
+
+
+ {status === 'not-found' ? `No preset named "${decodedName}".` : loadError}
+
+
+ navigate(PRESETS_ROUTE)}>
+ Back to presets
+
+
+
+ )
+ }
+
+ const editing = loaded !== null
+
+ return (
+
+
+
+ {editing ? `Edit ${decodedName}` : 'New preset'}
+
+
+ A preset stores what to test. The target, concurrency and retries are chosen at launch.
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.styles.ts b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.styles.ts
new file mode 100644
index 0000000000..a1adee152e
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.styles.ts
@@ -0,0 +1,104 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import { mobileTouchTarget, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets'
+import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground'
+
+export const useScenarioPresetLibraryStyles = makeStyles({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ width: '100%',
+ minWidth: 0,
+ padding: tokens.spacingVerticalXXL,
+ overflowX: 'hidden',
+ overflowY: 'auto',
+ backgroundColor: WORKSPACE_CANVAS_BACKGROUND,
+ [NARROW_VIEWPORT_QUERY]: {
+ padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`,
+ },
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ flexWrap: 'wrap',
+ gap: tokens.spacingVerticalL,
+ marginBottom: tokens.spacingVerticalL,
+ [NARROW_VIEWPORT_QUERY]: {
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ },
+ },
+ headerText: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ },
+ subtitle: {
+ color: tokens.colorNeutralForeground3,
+ },
+ headerActions: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalS,
+ alignItems: 'center',
+ },
+ touchTarget: {
+ ...mobileTouchTarget,
+ },
+ sourceLine: {
+ color: tokens.colorNeutralForeground3,
+ marginBottom: tokens.spacingVerticalM,
+ wordBreak: 'break-all',
+ },
+ centeredState: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingVerticalXXL,
+ textAlign: 'center',
+ },
+ tableContainer: {
+ width: '100%',
+ overflowX: 'auto',
+ },
+ table: {
+ minWidth: '900px',
+ backgroundColor: tokens.colorNeutralBackground1,
+ },
+ nameCell: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ minWidth: 0,
+ },
+ cardTitleRow: {
+ display: 'flex',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalS,
+ },
+ cardMeta: {
+ color: tokens.colorNeutralForeground3,
+ },
+ cardActions: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalXS,
+ alignItems: 'center',
+ },
+ issueList: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ margin: 0,
+ paddingLeft: tokens.spacingHorizontalL,
+ },
+ dialogBody: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ },
+})
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.test.tsx b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.test.tsx
new file mode 100644
index 0000000000..07cfa8d27a
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.test.tsx
@@ -0,0 +1,292 @@
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter } from 'react-router'
+
+import { scenarioPresetsApi } from '@/services/api'
+import type {
+ PresetIssue,
+ ScenarioPreset,
+ ScenarioPresetResponse,
+ ScenarioRunSizeEstimateResponse,
+} from '@/types'
+
+import ScenarioPresetLibrary from './ScenarioPresetLibrary'
+
+jest.mock('@/services/api', () => ({
+ scenarioPresetsApi: {
+ list: jest.fn(),
+ remove: jest.fn(),
+ resolve: jest.fn(),
+ },
+ scenariosApi: {
+ startRun: jest.fn(),
+ },
+}))
+
+const mockList = scenarioPresetsApi.list as jest.Mock
+const mockRemove = scenarioPresetsApi.remove as jest.Mock
+
+const mockNavigate = jest.fn()
+
+jest.mock('react-router', () => ({
+ ...jest.requireActual('react-router'),
+ useNavigate: () => mockNavigate,
+}))
+
+const NIGHTLY_PRESET: ScenarioPreset = {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ techniques: ['crescendo'],
+}
+
+function makeItem(
+ preset: ScenarioPreset = NIGHTLY_PRESET,
+ issues: PresetIssue[] = [],
+ runSize: ScenarioRunSizeEstimateResponse | null = null,
+): ScenarioPresetResponse {
+ return { preset, version: 'v1', issues, run_size: runSize }
+}
+
+function makeRunSize(attacks: number): ScenarioRunSizeEstimateResponse {
+ return {
+ status: 'exact',
+ dataset_size: { kind: 'bounded', value: attacks },
+ dataset_limit: { state: 'value', value: attacks },
+ estimated_attack_count: attacks,
+ components: [{ label: 'Crescendo', count: attacks, is_baseline: false, note: null }],
+ datasets: [],
+ note: null,
+ }
+}
+
+function renderLibrary() {
+ return render(
+
+
+
+
+ ,
+ )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [] })
+})
+
+describe('ScenarioPresetLibrary', () => {
+ it('shows an empty state and where presets are stored', async () => {
+ renderLibrary()
+
+ expect(await screen.findByTestId('empty-state')).toBeInTheDocument()
+ expect(screen.getByText('Stored in presets.yaml')).toBeInTheDocument()
+ })
+
+ it('lists each saved preset with its scenario and technique count', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [
+ makeItem(),
+ makeItem({ name: 'weekly_probe', scenario_name: 'foundry.encoding', techniques: ['base64'] }),
+ ],
+ })
+
+ renderLibrary()
+
+ const row = await screen.findByTestId('preset-row-nightly_probe')
+ expect(within(row).getByText('foundry.red_team_agent')).toBeInTheDocument()
+ expect(within(row).getByText('1 technique')).toBeInTheDocument()
+ expect(screen.getByTestId('preset-row-weekly_probe')).toBeInTheDocument()
+ })
+
+ it('reports scenario default techniques when the preset pins none', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [makeItem({ name: 'nightly_probe', scenario_name: 'foundry.red_team_agent' })],
+ })
+
+ renderLibrary()
+
+ expect(await screen.findByText('Scenario default techniques')).toBeInTheDocument()
+ })
+
+ it('shows the run size the preset itself produces', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [makeItem(NIGHTLY_PRESET, [], makeRunSize(24))],
+ })
+
+ renderLibrary()
+
+ const cell = await screen.findByTestId('preset-run-size-nightly_probe')
+ await waitFor(() => expect(within(cell).getByText('24 attacks')).toBeInTheDocument())
+ })
+
+ it('paints the table from an unsized read before asking for run sizes', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [makeItem(NIGHTLY_PRESET, [], makeRunSize(24))],
+ })
+
+ renderLibrary()
+
+ await screen.findByTestId('preset-row-nightly_probe')
+ await waitFor(() => expect(mockList).toHaveBeenCalledTimes(2))
+ expect(mockList.mock.calls[0][1]).toBe(false)
+ expect(mockList.mock.calls[1][1]).toBeUndefined()
+ })
+
+ it('leaves the run size blank for a preset this deployment cannot size', async () => {
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+
+ renderLibrary()
+
+ const cell = await screen.findByTestId('preset-run-size-nightly_probe')
+ await waitFor(() => expect(within(cell).getByText('—')).toBeInTheDocument())
+ })
+
+ it('keeps the table usable when run sizes cannot be calculated', async () => {
+ mockList
+ .mockResolvedValueOnce({ source: 'presets.yaml', items: [makeItem()] })
+ .mockRejectedValueOnce(new Error('sizing unavailable'))
+
+ renderLibrary()
+
+ expect(await screen.findByTestId('preset-row-nightly_probe')).toBeInTheDocument()
+ expect(
+ await screen.findByText('Run sizes could not be calculated: sizing unavailable'),
+ ).toBeInTheDocument()
+ })
+
+ it('attributes a preset to its author and leaves the column blank without one', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [
+ makeItem({ ...NIGHTLY_PRESET, author: 'Ada Lovelace' }),
+ makeItem({ name: 'weekly_probe', scenario_name: 'foundry.encoding' }),
+ ],
+ })
+
+ renderLibrary()
+
+ const authored = await screen.findByTestId('preset-author-nightly_probe')
+ expect(within(authored).getByText('Ada Lovelace')).toBeInTheDocument()
+ expect(within(screen.getByTestId('preset-author-weekly_probe')).getByText('—')).toBeInTheDocument()
+ })
+
+ it('disables launch for an unresolvable preset and names the missing reference', async () => {
+ mockList.mockResolvedValue({
+ source: 'presets.yaml',
+ items: [makeItem(NIGHTLY_PRESET, [
+ { field: 'scenario_name', message: 'Unknown scenario "foundry.retired".' },
+ ])],
+ })
+
+ renderLibrary()
+
+ expect(await screen.findByTestId('launch-preset-nightly_probe')).toBeDisabled()
+ expect(screen.getByText('Not runnable here')).toBeInTheDocument()
+ expect(screen.getByText('Unknown scenario "foundry.retired".')).toBeInTheDocument()
+ })
+
+ it('keeps launch enabled when the server reports no issues', async () => {
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+
+ renderLibrary()
+
+ expect(await screen.findByTestId('launch-preset-nightly_probe')).toBeEnabled()
+ expect(screen.queryByTestId('preset-issues-nightly_probe')).not.toBeInTheDocument()
+ })
+
+ it('surfaces a load failure with a retry that refetches', async () => {
+ const user = userEvent.setup()
+ mockList.mockRejectedValueOnce(new Error('storage unavailable'))
+
+ renderLibrary()
+
+ expect(await screen.findByTestId('error-state')).toBeInTheDocument()
+ expect(screen.getByText('storage unavailable')).toBeInTheDocument()
+
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+ await user.click(screen.getByTestId('retry-btn'))
+
+ expect(await screen.findByTestId('preset-row-nightly_probe')).toBeInTheDocument()
+ })
+
+ it('requires confirmation before deleting and refreshes afterwards', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+ mockRemove.mockResolvedValue(undefined)
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('delete-preset-nightly_probe'))
+ expect(mockRemove).not.toHaveBeenCalled()
+
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [] })
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => expect(mockRemove).toHaveBeenCalledWith('nightly_probe'))
+ expect(await screen.findByTestId('empty-state')).toBeInTheDocument()
+ })
+
+ it('cancels a delete without calling the server', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('delete-preset-nightly_probe'))
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: 'Cancel' }))
+
+ expect(mockRemove).not.toHaveBeenCalled()
+ expect(screen.getByTestId('preset-row-nightly_probe')).toBeInTheDocument()
+ })
+
+ it('reports a delete failure without removing the card', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+ mockRemove.mockRejectedValue(new Error('Admin access required.'))
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('delete-preset-nightly_probe'))
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: 'Delete' }))
+
+ expect(await screen.findByText('Admin access required.')).toBeInTheDocument()
+ expect(screen.getByTestId('preset-row-nightly_probe')).toBeInTheDocument()
+ })
+
+ it('navigates to the editor for create and edit', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('edit-preset-nightly_probe'))
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets/nightly_probe/edit')
+
+ await user.click(screen.getByTestId('new-preset-btn'))
+ expect(mockNavigate).toHaveBeenCalledWith('/registry/scenario-presets/new')
+ })
+
+ it('opens the launch dialog only for a runnable preset', async () => {
+ const user = userEvent.setup()
+ mockList.mockResolvedValue({ source: 'presets.yaml', items: [makeItem()] })
+
+ renderLibrary()
+
+ await user.click(await screen.findByTestId('launch-preset-nightly_probe'))
+
+ expect(await screen.findByTestId('confirm-launch-preset')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.tsx b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.tsx
new file mode 100644
index 0000000000..5577c77b9b
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/ScenarioPresetLibrary.tsx
@@ -0,0 +1,409 @@
+import { useCallback, useEffect, useState } from 'react'
+
+import {
+ Badge,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogBody,
+ DialogContent,
+ DialogSurface,
+ DialogTitle,
+ MessageBar,
+ MessageBarBody,
+ Spinner,
+ Table,
+ TableBody,
+ TableCell,
+ TableHeader,
+ TableHeaderCell,
+ TableRow,
+ Text,
+} from '@fluentui/react-components'
+import {
+ AddRegular,
+ ArrowSyncRegular,
+ DeleteRegular,
+ EditRegular,
+ PlayRegular,
+} from '@fluentui/react-icons'
+import { Link, useNavigate } from 'react-router'
+
+import { ScenarioRunEstimateSummary } from '@/components/Scenarios/ScenarioRunEstimate'
+import { mapScenarioRunEstimate } from '@/components/Scenarios/scenarioRunEstimateAdapter'
+import { scenarioPresetsApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { ScenarioPresetResponse, TargetInstance } from '@/types'
+
+import LaunchPresetDialog from './LaunchPresetDialog'
+import { useScenarioPresetLibraryStyles } from './ScenarioPresetLibrary.styles'
+import { NEW_PRESET_ROUTE, presetEditorRoutePath } from './presetRoutes'
+
+interface ScenarioPresetLibraryProps {
+ targets: TargetInstance[]
+ defaultObjectiveTarget: TargetInstance | null
+ defaultAdversarialTarget: TargetInstance | null
+ labels: Record
+}
+
+function techniqueSummary(item: ScenarioPresetResponse): string {
+ const { techniques } = item.preset
+ if (!techniques || techniques.length === 0) {
+ return 'Scenario default techniques'
+ }
+ return `${techniques.length} technique${techniques.length === 1 ? '' : 's'}`
+}
+
+/**
+ * Lists the saved scenario presets. Presets that reference something this
+ * deployment does not have are still listed — the server reports the problems
+ * as advisory issues — but cannot be launched here.
+ */
+export default function ScenarioPresetLibrary({
+ targets,
+ defaultObjectiveTarget,
+ defaultAdversarialTarget,
+ labels,
+}: ScenarioPresetLibraryProps) {
+ const styles = useScenarioPresetLibraryStyles()
+ const navigate = useNavigate()
+ const [items, setItems] = useState([])
+ const [source, setSource] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [actionError, setActionError] = useState(null)
+ const [estimatesLoading, setEstimatesLoading] = useState(true)
+ const [estimateError, setEstimateError] = useState(null)
+ const [launching, setLaunching] = useState(null)
+ const [pendingDelete, setPendingDelete] = useState(null)
+ const [deleting, setDeleting] = useState(false)
+ const [generation, setGeneration] = useState(0)
+
+ useEffect(() => {
+ const controller = new AbortController()
+ let cancelled = false
+
+ // Sizing a preset rebuilds its scenario, so the table paints from an
+ // unsized read first and fills the run-size column in a second pass.
+ const loadPresets = async (): Promise => {
+ try {
+ const response = await scenarioPresetsApi.list(controller.signal, false)
+ if (cancelled) return
+ setItems(response.items)
+ setSource(response.source)
+ setError(null)
+ setLoading(false)
+
+ if (response.items.length === 0) {
+ setEstimatesLoading(false)
+ return
+ }
+
+ try {
+ const sized = await scenarioPresetsApi.list(controller.signal)
+ if (cancelled) return
+ setItems(sized.items)
+ setEstimateError(null)
+ } catch (err: unknown) {
+ if (cancelled || controller.signal.aborted) return
+ setEstimateError(toApiError(err).detail)
+ } finally {
+ if (!cancelled) {
+ setEstimatesLoading(false)
+ }
+ }
+ } catch (err: unknown) {
+ if (cancelled || controller.signal.aborted) return
+ setError(toApiError(err).detail)
+ setLoading(false)
+ setEstimatesLoading(false)
+ }
+ }
+
+ void loadPresets()
+
+ return () => {
+ cancelled = true
+ controller.abort()
+ }
+ }, [generation])
+
+ const refresh = useCallback(() => {
+ setLoading(true)
+ setEstimatesLoading(true)
+ setError(null)
+ setEstimateError(null)
+ setGeneration((current) => current + 1)
+ }, [])
+
+ const handleDelete = async (): Promise => {
+ if (!pendingDelete) {
+ return
+ }
+ setDeleting(true)
+ setActionError(null)
+ try {
+ await scenarioPresetsApi.remove(pendingDelete.preset.name)
+ setPendingDelete(null)
+ refresh()
+ } catch (err) {
+ setActionError(toApiError(err).detail)
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ return (
+
+
+
+
+ Scenario presets
+
+
+ Reusable scenario configurations. A preset pins what to test; you choose the target at
+ launch.
+
+
+
+ }
+ onClick={() => navigate(NEW_PRESET_ROUTE)}
+ data-testid="new-preset-btn"
+ >
+ New preset
+
+ }
+ onClick={refresh}
+ disabled={loading}
+ >
+ Refresh
+
+
+
+
+ {source !== '' && (
+
+ Stored in {source}
+
+ )}
+
+ {actionError && (
+
+ {actionError}
+
+ )}
+
+ {estimateError && (
+
+ Run sizes could not be calculated: {estimateError}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+
+ {error}
+
+ }
+ onClick={refresh}
+ data-testid="retry-btn"
+ >
+ Retry
+
+
+ ) : items.length === 0 ? (
+
+ No presets saved yet
+
+ Create one to reuse a scenario configuration across targets and runs.
+
+
+ ) : (
+
+
+
+
+ Name
+ Scenario
+ Run size
+ Author
+ Actions
+
+
+
+ {items.map((item) => {
+ const runnable = item.issues.length === 0
+ const estimateState = item.run_size
+ ? mapScenarioRunEstimate(item.run_size, 'request')
+ : null
+ return (
+
+
+
+
+ {item.preset.name}
+
+ {!runnable && (
+
+ Not runnable here
+
+ )}
+
+ {item.preset.description && (
+
+ {item.preset.description}
+
+ )}
+ {!runnable && (
+
+ {item.issues.map((issue) => (
+
+ {issue.message}
+
+ ))}
+
+ )}
+
+
+ {item.preset.scenario_name}
+
+ {techniqueSummary(item)}
+
+
+
+ {estimatesLoading ? (
+
+ ) : estimateState ? (
+
+ ) : (
+ —
+ )}
+
+
+ {item.preset.author
+ ? {item.preset.author}
+ : — }
+
+
+
+ }
+ disabled={!runnable}
+ onClick={() => setLaunching(item)}
+ data-testid={`launch-preset-${item.preset.name}`}
+ >
+ Launch
+
+ }
+ onClick={() => navigate(presetEditorRoutePath(item.preset.name))}
+ data-testid={`edit-preset-${item.preset.name}`}
+ >
+ Edit
+
+ }
+ onClick={() => {
+ setActionError(null)
+ setPendingDelete(item)
+ }}
+ data-testid={`delete-preset-${item.preset.name}`}
+ >
+ Delete
+
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+ {launching && (
+ setLaunching(null)}
+ onPresetChanged={() => {
+ const changedName = launching.preset.name
+ setLaunching(null)
+ refresh()
+ setActionError(
+ `Preset '${changedName}' changed while you were launching it. Review it and launch again.`,
+ )
+ }}
+ />
+ )}
+
+ {pendingDelete && (
+ { if (!data.open) setPendingDelete(null) }}
+ >
+
+
+ Delete {pendingDelete.preset.name}?
+
+
+ This permanently removes the preset. Runs already started from it are unaffected.
+
+
+
+ setPendingDelete(null)}
+ disabled={deleting}
+ >
+ Cancel
+
+
+ {deleting ? 'Deleting...' : 'Delete'}
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ScenarioPresets/presetRoutes.test.ts b/frontend/src/components/ScenarioPresets/presetRoutes.test.ts
new file mode 100644
index 0000000000..c6f40146e9
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/presetRoutes.test.ts
@@ -0,0 +1,26 @@
+import { LEGACY_PRESETS_ROUTE, NEW_PRESET_ROUTE, PRESETS_ROUTE, presetEditorRoutePath } from './presetRoutes'
+
+describe('presetEditorRoutePath', () => {
+ it('builds an edit route under the preset library', () => {
+ expect(presetEditorRoutePath('nightly_probe')).toBe('/registry/scenario-presets/nightly_probe/edit')
+ })
+
+ it('escapes a name so it stays inside one path segment', () => {
+ expect(presetEditorRoutePath('a/b')).toBe('/registry/scenario-presets/a%2Fb/edit')
+ })
+
+ it('does not collide with the create route for a preset named "new"', () => {
+ expect(presetEditorRoutePath('new')).not.toBe(NEW_PRESET_ROUTE)
+ expect(presetEditorRoutePath('new').startsWith(`${NEW_PRESET_ROUTE}/`)).toBe(true)
+ })
+
+ it('nests both routes under the registry so the registry nav item stays selected', () => {
+ expect(PRESETS_ROUTE.startsWith('/registry/')).toBe(true)
+ expect(NEW_PRESET_ROUTE.startsWith('/registry/')).toBe(true)
+ })
+
+ it('keeps the old scanner location distinct so it can redirect', () => {
+ expect(LEGACY_PRESETS_ROUTE).toBe('/scanner/presets')
+ expect(LEGACY_PRESETS_ROUTE).not.toBe(PRESETS_ROUTE)
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/presetRoutes.ts b/frontend/src/components/ScenarioPresets/presetRoutes.ts
new file mode 100644
index 0000000000..e9df5dfaa2
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/presetRoutes.ts
@@ -0,0 +1,19 @@
+/**
+ * Presets live under the registry because a preset is a stored, reusable
+ * configuration alongside targets and converters, not a property of one scan.
+ *
+ * Editing uses a trailing `/edit` segment rather than bare
+ * `/registry/scenario-presets/:presetName` so the static create route cannot
+ * shadow a preset whose name happens to be `new` — preset names are
+ * user-authored and `new` matches the server's name pattern.
+ */
+
+export const PRESETS_ROUTE = '/registry/scenario-presets'
+export const NEW_PRESET_ROUTE = `${PRESETS_ROUTE}/new`
+
+/** Where presets used to live. Kept as a redirect so bookmarked links still resolve. */
+export const LEGACY_PRESETS_ROUTE = '/scanner/presets'
+
+export function presetEditorRoutePath(name: string): string {
+ return `${PRESETS_ROUTE}/${encodeURIComponent(name)}/edit`
+}
diff --git a/frontend/src/components/ScenarioPresets/scenarioPresetForm.test.ts b/frontend/src/components/ScenarioPresets/scenarioPresetForm.test.ts
new file mode 100644
index 0000000000..581e0066f8
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/scenarioPresetForm.test.ts
@@ -0,0 +1,502 @@
+import {
+ buildScenarioConfig,
+ defaultMaxDatasetSize,
+ initialScenarioConfigState,
+} from '@/components/Scenarios/scenarioConfigForm'
+import { makeScenario } from '@/test-utils/scenarioFixtures'
+import type { Parameter, ScenarioPreset } from '@/types'
+
+import {
+ configToPreset,
+ initialPresetConfigState,
+ presetToConfigState,
+ uneditableScenarioParams,
+ unknownPresetTechniques,
+ validatePresetName,
+} from './scenarioPresetForm'
+
+function makePreset(overrides: Partial = {}): ScenarioPreset {
+ return {
+ name: 'nightly_probe',
+ scenario_name: 'foundry.red_team_agent',
+ ...overrides,
+ }
+}
+
+/** A scenario whose dataset cap resolves to a concrete number, i.e. one this deployment could pin. */
+function makeCappedScenario() {
+ return makeScenario({
+ default_run_size: {
+ dataset_size: { kind: 'bounded', value: 40 },
+ dataset_limit: { state: 'value', value: 40 },
+ estimated_attack_count: 40,
+ components: [],
+ datasets: [{
+ name: 'harmbench',
+ kind: 'dataset',
+ logical_seed_group_count: 400,
+ selected_seed_group_count: 40,
+ configured_caps: [{ label: 'cap', count: 40, configured_on: 'dataset', dataset_name: 'harmbench' }],
+ selection_note: null,
+ }],
+ note: null,
+ },
+ })
+}
+
+const IDENTITY = { name: 'nightly_probe', scenarioName: 'foundry.red_team_agent', description: '' }
+const ITERATION_PARAMETER: Parameter = {
+ name: 'max_turns',
+ type_name: 'int',
+ required: false,
+ default: '5',
+ description: 'Turn budget.',
+}
+
+const CONCURRENCY_PARAMETER: Parameter = {
+ name: 'max_concurrency',
+ type_name: 'int',
+ required: false,
+ default: '1',
+ description: 'Owned by the launch form, never rendered as a preset field.',
+}
+
+describe('validatePresetName', () => {
+ it('rejects an empty name', () => {
+ expect(validatePresetName('')).toBe('Name is required.')
+ })
+
+ it.each([
+ ['nightly_probe'],
+ ['a'],
+ ['a1_b2'],
+ [`a${'b'.repeat(63)}`],
+ ])('accepts %s', (name) => {
+ expect(validatePresetName(name)).toBeNull()
+ })
+
+ it.each([
+ ['Nightly'],
+ ['1nightly'],
+ ['nightly-probe'],
+ ['nightly probe'],
+ ['nightly.probe'],
+ [`a${'b'.repeat(64)}`],
+ ])('rejects %s', (name) => {
+ expect(validatePresetName(name)).not.toBeNull()
+ })
+})
+
+describe('unknownPresetTechniques', () => {
+ it('returns nothing when the preset pins no techniques', () => {
+ expect(unknownPresetTechniques(makeScenario(), makePreset())).toEqual([])
+ })
+
+ it('names only the techniques the scenario no longer offers', () => {
+ const scenario = makeScenario({ all_techniques: ['crescendo', 'default_technique'] })
+ const preset = makePreset({ techniques: ['crescendo', 'retired_attack'] })
+
+ expect(unknownPresetTechniques(scenario, preset)).toEqual(['retired_attack'])
+ })
+
+ it('accepts an aggregate technique, which the server allows but the selector has no checkbox for', () => {
+ const scenario = makeScenario({ aggregate_techniques: ['all', 'default'] })
+ const preset = makePreset({ techniques: ['all'] })
+
+ expect(unknownPresetTechniques(scenario, preset)).toEqual([])
+ })
+
+ it('accepts a converter modifier on a technique the scenario does define', () => {
+ const scenario = makeScenario({ all_techniques: ['crescendo', 'default_technique'] })
+ const preset = makePreset({ techniques: ['crescendo:converter.translation_spanish'] })
+
+ expect(unknownPresetTechniques(scenario, preset)).toEqual([])
+ })
+})
+
+describe('defaultMaxDatasetSize', () => {
+ it('reads the cap the scenario declares rather than counting loaded seeds', () => {
+ expect(defaultMaxDatasetSize(makeCappedScenario())).toBe('40')
+ })
+
+ it('offers no default when the scenario sizes itself by prompt generation', () => {
+ const scenario = makeScenario({
+ default_run_size: {
+ dataset_size: { kind: 'indeterminate', detail: 'Generated at run time.' },
+ dataset_limit: { state: 'not_applicable' },
+ estimated_attack_count: null,
+ components: [],
+ datasets: [],
+ note: null,
+ },
+ })
+
+ expect(defaultMaxDatasetSize(scenario)).toBe('')
+ })
+
+ it('offers no default when the scenario declares none', () => {
+ expect(defaultMaxDatasetSize(makeScenario())).toBe('')
+ })
+})
+
+describe('initialPresetConfigState', () => {
+ it('leaves the dataset cap blank where the launch form prefills the scenario default', () => {
+ const scenario = makeCappedScenario()
+
+ expect(initialScenarioConfigState(scenario).maxDatasetSize).toBe('40')
+ expect(initialPresetConfigState(scenario).maxDatasetSize).toBe('')
+ })
+})
+
+describe('uneditableScenarioParams', () => {
+ it('returns nothing when the preset stores no parameters', () => {
+ expect(uneditableScenarioParams(makeScenario(), makePreset())).toEqual({})
+ expect(uneditableScenarioParams(makeScenario(), null)).toEqual({})
+ })
+
+ it('names the stored keys that have no field, whether undeclared or owned by the launch form', () => {
+ const scenario = makeScenario({
+ supported_parameters: [ITERATION_PARAMETER, CONCURRENCY_PARAMETER],
+ })
+ const preset = makePreset({
+ scenario_params: { max_turns: 9, retired_knob: 'x', max_concurrency: 4 },
+ })
+
+ expect(uneditableScenarioParams(scenario, preset)).toEqual({ retired_knob: 'x', max_concurrency: 4 })
+ })
+})
+
+describe('presetToConfigState', () => {
+ it('falls back to the scenario defaults for every omitted field', () => {
+ const scenario = makeScenario()
+ const state = presetToConfigState(scenario, makePreset())
+
+ expect(state.techniques).toEqual(['default_technique'])
+ expect(state.includeBaseline).toBe(true)
+ expect(state.datasetOverride).toBe('')
+ expect(state.maxDatasetSize).toBe('')
+ expect(state.harmCategoriesFilter).toBe('')
+ expect(state.dataTypesFilter).toBe('')
+ })
+
+ it('expands the stored fields a preset does carry', () => {
+ const scenario = makeScenario()
+ const state = presetToConfigState(scenario, makePreset({
+ techniques: ['crescendo'],
+ include_baseline: false,
+ dataset_names: ['harmbench', 'xstest'],
+ max_dataset_size: 25,
+ dataset_filters: { harm_categories: ['violence'], data_types: ['text'] },
+ }))
+
+ expect(state.techniques).toEqual(['crescendo'])
+ expect(state.includeBaseline).toBe(false)
+ expect(state.datasetOverride).toBe('harmbench, xstest')
+ expect(state.maxDatasetSize).toBe('25')
+ expect(state.harmCategoriesFilter).toBe('violence')
+ expect(state.dataTypesFilter).toBe('text')
+ })
+
+ it('keeps pinned techniques the scenario no longer offers rather than rewriting the selection', () => {
+ const scenario = makeScenario({ all_techniques: ['crescendo', 'default_technique'] })
+ const state = presetToConfigState(
+ scenario,
+ makePreset({ techniques: ['crescendo', 'retired_attack'] }),
+ )
+
+ expect(state.techniques).toEqual(['crescendo', 'retired_attack'])
+ })
+
+ it('keeps a converter-qualified technique the selector renders no checkbox for', () => {
+ const scenario = makeScenario({ all_techniques: ['crescendo', 'default_technique'] })
+ const state = presetToConfigState(
+ scenario,
+ makePreset({ techniques: ['crescendo:converter.translation_spanish'] }),
+ )
+
+ expect(state.techniques).toEqual(['crescendo:converter.translation_spanish'])
+ })
+
+ it('falls back to the scenario defaults only when the preset pins nothing', () => {
+ const scenario = makeScenario({ all_techniques: ['crescendo', 'default_technique'] })
+ const state = presetToConfigState(scenario, makePreset({ techniques: [] }))
+
+ expect(state.techniques).toEqual(['default_technique'])
+ })
+
+ it('keeps a pinned aggregate technique that the selector renders no checkbox for', () => {
+ const scenario = makeScenario({ aggregate_techniques: ['all', 'default'] })
+ const state = presetToConfigState(scenario, makePreset({ techniques: ['all'] }))
+
+ expect(state.techniques).toEqual(['all'])
+ })
+
+ it('leaves the dataset cap blank when the preset omits it, even where the scenario has one', () => {
+ const state = presetToConfigState(makeCappedScenario(), makePreset())
+
+ expect(state.maxDatasetSize).toBe('')
+ })
+
+ it('keeps baseline off when the scenario forbids it, whatever the preset stored', () => {
+ const scenario = makeScenario({ baseline_policy: 'forbidden' })
+ const state = presetToConfigState(scenario, makePreset({ include_baseline: true }))
+
+ expect(state.includeBaseline).toBe(false)
+ })
+
+ it('seeds dynamic parameter values from the stored scenario params', () => {
+ const scenario = makeScenario({ supported_parameters: [ITERATION_PARAMETER] })
+ const state = presetToConfigState(
+ scenario,
+ makePreset({ scenario_params: { max_turns: 9 } }),
+ )
+
+ expect(state.scenarioParamValues.max_turns).toBe('9')
+ })
+})
+
+describe('configToPreset', () => {
+ function buildConfig(overrides: Partial[0]> = {}) {
+ const result = buildScenarioConfig({
+ techniques: ['crescendo'],
+ dynamicParameters: [],
+ scenarioParamValues: {},
+ datasetOverride: '',
+ maxDatasetSize: '',
+ harmCategoriesFilter: '',
+ dataTypesFilter: '',
+ includeBaseline: false,
+ ...overrides,
+ })
+ if (!result.ok) {
+ throw new Error(result.error)
+ }
+ return result.config
+ }
+
+ it('omits a blank description rather than storing an empty string', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(
+ { ...IDENTITY, description: ' ' },
+ buildConfig(),
+ { scenario, previous: null },
+ )
+
+ expect(preset).not.toHaveProperty('description')
+ })
+
+ it('trims a description it does keep', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(
+ { ...IDENTITY, description: ' nightly ' },
+ buildConfig(),
+ { scenario, previous: null },
+ )
+
+ expect(preset.description).toBe('nightly')
+ })
+
+ it('keeps the author the server stamped at create', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig(),
+ {
+ scenario,
+ previous: { name: 'nightly', scenario_name: scenario.scenario_name, author: 'Ada Lovelace' },
+ },
+ )
+
+ expect(preset.author).toBe('Ada Lovelace')
+ })
+
+ it('leaves the author unset on create so the server can stamp it', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(IDENTITY, buildConfig(), { scenario, previous: null })
+
+ expect(preset).not.toHaveProperty('author')
+ })
+
+ it('carries only the fields the operator moved off the scenario default', () => {
+ const scenario = makeScenario()
+ const preset = configToPreset(IDENTITY, buildConfig(), { scenario, previous: null })
+
+ expect(Object.keys(preset).sort()).toEqual(
+ ['include_baseline', 'name', 'scenario_name', 'techniques'],
+ )
+ })
+
+ it('pins nothing when a new preset leaves every scenario-owned field alone', () => {
+ const scenario = makeScenario()
+ const state = initialPresetConfigState(scenario)
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({
+ techniques: state.techniques,
+ includeBaseline: state.includeBaseline,
+ maxDatasetSize: state.maxDatasetSize,
+ }),
+ { scenario, previous: null },
+ )
+
+ expect(preset).toEqual({ name: IDENTITY.name, scenario_name: IDENTITY.scenarioName })
+ })
+
+ it('leaves unpinned fields unset when an unrelated edit round-trips a sparse preset', () => {
+ const scenario = makeCappedScenario()
+ const original = makePreset({ description: 'Nightly smoke test.' })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ { ...IDENTITY, description: 'Edited.' },
+ buildConfig({
+ techniques: state.techniques,
+ includeBaseline: state.includeBaseline,
+ datasetOverride: state.datasetOverride,
+ maxDatasetSize: state.maxDatasetSize,
+ }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped).toEqual({ ...original, description: 'Edited.' })
+ })
+
+ it('keeps a field the preset already pinned even where it equals the scenario default', () => {
+ const scenario = makeScenario()
+ const original = makePreset({ techniques: ['default_technique'], include_baseline: true })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ IDENTITY,
+ buildConfig({ techniques: state.techniques, includeBaseline: state.includeBaseline }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped.techniques).toEqual(['default_technique'])
+ expect(roundTripped.include_baseline).toBe(true)
+ })
+
+ it('pins nothing when the default techniques come back in a different order', () => {
+ const scenario = makeScenario({
+ all_techniques: ['default_technique', 'crescendo'],
+ default_techniques: ['default_technique', 'crescendo'],
+ })
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({ techniques: ['crescendo', 'default_technique'], includeBaseline: true }),
+ { scenario, previous: null },
+ )
+
+ expect(preset).toEqual({ name: IDENTITY.name, scenario_name: IDENTITY.scenarioName })
+ })
+
+ it('still pins techniques when the selection swaps a member rather than reordering', () => {
+ const scenario = makeScenario({
+ all_techniques: ['default_technique', 'crescendo', 'flip'],
+ default_techniques: ['default_technique', 'crescendo'],
+ })
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({ techniques: ['crescendo', 'flip'], includeBaseline: true }),
+ { scenario, previous: null },
+ )
+
+ expect(preset.techniques).toEqual(['crescendo', 'flip'])
+ })
+
+ it('preserves a stored baseline pin the forbidden policy hides from the form', () => {
+ const scenario = makeScenario({ baseline_policy: 'forbidden' })
+ const original = makePreset({ include_baseline: true })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ IDENTITY,
+ buildConfig({ includeBaseline: state.includeBaseline }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped.include_baseline).toBe(true)
+ })
+
+ it('pins no baseline for a new preset the scenario forbids one on', () => {
+ const scenario = makeScenario({ baseline_policy: 'forbidden' })
+ const preset = configToPreset(IDENTITY, buildConfig(), { scenario, previous: null })
+
+ expect(preset).not.toHaveProperty('include_baseline')
+ })
+
+ it('omits a dynamic parameter left at its declared default', () => {
+ const scenario = makeScenario({ supported_parameters: [ITERATION_PARAMETER] })
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({
+ dynamicParameters: [ITERATION_PARAMETER],
+ scenarioParamValues: { max_turns: '5' },
+ }),
+ { scenario, previous: null },
+ )
+
+ expect(preset).not.toHaveProperty('scenario_params')
+ })
+
+ it('pins a dynamic parameter the operator moved off its declared default', () => {
+ const scenario = makeScenario({ supported_parameters: [ITERATION_PARAMETER] })
+ const preset = configToPreset(
+ IDENTITY,
+ buildConfig({
+ dynamicParameters: [ITERATION_PARAMETER],
+ scenarioParamValues: { max_turns: '9' },
+ }),
+ { scenario, previous: null },
+ )
+
+ expect(preset.scenario_params).toEqual({ max_turns: 9 })
+ })
+
+ it('carries stored parameters this editor renders no control for', () => {
+ const scenario = makeScenario({ supported_parameters: [ITERATION_PARAMETER] })
+ const original = makePreset({
+ scenario_params: { max_turns: 5, retired_knob: 'x', max_concurrency: 4 },
+ })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ IDENTITY,
+ buildConfig({
+ techniques: state.techniques,
+ dynamicParameters: [ITERATION_PARAMETER],
+ scenarioParamValues: state.scenarioParamValues,
+ }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped.scenario_params).toEqual({ max_turns: 5, retired_knob: 'x', max_concurrency: 4 })
+ })
+
+ it('round-trips a fully populated preset back through the form state', () => {
+ const scenario = makeScenario()
+ const original = makePreset({
+ techniques: ['crescendo'],
+ include_baseline: false,
+ dataset_names: ['harmbench'],
+ max_dataset_size: 25,
+ dataset_filters: { harm_categories: ['violence'] },
+ })
+ const state = presetToConfigState(scenario, original)
+
+ const roundTripped = configToPreset(
+ IDENTITY,
+ buildConfig({
+ techniques: state.techniques,
+ includeBaseline: state.includeBaseline,
+ datasetOverride: state.datasetOverride,
+ maxDatasetSize: state.maxDatasetSize,
+ harmCategoriesFilter: state.harmCategoriesFilter,
+ dataTypesFilter: state.dataTypesFilter,
+ }),
+ { scenario, previous: original },
+ )
+
+ expect(roundTripped).toEqual(original)
+ })
+})
diff --git a/frontend/src/components/ScenarioPresets/scenarioPresetForm.ts b/frontend/src/components/ScenarioPresets/scenarioPresetForm.ts
new file mode 100644
index 0000000000..b8328a9633
--- /dev/null
+++ b/frontend/src/components/ScenarioPresets/scenarioPresetForm.ts
@@ -0,0 +1,257 @@
+import { buildParametersFromForm, getInitialFormValues } from '@/components/Parameters/parameterForm'
+import {
+ dynamicScenarioParameters,
+ initialScenarioConfigState,
+ uniqueTechniqueOptions,
+ type ScenarioConfigFields,
+ type ScenarioConfigFormState,
+} from '@/components/Scenarios/scenarioConfigForm'
+import type { RegisteredScenario, ScenarioPreset } from '@/types'
+
+/**
+ * Preset-specific form logic: the translation between a stored `ScenarioPreset`
+ * and the scenario-config form state shared with the launch form. The launch
+ * form never sees a preset, so none of this belongs in `scenarioConfigForm`.
+ *
+ * Every scenario-owned field on a preset is tri-state: an explicit value pins it,
+ * and an omitted field means "whatever the scenario defaults to at run time". The
+ * editor has to prefill omitted fields to render a control for them, so it writes a
+ * field back only when the operator moved it off that default or the document already
+ * pinned it. Round-tripping a preset through the editor otherwise freezes this
+ * deployment's current defaults into the document on the first unrelated edit.
+ */
+
+/** Mirrors the server-side `ScenarioPreset.name` pattern so the editor fails fast instead of on a 422. */
+const PRESET_NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/
+
+export function validatePresetName(name: string): string | null {
+ if (name.length === 0) {
+ return 'Name is required.'
+ }
+ if (!PRESET_NAME_PATTERN.test(name)) {
+ return 'Use lowercase letters, digits and underscores, starting with a letter (max 64 characters).'
+ }
+ return null
+}
+
+/**
+ * Technique names a preset may legitimately pin. Mirrors the server's allow-list:
+ * the concrete techniques plus the scenario's aggregates (`all`, `easy`, ...), which
+ * the selector has no checkbox for but which remain valid stored values.
+ */
+function presetSelectableTechniques(scenario: RegisteredScenario): Set {
+ const concrete = uniqueTechniqueOptions(scenario).techniques.map((technique) => technique.name)
+ return new Set([...concrete, ...scenario.aggregate_techniques])
+}
+
+/**
+ * Mirrors the server-side technique-token grammar (`pyrit/scenario/core/_technique_tokens.py`):
+ * a technique name optionally followed by colon-separated modifiers, as in
+ * `role_play:converter.translation_spanish`. Only the technique name is matched against the
+ * scenario's techniques, so a modifier does not make a known technique look unknown.
+ */
+function techniqueBaseName(token: string): string {
+ const separator = token.indexOf(':')
+ return separator === -1 ? token : token.slice(0, separator)
+}
+
+/**
+ * Techniques the preset pins that this deployment's scenario does not define. The editor
+ * surfaces them so the operator knows the preset will not run here; it carries them through
+ * untouched rather than rewriting the document on an unrelated edit.
+ */
+export function unknownPresetTechniques(
+ scenario: RegisteredScenario,
+ preset: ScenarioPreset,
+): string[] {
+ if (!preset.techniques) {
+ return []
+ }
+ const available = presetSelectableTechniques(scenario)
+ return preset.techniques.filter((token) => !available.has(techniqueBaseName(token)))
+}
+
+/**
+ * Starting state for a preset that pins nothing yet. Identical to the launch form's
+ * except the dataset cap stays blank. Unlike the technique and baseline controls, a
+ * number input can represent "unset" directly, so the editor uses blank to mean
+ * "track the scenario" rather than prefilling the default and comparing it back out.
+ * The scenario's own cap is still shown as hint text beneath the field.
+ */
+export function initialPresetConfigState(scenario: RegisteredScenario): ScenarioConfigFormState {
+ return { ...initialScenarioConfigState(scenario), maxDatasetSize: '' }
+}
+
+/**
+ * Parameters the preset stores that this editor renders no control for — a key the
+ * scenario no longer declares, or one of the common parameters the launch form owns.
+ * They are carried through untouched rather than dropped on an unrelated edit.
+ */
+export function uneditableScenarioParams(
+ scenario: RegisteredScenario,
+ preset: ScenarioPreset | null,
+): Record {
+ const stored = preset?.scenario_params
+ if (!stored) {
+ return {}
+ }
+ const editable = new Set(dynamicScenarioParameters(scenario).map((parameter) => parameter.name))
+ return Object.fromEntries(Object.entries(stored).filter(([name]) => !editable.has(name)))
+}
+
+/**
+ * Expands a stored preset into editable form state. Fields the preset omits
+ * mean "use the scenario default", so they fall back to the same initial state
+ * the launch form starts from.
+ *
+ * Every pinned technique is kept, including tokens the selector renders no checkbox for —
+ * an aggregate, a converter-qualified token, or a technique this deployment dropped. The
+ * selector shows those as dismissible tags; filtering them here would replace the whole
+ * selection with this scenario's defaults the moment an operator edited the description.
+ */
+export function presetToConfigState(
+ scenario: RegisteredScenario,
+ preset: ScenarioPreset,
+): ScenarioConfigFormState {
+ const defaults = initialPresetConfigState(scenario)
+ const filters = preset.dataset_filters ?? {}
+ const pinned = preset.techniques ?? []
+ return {
+ techniques: pinned.length > 0 ? pinned : defaults.techniques,
+ includeBaseline: scenario.baseline_policy === 'forbidden'
+ ? false
+ : preset.include_baseline ?? defaults.includeBaseline,
+ datasetOverride: (preset.dataset_names ?? []).join(', '),
+ maxDatasetSize: preset.max_dataset_size == null
+ ? defaults.maxDatasetSize
+ : String(preset.max_dataset_size),
+ harmCategoriesFilter: (filters.harm_categories ?? []).join(', '),
+ dataTypesFilter: (filters.data_types ?? []).join(', '),
+ scenarioParamValues: getInitialFormValues(
+ dynamicScenarioParameters(scenario),
+ preset.scenario_params ?? null,
+ ),
+ }
+}
+
+interface PresetIdentity {
+ name: string
+ scenarioName: string
+ description: string
+}
+
+interface PresetWriteContext {
+ scenario: RegisteredScenario
+ /** The document being edited, or `null` when creating. A field it already pins stays pinned. */
+ previous: ScenarioPreset | null
+}
+
+interface ScenarioOwnedDefaults {
+ techniques: string[]
+ includeBaseline: boolean
+ scenarioParams: Record
+}
+
+/** What the scenario would resolve each field to on its own, i.e. what pinning nothing means. */
+function scenarioOwnedDefaults(scenario: RegisteredScenario): ScenarioOwnedDefaults {
+ const parameters = dynamicScenarioParameters(scenario)
+ const built = parameters.length > 0
+ ? buildParametersFromForm(parameters, getInitialFormValues(parameters))
+ : null
+ return {
+ techniques: uniqueTechniqueOptions(scenario).defaultTechniques,
+ includeBaseline: scenario.baseline_policy !== 'forbidden' && scenario.include_baseline_by_default,
+ // A scenario whose untouched form cannot resolve (a required parameter with no
+ // default) has no implicit value to compare against, so every key counts as edited.
+ scenarioParams: built?.ok ? built.parameters ?? {} : {},
+ }
+}
+
+/** Both sides originate from `buildParametersFromForm`, so a structural compare is sound. */
+function matchesDefault(value: unknown, fallback: unknown): boolean {
+ return JSON.stringify(value) === JSON.stringify(fallback)
+}
+
+/**
+ * Compares technique selections as sets, because the two sides are ordered differently:
+ * the selector appends in click order while the scenario lists its defaults in registry
+ * order, and the backend resolves either through a set. A structural compare would treat
+ * a reorder as an edit and pin the whole default list, costing the preset its tracking.
+ */
+function matchesTechniques(value: string[], fallback: string[]): boolean {
+ if (value.length !== fallback.length) {
+ return false
+ }
+ const sortedFallback = [...fallback].sort()
+ return [...value].sort().every((name, index) => name === sortedFallback[index])
+}
+
+function presetScenarioParams(
+ config: ScenarioConfigFields,
+ { scenario, previous }: PresetWriteContext,
+ defaults: Record,
+): Record | null {
+ const stored = previous?.scenario_params ?? {}
+ const edited = Object.entries(config.scenario_params ?? {}).filter(
+ ([name, value]) => (
+ Object.prototype.hasOwnProperty.call(stored, name) || !matchesDefault(value, defaults[name])
+ ),
+ )
+ const merged = {
+ ...Object.fromEntries(edited),
+ ...uneditableScenarioParams(scenario, previous),
+ }
+ return Object.keys(merged).length > 0 ? merged : null
+}
+
+/**
+ * Combines the preset's identity with the resolved scenario config, keeping fields the
+ * operator left at the scenario's default unset so the preset keeps tracking them.
+ */
+export function configToPreset(
+ { name, scenarioName, description }: PresetIdentity,
+ config: ScenarioConfigFields,
+ context: PresetWriteContext,
+): ScenarioPreset {
+ const { scenario, previous } = context
+ const defaults = scenarioOwnedDefaults(scenario)
+ const preset: ScenarioPreset = { name, scenario_name: scenarioName }
+
+ const trimmedDescription = description.trim()
+ if (trimmedDescription.length > 0) {
+ preset.description = trimmedDescription
+ }
+ // The server stamps `author` once, at create. Editing rebuilds the document from
+ // form state, so without carrying it forward every edit would erase it.
+ if (previous?.author != null) {
+ preset.author = previous.author
+ }
+ if (previous?.techniques != null || !matchesTechniques(config.techniques, defaults.techniques)) {
+ preset.techniques = config.techniques
+ }
+ // A forbidden baseline renders the checkbox disabled and forced off, so the form value
+ // carries no operator intent — only whatever the document already said.
+ const includeBaseline = scenario.baseline_policy === 'forbidden'
+ ? previous?.include_baseline
+ : config.include_baseline
+ if (
+ includeBaseline != null
+ && (previous?.include_baseline != null || includeBaseline !== defaults.includeBaseline)
+ ) {
+ preset.include_baseline = includeBaseline
+ }
+ if (config.dataset_names) {
+ preset.dataset_names = config.dataset_names
+ }
+ if (config.max_dataset_size !== undefined) {
+ preset.max_dataset_size = config.max_dataset_size
+ }
+ if (config.dataset_filters) {
+ preset.dataset_filters = config.dataset_filters
+ }
+ const scenarioParams = presetScenarioParams(config, context, defaults.scenarioParams)
+ if (scenarioParams) {
+ preset.scenario_params = scenarioParams
+ }
+ return preset
+}
diff --git a/frontend/src/components/Scenarios/ScenarioDatasetFields.styles.ts b/frontend/src/components/Scenarios/ScenarioDatasetFields.styles.ts
new file mode 100644
index 0000000000..90630877df
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioDatasetFields.styles.ts
@@ -0,0 +1,24 @@
+import { makeStyles } from '@fluentui/react-components'
+
+import {
+ MINIMUM_TOUCH_TARGET_SIZE,
+ mobileTouchTargetHeight,
+ TOUCH_INPUT_QUERY,
+} from '@/styles/touchTargets'
+
+export const useScenarioDatasetFieldsStyles = makeStyles({
+ control: {
+ ...mobileTouchTargetHeight,
+ '& > input': {
+ [TOUCH_INPUT_QUERY]: {
+ minHeight: MINIMUM_TOUCH_TARGET_SIZE,
+ },
+ },
+ },
+ numberInput: {
+ maxWidth: '10rem',
+ [TOUCH_INPUT_QUERY]: {
+ minHeight: MINIMUM_TOUCH_TARGET_SIZE,
+ },
+ },
+})
diff --git a/frontend/src/components/Scenarios/ScenarioDatasetFields.tsx b/frontend/src/components/Scenarios/ScenarioDatasetFields.tsx
new file mode 100644
index 0000000000..126c63ed8a
--- /dev/null
+++ b/frontend/src/components/Scenarios/ScenarioDatasetFields.tsx
@@ -0,0 +1,101 @@
+import { Field, Input } from '@fluentui/react-components'
+
+import type { RegisteredScenario } from '@/types'
+
+import { useScenarioDatasetFieldsStyles } from './ScenarioDatasetFields.styles'
+import { datasetSizeNotApplicable } from './scenarioConfigForm'
+
+interface ScenarioDatasetFieldsProps {
+ scenario: RegisteredScenario
+ datasetOverride: string
+ maxDatasetSize: string
+ harmCategoriesFilter: string
+ dataTypesFilter: string
+ /** The scenario's own cap, shown as guidance so an operator can tell a default from an override. */
+ configuredDefaultMaxDatasetSize: string
+ disabled: boolean
+ onDatasetOverrideChange: (value: string) => void
+ onMaxDatasetSizeChange: (value: string) => void
+ onHarmCategoriesFilterChange: (value: string) => void
+ onDataTypesFilterChange: (value: string) => void
+}
+
+/** Dataset selection and filtering inputs shared by the launch form and the preset editor. */
+export default function ScenarioDatasetFields({
+ scenario,
+ datasetOverride,
+ maxDatasetSize,
+ harmCategoriesFilter,
+ dataTypesFilter,
+ configuredDefaultMaxDatasetSize,
+ disabled,
+ onDatasetOverrideChange,
+ onMaxDatasetSizeChange,
+ onHarmCategoriesFilterChange,
+ onDataTypesFilterChange,
+}: ScenarioDatasetFieldsProps) {
+ const styles = useScenarioDatasetFieldsStyles()
+ const notApplicable = datasetSizeNotApplicable(scenario)
+
+ return (
+ <>
+
+ onDatasetOverrideChange(data.value)}
+ placeholder={scenario.default_datasets.join(', ') || undefined}
+ data-testid="dataset-override-input"
+ />
+
+
+ onMaxDatasetSizeChange(data.value)}
+ data-testid="max-dataset-size-input"
+ />
+
+
+ onHarmCategoriesFilterChange(data.value)}
+ data-testid="harm-categories-filter-input"
+ />
+
+
+ onDataTypesFilterChange(data.value)}
+ data-testid="data-types-filter-input"
+ />
+
+ >
+ )
+}
diff --git a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx
index 3497750ed5..7ee44549f9 100644
--- a/frontend/src/components/Scenarios/ScenarioDetail.test.tsx
+++ b/frontend/src/components/Scenarios/ScenarioDetail.test.tsx
@@ -654,7 +654,7 @@ describe('ScenarioDetail', () => {
expect(screen.queryByText('Backend-resolved preset members')).not.toBeInTheDocument()
})
- it('shows technique descriptions and tags', async () => {
+ it('shows technique descriptions', async () => {
mockGetScenario.mockResolvedValue(
makeScenario({
default_technique: 'default',
@@ -684,8 +684,6 @@ describe('ScenarioDetail', () => {
expect(screen.getByText('Sends the objective directly.')).toBeInTheDocument()
expect(screen.getByText('Places the jailbreak in the system prompt.')).toBeInTheDocument()
- expect(screen.getAllByRole('button', { name: 'Clear Recommended techniques' })).toHaveLength(2)
- expect(screen.getAllByRole('button', { name: 'Clear Single-turn techniques' })).toHaveLength(3)
})
it('renders only concrete techniques and de-duplicates their names', async () => {
@@ -715,32 +713,6 @@ describe('ScenarioDetail', () => {
expect(new Set(request.techniques).size).toBe(request.techniques.length)
})
- it('selects and clears all members of a tag', async () => {
- mockGetScenario.mockResolvedValue(
- makeScenario({
- default_techniques: ['default_technique'],
- all_techniques: ['default_technique', 'crescendo', 'many_shot'],
- technique_summaries: [
- { name: 'default_technique', description: 'Direct attack.', tags: ['single_turn'] },
- { name: 'crescendo', description: 'Escalating attack.', tags: ['multi_turn'] },
- { name: 'many_shot', description: 'Many-shot attack.', tags: ['multi_turn'] },
- ],
- }),
- )
- const user = userEvent.setup()
- renderDetail('/scanner/foundry.red_team_agent')
- await screen.findByTestId('scenario-target-select')
-
- await user.click(screen.getAllByRole('button', { name: 'Select Multi-turn techniques' })[0])
- expect(screen.getByTestId('technique-crescendo')).toBeChecked()
- expect(screen.getByTestId('technique-many_shot')).toBeChecked()
-
- await user.click(screen.getAllByRole('button', { name: 'Clear Multi-turn techniques' })[0])
- expect(screen.getByTestId('technique-crescendo')).not.toBeChecked()
- expect(screen.getByTestId('technique-many_shot')).not.toBeChecked()
- expect(screen.getByTestId('technique-default_technique')).toBeChecked()
- })
-
it('initializes a concrete default as custom and allows adding another concrete technique', async () => {
mockGetScenario.mockResolvedValue(
makeScenario({
@@ -792,20 +764,6 @@ describe('ScenarioDetail', () => {
expect(mockStartRun.mock.calls[0][0].include_baseline).toBe(false)
})
- it('includes the baseline when a shared tag selects or clears its members', async () => {
- const user = userEvent.setup()
- renderDetail('/scanner/foundry.red_team_agent')
- await screen.findByTestId('scenario-target-select')
-
- await user.click(screen.getAllByRole('button', { name: 'Clear Single-turn techniques' })[0])
- expect(screen.getByTestId('baseline-checkbox')).not.toBeChecked()
- expect(screen.getByTestId('technique-default_technique')).not.toBeChecked()
-
- await user.click(screen.getAllByRole('button', { name: 'Select Single-turn techniques' })[0])
- expect(screen.getByTestId('baseline-checkbox')).toBeChecked()
- expect(screen.getByTestId('technique-default_technique')).toBeChecked()
- })
-
it('defaults the baseline checkbox to unchecked when the policy is disabled with include_baseline_by_default false', async () => {
mockGetScenario.mockResolvedValue(
makeScenario({ baseline_policy: 'disabled', include_baseline_by_default: false }),
diff --git a/frontend/src/components/Scenarios/ScenarioDetail.tsx b/frontend/src/components/Scenarios/ScenarioDetail.tsx
index df7c8538ad..39185a7efb 100644
--- a/frontend/src/components/Scenarios/ScenarioDetail.tsx
+++ b/frontend/src/components/Scenarios/ScenarioDetail.tsx
@@ -3,7 +3,6 @@ import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
- Checkbox,
Dialog,
DialogActions,
DialogBody,
@@ -11,7 +10,6 @@ import {
DialogSurface,
DialogTitle,
Field,
- Input,
MessageBar,
MessageBarBody,
mergeClasses,
@@ -19,7 +17,6 @@ import {
Spinner,
Text,
Tooltip,
- ToggleButton,
} from '@fluentui/react-components'
import {
ArrowLeftRegular,
@@ -35,7 +32,6 @@ import { useRuntime } from '@/hooks/useRuntime'
import ParameterField from '@/components/Parameters/ParameterField'
import SingleStepSpinButton from '@/components/Parameters/SingleStepSpinButton'
import {
- buildParametersFromForm,
getInitialFormValues,
type ParameterFormValue,
} from '@/components/Parameters/parameterForm'
@@ -50,42 +46,35 @@ import type {
ScenarioRunEstimateResult,
ScenarioRunSizeEstimateRequest,
ScenarioRunEstimateState,
- ScenarioTechniqueSummary,
TargetInstance,
} from '@/types'
import { routerPathParamValue, scenarioRunRoutePath } from '@/utils/routeParams'
import { sameTarget, targetModelName } from '@/utils/targetIdentity'
import { useScenarioDetailStyles } from './ScenarioDetail.styles'
+import ScenarioDatasetFields from './ScenarioDatasetFields'
import { ScenarioRunEstimateDetails } from './ScenarioRunEstimate'
+import ScenarioTechniqueSelector from './ScenarioTechniqueSelector'
+import {
+ buildScenarioConfig,
+ datasetSizeNotApplicable,
+ defaultMaxDatasetSize,
+ dynamicScenarioParameters,
+ parseDatasetNames,
+ uniqueTechniqueOptions,
+} from './scenarioConfigForm'
import { normalizeScenarioMarkdown } from './scenarioMarkdown'
import { mapScenarioRunEstimate } from './scenarioRunEstimateAdapter'
-import { techniqueSetName } from './scenarioTechniqueSets'
-
-/**
- * Common/opaque parameters every scenario declares via
- * `Scenario._common_scenario_parameters` — the launch form already exposes a
- * purpose-built control for each of these (target, techniques, datasets,
- * labels, concurrency, retries, baseline), and `technique_converters` has no
- * UI at all. They're hidden from the dynamic scenario-specific parameter list.
- */
-const COMMON_SCENARIO_PARAMETER_NAMES = new Set([
- 'objective_target',
- 'scenario_techniques',
- 'technique_converters',
- 'dataset_config',
- 'memory_labels',
- 'max_concurrency',
- 'max_retries',
- 'include_baseline',
-])
+import {
+ DEFAULT_MAX_CONCURRENCY,
+ DEFAULT_MAX_RETRIES,
+ MAX_MAX_CONCURRENCY,
+ MAX_MAX_RETRIES,
+ MIN_MAX_CONCURRENCY,
+ MIN_MAX_RETRIES,
+ resolveSpinButtonValue,
+} from './scenarioRunLimits'
-const MIN_MAX_CONCURRENCY = 1
-const MAX_MAX_CONCURRENCY = 100
-const MIN_MAX_RETRIES = 0
-const MAX_MAX_RETRIES = 20
-const DEFAULT_MAX_CONCURRENCY = 10
-const DEFAULT_MAX_RETRIES = 0
const ESTIMATE_DEBOUNCE_MS = 300
function targetOptionLabel(target: TargetInstance): string {
@@ -95,66 +84,9 @@ function targetOptionLabel(target: TargetInstance): string {
: target.target_registry_name
}
-function defaultMaxDatasetSize(scenario: RegisteredScenario): string {
- const limit = scenario.default_run_size.dataset_limit
- return limit.state === 'value' ? String(limit.value) : ''
-}
-
-/** Resolves a Fluent `SpinButton` change event to a numeric value, preferring the parsed `value` over the raw `displayValue`. */
-function resolveSpinButtonValue(data: { value?: number | null; displayValue?: string }, previous: number): number {
- if (typeof data.value === 'number') {
- return data.value
- }
- const parsed = data.displayValue !== undefined ? Number(data.displayValue) : NaN
- return Number.isFinite(parsed) ? parsed : previous
-}
type LoadStatus = 'loading' | 'success' | 'not-found' | 'error'
-interface TechniqueOptions {
- techniques: ScenarioTechniqueSummary[]
- defaultTechniques: string[]
-}
-
-function uniqueTechniqueOptions(scenario: RegisteredScenario): TechniqueOptions {
- const aggregateNames = new Set(scenario.aggregate_techniques)
- const summariesByName = new Map(
- scenario.technique_summaries.map((summary) => [summary.name, summary]),
- )
- const techniques: ScenarioTechniqueSummary[] = []
- const seen = new Set()
- for (const name of scenario.all_techniques) {
- if (!aggregateNames.has(name) && !seen.has(name)) {
- techniques.push(summariesByName.get(name) ?? { name, description: null, tags: [] })
- seen.add(name)
- }
- }
- const concreteNames = new Set(techniques.map((technique) => technique.name))
- const defaultTechniques = scenario.default_techniques.filter((name) => concreteNames.has(name))
- if (defaultTechniques.length === 0 && concreteNames.has(scenario.default_technique)) {
- defaultTechniques.push(scenario.default_technique)
- }
- return { techniques, defaultTechniques }
-}
-
-interface SelectableTechnique extends ScenarioTechniqueSummary {
- isBaseline: boolean
- disabled: boolean
-}
-
-const BASELINE_TECHNIQUE: ScenarioTechniqueSummary = {
- name: 'baseline',
- description: 'Sends each objective directly to the target for comparison.',
- tags: ['baseline', 'single_turn'],
-}
-
-function parseDatasetNames(datasetOverride: string): string[] {
- return datasetOverride
- .split(',')
- .map((entry) => entry.trim())
- .filter((entry) => entry.length > 0)
-}
-
function formatParameterPreview(value: ParameterFormValue | undefined): string {
if (Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : 'Not set'
@@ -284,69 +216,19 @@ type EstimateRequestState =
error: string
}
-function buildEstimateRequest({
- scenario,
- targetName,
- adversarialTargetName,
- techniques,
- dynamicParameters,
- scenarioParamValues,
- datasetOverride,
- maxDatasetSize,
- harmCategoriesFilter,
- dataTypesFilter,
- includeBaseline,
-}: BuildEstimateRequestInput): BuildEstimateRequestResult {
- if (techniques.length === 0) {
- return { ok: false, error: 'Select at least one technique.' }
- }
-
- let scenarioParams: Record | null = null
- if (dynamicParameters.length > 0) {
- const result = buildParametersFromForm(dynamicParameters, scenarioParamValues)
- if (!result.ok) {
- return result
- }
- scenarioParams = result.parameters
- }
-
- let maxDatasetSizeValue: number | undefined
- const trimmedMaxDatasetSize = maxDatasetSize.trim()
- if (trimmedMaxDatasetSize.length > 0) {
- const parsed = Number(trimmedMaxDatasetSize)
- if (!Number.isInteger(parsed) || parsed < 1) {
- return { ok: false, error: 'Max dataset size must be a positive integer.' }
- }
- maxDatasetSizeValue = parsed
- }
- const datasetNames = parseDatasetNames(datasetOverride)
- const request: ScenarioRunSizeEstimateRequest = {
- techniques,
- include_baseline: includeBaseline,
+function buildEstimateRequest(input: BuildEstimateRequestInput): BuildEstimateRequestResult {
+ const configResult = buildScenarioConfig(input)
+ if (!configResult.ok) {
+ return configResult
}
+ const { scenario, targetName, adversarialTargetName } = input
+ const request: ScenarioRunSizeEstimateRequest = { ...configResult.config }
if (targetName) {
request.target_name = targetName
}
if (scenario.uses_default_adversarial_target && adversarialTargetName) {
request.adversarial_target_name = adversarialTargetName
}
- if (datasetNames.length > 0) {
- request.dataset_names = datasetNames
- }
- if (maxDatasetSizeValue !== undefined) {
- request.max_dataset_size = maxDatasetSizeValue
- }
- const harmCategories = parseDatasetNames(harmCategoriesFilter)
- const dataTypes = parseDatasetNames(dataTypesFilter)
- if (harmCategories.length > 0 || dataTypes.length > 0) {
- request.dataset_filters = {
- ...(harmCategories.length > 0 ? { harm_categories: harmCategories } : {}),
- ...(dataTypes.length > 0 ? { data_types: dataTypes } : {}),
- }
- }
- if (scenarioParams) {
- request.scenario_params = scenarioParams
- }
return { ok: true, request }
}
@@ -579,10 +461,8 @@ function ScenarioLaunchForm({
[scenario],
)
const dynamicParameters = useMemo(
- () => scenario.supported_parameters.filter(
- (parameter) => !COMMON_SCENARIO_PARAMETER_NAMES.has(parameter.name),
- ),
- [scenario.supported_parameters],
+ () => dynamicScenarioParameters(scenario),
+ [scenario],
)
const isBaselineForbidden = scenario.baseline_policy === 'forbidden'
@@ -652,27 +532,12 @@ function ScenarioLaunchForm({
}
}
- const selectableTechniques = useMemo(
- () => [
- {
- ...BASELINE_TECHNIQUE,
- isBaseline: true,
- disabled: isBaselineForbidden,
- },
- ...techniqueOptions.map((technique) => ({
- ...technique,
- isBaseline: false,
- disabled: false,
- })),
- ],
- [isBaselineForbidden, techniqueOptions],
- )
const techniques = selectedTechniques
const maxDatasetSizeOverride = maxDatasetSize.trim()
&& maxDatasetSize !== configuredDefaultMaxDatasetSize
? maxDatasetSize
: ''
- const datasetSizeLabel = scenario.default_run_size.dataset_limit.state === 'not_applicable'
+ const datasetSizeLabel = datasetSizeNotApplicable(scenario)
? 'Not applicable'
: maxDatasetSize.trim() || configuredDefaultMaxDatasetSize || 'Scenario default'
const estimateResult = useMemo(
@@ -840,48 +705,6 @@ function ScenarioLaunchForm({
estimateState = { status: 'loading', scope: 'request' }
}
- const handleTechniqueChange = (technique: SelectableTechnique, checked: boolean): void => {
- if (technique.isBaseline) {
- setBaselineChecked(checked)
- } else {
- setSelectedTechniques((current) => {
- if (checked) {
- return current.includes(technique.name)
- ? current
- : [...current, technique.name]
- }
- return current.filter((name) => name !== technique.name)
- })
- }
- setValidationError(null)
- }
-
- const isTechniqueSelected = (technique: SelectableTechnique): boolean => (
- technique.isBaseline ? baselineChecked : selectedTechniques.includes(technique.name)
- )
-
- const handleTagChange = (tag: string): void => {
- const members = selectableTechniques.filter(
- (technique) => !technique.disabled && technique.tags.includes(tag),
- )
- const shouldSelect = members.some((technique) => !isTechniqueSelected(technique))
- const memberNames = new Set(
- members.filter((technique) => !technique.isBaseline).map((technique) => technique.name),
- )
- setSelectedTechniques((current) => {
- const selected = new Set(current)
- for (const name of memberNames) {
- if (shouldSelect) selected.add(name)
- else selected.delete(name)
- }
- return techniqueOptions.map((technique) => technique.name).filter((name) => selected.has(name))
- })
- if (members.some((technique) => technique.isBaseline)) {
- setBaselineChecked(shouldSelect)
- }
- setValidationError(null)
- }
-
const updateScenarioParam = (name: string, value: ParameterFormValue): void => {
setScenarioParamValues((current) => ({ ...current, [name]: value }))
}
@@ -1033,70 +856,21 @@ function ScenarioLaunchForm({
)}
-
-
- Techniques
-
-
- Select individual techniques, or use a tag to select or clear all techniques with that tag.
-
- {techniqueSelectionInvalid && (
-
- Select at least one attack technique.
-
- )}
-
- {selectableTechniques.map((technique) => {
- const selected = isTechniqueSelected(technique)
- return (
-
-
handleTechniqueChange(technique, data.checked === true)}
- data-testid={technique.isBaseline ? 'baseline-checkbox' : `technique-${technique.name}`}
- />
-
- {technique.description && (
-
{technique.description}
- )}
- {technique.tags.length > 0 && (
-
- {technique.tags.map((tag) => {
- const tagMembers = selectableTechniques.filter(
- (candidate) => !candidate.disabled && candidate.tags.includes(tag),
- )
- const tagSelected = tagMembers.length > 0 && tagMembers.every(isTechniqueSelected)
- return (
- handleTagChange(tag)}
- aria-label={`${tagSelected ? 'Clear' : 'Select'} ${techniqueSetName(tag)} techniques`}
- >
- {techniqueSetName(tag)}
-
- )
- })}
-
- )}
- {technique.disabled && (
-
- This scenario does not support a baseline comparison.
-
- )}
-
-
- )
- })}
-
-
+ {
+ setSelectedTechniques(next)
+ setValidationError(null)
+ }}
+ onIncludeBaselineChange={(next) => {
+ setBaselineChecked(next)
+ setValidationError(null)
+ }}
+ />
@@ -1126,63 +900,19 @@ function ScenarioLaunchForm({
disabled={submitting}
/>
)}
-
- setDatasetOverride(data.value)}
- placeholder={scenario.default_datasets.join(', ') || undefined}
- data-testid="dataset-override-input"
- />
-
-
- setMaxDatasetSize(data.value)}
- data-testid="max-dataset-size-input"
- />
-
-
- setHarmCategoriesFilter(data.value)}
- data-testid="harm-categories-filter-input"
- />
-
-
- setDataTypesFilter(data.value)}
- data-testid="data-types-filter-input"
- />
-
+
void
+ onIncludeBaselineChange: (includeBaseline: boolean) => void
+}
+
+/**
+ * Technique picker shared by the scenario launch form and the preset editor.
+ *
+ * Baseline is rendered as a pseudo-technique so an operator sees one list, but it
+ * travels as its own flag because the backend models it as `include_baseline`.
+ *
+ * A selection may hold tokens no checkbox represents: an aggregate such as `all`, or a
+ * technique carrying a converter modifier (`role_play:converter.translation_spanish`).
+ * Those render as dismissible tags, because leaving them out would show every checkbox
+ * cleared while the run still executes them.
+ */
+export default function ScenarioTechniqueSelector({
+ techniqueOptions,
+ selectedTechniques,
+ includeBaseline,
+ isBaselineForbidden,
+ disabled,
+ onTechniquesChange,
+ onIncludeBaselineChange,
+}: ScenarioTechniqueSelectorProps) {
+ const styles = useScenarioTechniqueSelectorStyles()
+ const titleId = useId()
+
+ const selectableTechniques = useMemo(
+ () => buildSelectableTechniques(techniqueOptions, isBaselineForbidden),
+ [isBaselineForbidden, techniqueOptions],
+ )
+
+ const isTechniqueSelected = (technique: SelectableTechnique): boolean => (
+ technique.isBaseline ? includeBaseline : selectedTechniques.includes(technique.name)
+ )
+
+ const tokensWithoutCheckbox = useMemo(() => {
+ const checkboxNames = new Set(
+ selectableTechniques.filter((technique) => !technique.isBaseline).map((technique) => technique.name),
+ )
+ return selectedTechniques.filter((token) => !checkboxNames.has(token))
+ }, [selectableTechniques, selectedTechniques])
+
+ const handleTechniqueChange = (technique: SelectableTechnique, checked: boolean): void => {
+ if (technique.isBaseline) {
+ onIncludeBaselineChange(checked)
+ return
+ }
+ if (checked) {
+ if (!selectedTechniques.includes(technique.name)) {
+ onTechniquesChange([...selectedTechniques, technique.name])
+ }
+ return
+ }
+ onTechniquesChange(selectedTechniques.filter((name) => name !== technique.name))
+ }
+
+ return (
+
+
+ Techniques
+
+
+ Select individual techniques.
+
+ {selectedTechniques.length === 0 && (
+
+ Select at least one attack technique.
+
+ )}
+ {tokensWithoutCheckbox.length > 0 && (
+
+
+ Also running, with no checkbox of their own:
+
+ onTechniquesChange(
+ selectedTechniques.filter((name) => name !== data.value),
+ )}
+ >
+ {tokensWithoutCheckbox.map((token) => (
+
+ {token}
+
+ ))}
+
+
+ )}
+
+ {selectableTechniques.map((technique) => {
+ const selected = isTechniqueSelected(technique)
+ return (
+
+
handleTechniqueChange(technique, data.checked === true)}
+ data-testid={technique.isBaseline ? 'baseline-checkbox' : `technique-${technique.name}`}
+ />
+
+ {technique.description && (
+ {technique.description}
+ )}
+ {technique.disabled && (
+
+ This scenario does not support a baseline comparison.
+
+ )}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/src/components/Scenarios/scenarioConfigForm.ts b/frontend/src/components/Scenarios/scenarioConfigForm.ts
new file mode 100644
index 0000000000..acf28af3d8
--- /dev/null
+++ b/frontend/src/components/Scenarios/scenarioConfigForm.ts
@@ -0,0 +1,210 @@
+import {
+ buildParametersFromForm,
+ getInitialFormValues,
+ type ParameterFormValue,
+} from '@/components/Parameters/parameterForm'
+import type { Parameter, RegisteredScenario, ScenarioTechniqueSummary } from '@/types'
+
+/**
+ * Common/opaque parameters every scenario declares via
+ * `Scenario._common_scenario_parameters` — the launch form already exposes a
+ * purpose-built control for each of these (target, techniques, datasets,
+ * labels, concurrency, retries, baseline), and `technique_converters` has no
+ * UI at all. They're hidden from the dynamic scenario-specific parameter list.
+ */
+const COMMON_SCENARIO_PARAMETER_NAMES = new Set([
+ 'objective_target',
+ 'scenario_techniques',
+ 'technique_converters',
+ 'dataset_config',
+ 'memory_labels',
+ 'max_concurrency',
+ 'max_retries',
+ 'include_baseline',
+])
+
+export const BASELINE_TECHNIQUE: ScenarioTechniqueSummary = {
+ name: 'baseline',
+ description: 'Sends each objective directly to the target for comparison.',
+ tags: ['baseline', 'single_turn'],
+}
+
+/** A technique checkbox, including the baseline pseudo-technique the scenario may forbid. */
+export interface SelectableTechnique extends ScenarioTechniqueSummary {
+ isBaseline: boolean
+ disabled: boolean
+}
+
+/** The scenario-owned configuration both the launch form and the preset editor collect. */
+export interface ScenarioConfigFormState {
+ techniques: string[]
+ includeBaseline: boolean
+ datasetOverride: string
+ maxDatasetSize: string
+ harmCategoriesFilter: string
+ dataTypesFilter: string
+ scenarioParamValues: Record
+}
+
+/** The resolved scenario-owned fields shared by an estimate request, a run request, and a preset. */
+export interface ScenarioConfigFields {
+ techniques: string[]
+ include_baseline: boolean
+ dataset_names?: string[]
+ max_dataset_size?: number
+ dataset_filters?: Record
+ scenario_params?: Record
+}
+
+export type BuildScenarioConfigResult =
+ | { ok: true; config: ScenarioConfigFields }
+ | { ok: false; error: string }
+
+interface TechniqueOptions {
+ techniques: ScenarioTechniqueSummary[]
+ defaultTechniques: string[]
+}
+
+export function parseDatasetNames(datasetOverride: string): string[] {
+ return datasetOverride
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0)
+}
+
+/**
+ * The scenario's own declared dataset cap, or `''` when it declares none and when the
+ * scenario sizes itself by prompt generation instead. Read from the scenario's declared
+ * limit rather than counted from the seeds this deployment happens to have loaded, so the
+ * value means the same thing everywhere.
+ */
+export function defaultMaxDatasetSize(scenario: RegisteredScenario): string {
+ const limit = scenario.default_run_size.dataset_limit
+ return limit.state === 'value' ? String(limit.value) : ''
+}
+
+/** A scenario sized by prompt generation has no dataset cap to set. */
+export function datasetSizeNotApplicable(scenario: RegisteredScenario): boolean {
+ return scenario.default_run_size.dataset_limit.state === 'not_applicable'
+}
+
+export function uniqueTechniqueOptions(scenario: RegisteredScenario): TechniqueOptions {
+ const aggregateNames = new Set(scenario.aggregate_techniques)
+ const summariesByName = new Map(
+ scenario.technique_summaries.map((summary) => [summary.name, summary]),
+ )
+ const techniques: ScenarioTechniqueSummary[] = []
+ const seen = new Set()
+ for (const name of scenario.all_techniques) {
+ if (!aggregateNames.has(name) && !seen.has(name)) {
+ techniques.push(summariesByName.get(name) ?? { name, description: null, tags: [] })
+ seen.add(name)
+ }
+ }
+ const concreteNames = new Set(techniques.map((technique) => technique.name))
+ const defaultTechniques = scenario.default_techniques.filter((name) => concreteNames.has(name))
+ if (defaultTechniques.length === 0 && concreteNames.has(scenario.default_technique)) {
+ defaultTechniques.push(scenario.default_technique)
+ }
+ return { techniques, defaultTechniques }
+}
+
+/** Scenario-specific parameters, excluding the common ones that already have a purpose-built control. */
+export function dynamicScenarioParameters(scenario: RegisteredScenario): Parameter[] {
+ return scenario.supported_parameters.filter(
+ (parameter) => !COMMON_SCENARIO_PARAMETER_NAMES.has(parameter.name),
+ )
+}
+
+export function buildSelectableTechniques(
+ techniqueOptions: ScenarioTechniqueSummary[],
+ isBaselineForbidden: boolean,
+): SelectableTechnique[] {
+ return [
+ { ...BASELINE_TECHNIQUE, isBaseline: true, disabled: isBaselineForbidden },
+ ...techniqueOptions.map((technique) => ({ ...technique, isBaseline: false, disabled: false })),
+ ]
+}
+
+export function initialScenarioConfigState(scenario: RegisteredScenario): ScenarioConfigFormState {
+ return {
+ techniques: uniqueTechniqueOptions(scenario).defaultTechniques,
+ includeBaseline: scenario.baseline_policy !== 'forbidden' && scenario.include_baseline_by_default,
+ datasetOverride: '',
+ maxDatasetSize: defaultMaxDatasetSize(scenario),
+ harmCategoriesFilter: '',
+ dataTypesFilter: '',
+ scenarioParamValues: getInitialFormValues(dynamicScenarioParameters(scenario)),
+ }
+}
+
+interface BuildScenarioConfigInput {
+ techniques: string[]
+ dynamicParameters: Parameter[]
+ scenarioParamValues: Record
+ datasetOverride: string
+ maxDatasetSize: string
+ harmCategoriesFilter: string
+ dataTypesFilter: string
+ includeBaseline: boolean
+}
+
+/**
+ * Resolves the scenario-owned form state into the fields an estimate, a run, or a preset carries.
+ * Fields the operator left blank are omitted rather than sent as an empty value, so the scenario's
+ * own default still applies downstream.
+ */
+export function buildScenarioConfig({
+ techniques,
+ dynamicParameters,
+ scenarioParamValues,
+ datasetOverride,
+ maxDatasetSize,
+ harmCategoriesFilter,
+ dataTypesFilter,
+ includeBaseline,
+}: BuildScenarioConfigInput): BuildScenarioConfigResult {
+ if (techniques.length === 0) {
+ return { ok: false, error: 'Select at least one technique.' }
+ }
+
+ let scenarioParams: Record | null = null
+ if (dynamicParameters.length > 0) {
+ const result = buildParametersFromForm(dynamicParameters, scenarioParamValues)
+ if (!result.ok) {
+ return result
+ }
+ scenarioParams = result.parameters
+ }
+
+ let maxDatasetSizeValue: number | undefined
+ const trimmedMaxDatasetSize = maxDatasetSize.trim()
+ if (trimmedMaxDatasetSize.length > 0) {
+ const parsed = Number(trimmedMaxDatasetSize)
+ if (!Number.isInteger(parsed) || parsed < 1) {
+ return { ok: false, error: 'Max dataset size must be a positive integer.' }
+ }
+ maxDatasetSizeValue = parsed
+ }
+
+ const config: ScenarioConfigFields = { techniques, include_baseline: includeBaseline }
+ const datasetNames = parseDatasetNames(datasetOverride)
+ if (datasetNames.length > 0) {
+ config.dataset_names = datasetNames
+ }
+ if (maxDatasetSizeValue !== undefined) {
+ config.max_dataset_size = maxDatasetSizeValue
+ }
+ const harmCategories = parseDatasetNames(harmCategoriesFilter)
+ const dataTypes = parseDatasetNames(dataTypesFilter)
+ if (harmCategories.length > 0 || dataTypes.length > 0) {
+ config.dataset_filters = {
+ ...(harmCategories.length > 0 ? { harm_categories: harmCategories } : {}),
+ ...(dataTypes.length > 0 ? { data_types: dataTypes } : {}),
+ }
+ }
+ if (scenarioParams) {
+ config.scenario_params = scenarioParams
+ }
+ return { ok: true, config }
+}
diff --git a/frontend/src/components/Scenarios/scenarioRunLimits.ts b/frontend/src/components/Scenarios/scenarioRunLimits.ts
new file mode 100644
index 0000000000..15b5332576
--- /dev/null
+++ b/frontend/src/components/Scenarios/scenarioRunLimits.ts
@@ -0,0 +1,24 @@
+/**
+ * Launch-owned execution bounds, mirroring the server-side `RunScenarioRequest`
+ * field constraints. Shared by every surface that starts a scenario run so the
+ * client never offers a value the API will reject.
+ */
+
+export const MIN_MAX_CONCURRENCY = 1
+export const MAX_MAX_CONCURRENCY = 100
+export const MIN_MAX_RETRIES = 0
+export const MAX_MAX_RETRIES = 20
+export const DEFAULT_MAX_CONCURRENCY = 10
+export const DEFAULT_MAX_RETRIES = 0
+
+/** Resolves a Fluent `SpinButton` change event to a numeric value, preferring the parsed `value` over the raw `displayValue`. */
+export function resolveSpinButtonValue(
+ data: { value?: number | null; displayValue?: string },
+ previous: number,
+): number {
+ if (typeof data.value === 'number') {
+ return data.value
+ }
+ const parsed = data.displayValue !== undefined ? Number(data.displayValue) : NaN
+ return Number.isFinite(parsed) ? parsed : previous
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 4e4cde69df..5f4150596d 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -35,7 +35,12 @@ import type {
ChangeMainConversationResponse,
ListRegisteredScenariosResponse,
RegisteredScenario,
+ ResolveScenarioPresetRequest,
RunScenarioRequest,
+ ScenarioPreset,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+ UpdateScenarioPresetRequest,
ScenarioRunSizeEstimateResponse,
ScenarioRunSizeEstimateRequest,
ScenarioRunSummary,
@@ -559,4 +564,61 @@ export const scenariosApi = {
return response.data
},
}
+
+export const scenarioPresetsApi = {
+ list: async (
+ signal?: AbortSignal,
+ includeEstimates = true,
+ ): Promise => {
+ const params: Record = {}
+ if (!includeEstimates) params.include_estimates = false
+ const response = await apiClient.get('/scenario-presets', { params, signal })
+ return response.data
+ },
+
+ get: async (name: string): Promise => {
+ const response = await apiClient.get(`/scenario-presets/${encodeURIComponent(name)}`)
+ return response.data
+ },
+
+ /** Creates a preset that must not already exist; the server returns 409 when the name is taken. */
+ create: async (preset: ScenarioPreset): Promise => {
+ const response = await apiClient.post('/scenario-presets', preset)
+ return response.data
+ },
+
+ /**
+ * Replaces a preset the caller has read. `expectedVersion` is the version returned
+ * by the read, so a concurrent edit fails with 409 instead of being overwritten.
+ */
+ update: async (
+ name: string,
+ preset: ScenarioPreset,
+ expectedVersion: string,
+ ): Promise => {
+ const body: UpdateScenarioPresetRequest = { preset, expected_version: expectedVersion }
+ const response = await apiClient.put(`/scenario-presets/${encodeURIComponent(name)}`, body)
+ return response.data
+ },
+
+ remove: async (name: string): Promise => {
+ await apiClient.delete(`/scenario-presets/${encodeURIComponent(name)}`)
+ },
+
+ /**
+ * Combines a stored preset with the launch-owned fields it omits into a runnable request.
+ * Sending `expected_version` fails with 409 when the preset changed after it was read,
+ * so a launch cannot silently run a configuration the operator never saw.
+ */
+ resolve: async (
+ name: string,
+ request: ResolveScenarioPresetRequest,
+ ): Promise => {
+ const response = await apiClient.post(
+ `/scenario-presets/${encodeURIComponent(name)}/resolve`,
+ request,
+ )
+ return response.data
+ },
+}
import type { RuntimeReadiness, RuntimeStatus } from '@/types'
diff --git a/frontend/src/test-utils/scenarioFixtures.ts b/frontend/src/test-utils/scenarioFixtures.ts
new file mode 100644
index 0000000000..caeedd1bb9
--- /dev/null
+++ b/frontend/src/test-utils/scenarioFixtures.ts
@@ -0,0 +1,50 @@
+import type { RegisteredScenario } from '../types'
+
+/**
+ * Builds a `RegisteredScenario` for tests. Technique fields are derived from
+ * one another so a test can override just `all_techniques` (or just
+ * `default_techniques`) and still get a self-consistent scenario.
+ */
+export function makeScenario(overrides: Partial = {}): RegisteredScenario {
+ const description = overrides.description ?? 'Red teams a target.'
+ const defaultTechnique = overrides.default_technique ?? 'default'
+ const aggregateTechniques = overrides.aggregate_techniques ?? ['all', 'default']
+ const defaultTechniques = overrides.default_techniques
+ ?? (aggregateTechniques.includes(defaultTechnique) ? ['default_technique'] : [defaultTechnique])
+ const allTechniques = overrides.all_techniques ?? ['default_technique', 'crescendo']
+ const techniqueSummaries = overrides.technique_summaries ?? allTechniques.map((name) => ({
+ name,
+ description: `${name} description.`,
+ tags: name === 'default_technique' ? ['default', 'single_turn'] : ['multi_turn'],
+ }))
+ return {
+ scenario_name: 'foundry.red_team_agent',
+ scenario_type: 'RedTeamAgentScenario',
+ scenario_version: 1,
+ aggregate_technique_expansions: overrides.aggregate_technique_expansions
+ ?? Object.fromEntries(
+ aggregateTechniques.map((name) => [name, name === defaultTechnique ? defaultTechniques : []]),
+ ),
+ all_techniques: allTechniques,
+ technique_summaries: techniqueSummaries,
+ default_datasets: ['harmbench'],
+ baseline_policy: 'enabled',
+ include_baseline_by_default: true,
+ uses_default_adversarial_target: true,
+ supported_parameters: [],
+ default_run_size: {
+ dataset_size: { kind: 'indeterminate', detail: 'Default sizing is unavailable.' },
+ dataset_limit: { state: 'scenario_default' },
+ estimated_attack_count: null,
+ components: [],
+ datasets: [],
+ note: 'Default sizing is unavailable.',
+ },
+ ...overrides,
+ description,
+ description_markdown: overrides.description_markdown ?? description,
+ default_technique: defaultTechnique,
+ default_techniques: defaultTechniques,
+ aggregate_techniques: aggregateTechniques,
+ }
+}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index ea5333de68..315643186f 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -801,6 +801,70 @@ export interface ListRegisteredScenariosResponse {
pagination: PaginationInfo
}
+/**
+ * A saved, portable scenario configuration.
+ *
+ * Every optional field is tri-state: absent means "use the scenario's own default",
+ * so a preset only pins what the author deliberately chose. It carries no target,
+ * concurrency, retries or labels — those are launch-time decisions supplied by
+ * `ResolveScenarioPresetRequest`.
+ */
+export interface ScenarioPreset {
+ name: string
+ scenario_name: string
+ description?: string | null
+ /** Who created the preset. Descriptive only; never used to authorize a read or a write. */
+ author?: string | null
+ techniques?: string[] | null
+ dataset_names?: string[] | null
+ max_dataset_size?: number | null
+ dataset_filters?: Record | null
+ include_baseline?: boolean | null
+ scenario_params?: Record | null
+}
+
+/** One advisory problem found checking a preset against this deployment's registry. */
+export interface PresetIssue {
+ field: string
+ message: string
+}
+
+export interface ScenarioPresetResponse {
+ preset: ScenarioPreset
+ /** Opaque document version; required to update the preset. */
+ version: string
+ /** Empty when the preset is runnable here. Never blocks a save. */
+ issues: PresetIssue[]
+ /**
+ * How much this preset runs with its own techniques, datasets, and limits.
+ * Only the list endpoint fills this in, and only when estimates are requested;
+ * it stays null for a preset this deployment cannot resolve.
+ */
+ run_size?: ScenarioRunSizeEstimateResponse | null
+}
+
+export interface ScenarioPresetListResponse {
+ source: string
+ items: ScenarioPresetResponse[]
+}
+
+export interface UpdateScenarioPresetRequest {
+ preset: ScenarioPreset
+ expected_version: string
+}
+
+/** The launch-owned fields a preset deliberately omits. */
+export interface ResolveScenarioPresetRequest {
+ expected_version?: string | null
+ target_name: string
+ adversarial_target_name?: string | null
+ initializers?: string[] | null
+ initializer_args?: Record> | null
+ max_concurrency?: number | null
+ max_retries?: number | null
+ labels?: Record | null
+}
+
export interface RunScenarioRequest {
scenario_name: string
target_name: string
diff --git a/pyrit/backend/main.py b/pyrit/backend/main.py
index 25fd7e0998..8434b55410 100644
--- a/pyrit/backend/main.py
+++ b/pyrit/backend/main.py
@@ -37,6 +37,7 @@
labels,
media,
message_sends,
+ scenario_presets,
scenarios,
scorers,
scores,
@@ -128,6 +129,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
app.include_router(converters.router, prefix="/api", tags=["converters"])
app.include_router(datasets.router, prefix="/api", tags=["datasets"])
app.include_router(scenarios.router, prefix="/api", tags=["scenarios"])
+app.include_router(scenario_presets.router, prefix="/api", tags=["scenario-presets"])
app.include_router(initializers.router, prefix="/api", tags=["initializers"])
app.include_router(labels.router, prefix="/api", tags=["labels"])
app.include_router(health.router, prefix="/api", tags=["health"])
diff --git a/pyrit/backend/middleware/auth.py b/pyrit/backend/middleware/auth.py
index face0d3d3b..8ca60627b6 100644
--- a/pyrit/backend/middleware/auth.py
+++ b/pyrit/backend/middleware/auth.py
@@ -74,6 +74,25 @@ def require_admin(request: Request) -> None:
)
+def current_user_name(request: Request) -> str | None:
+ """
+ Return a display name for the signed-in user, or None when nobody is identified.
+
+ Authentication is optional in PyRIT, so most deployments have no identity to
+ report and callers must treat the result as unknown rather than anonymous.
+
+ Args:
+ request (Request): The request whose authenticated user to read.
+
+ Returns:
+ str | None: The user's display name, their email when no name is set, or None.
+ """
+ user = getattr(request.state, "user", None)
+ if not isinstance(user, AuthenticatedUser):
+ return None
+ return user.name or user.email or None
+
+
class EntraAuthMiddleware(BaseHTTPMiddleware):
"""Authenticate API requests through Microsoft Graph."""
diff --git a/pyrit/backend/models/__init__.py b/pyrit/backend/models/__init__.py
index 9167c0c529..ff5e57f1d2 100644
--- a/pyrit/backend/models/__init__.py
+++ b/pyrit/backend/models/__init__.py
@@ -63,6 +63,13 @@
ListRegisteredInitializersResponse,
RegisterInitializerRequest,
)
+ from pyrit.backend.models.scenario_presets import (
+ PresetIssue,
+ ResolveScenarioPresetRequest,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+ UpdateScenarioPresetRequest,
+ )
from pyrit.backend.models.scenarios import ListRegisteredScenariosResponse, ScenarioRunListResponse
from pyrit.backend.models.scorers import (
CreateScorerRequest,
@@ -114,6 +121,11 @@
"PreviewStep": "pyrit.backend.models.converters",
"DatasetInfo": "pyrit.backend.models.datasets",
"DatasetListResponse": "pyrit.backend.models.datasets",
+ "PresetIssue": "pyrit.backend.models.scenario_presets",
+ "ResolveScenarioPresetRequest": "pyrit.backend.models.scenario_presets",
+ "ScenarioPresetListResponse": "pyrit.backend.models.scenario_presets",
+ "ScenarioPresetResponse": "pyrit.backend.models.scenario_presets",
+ "UpdateScenarioPresetRequest": "pyrit.backend.models.scenario_presets",
"ListRegisteredScenariosResponse": "pyrit.backend.models.scenarios",
"CreateScorerRequest": "pyrit.backend.models.scorers",
"ScorerListResponse": "pyrit.backend.models.scorers",
diff --git a/pyrit/backend/models/scenario_presets.py b/pyrit/backend/models/scenario_presets.py
new file mode 100644
index 0000000000..cfc1dda45b
--- /dev/null
+++ b/pyrit/backend/models/scenario_presets.py
@@ -0,0 +1,114 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+REST envelopes for the scenario preset endpoints.
+
+The canonical preset types (``ScenarioPreset``, ``StoredPreset``) live in
+``pyrit.models.catalog.scenario_preset`` and are imported from there directly.
+The models here only add the wire-level concerns: the storage source, the
+advisory issues attached to a read, and the launch-owned fields that a preset
+deliberately does not carry.
+"""
+
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+from pyrit.models.catalog.scenario import ScenarioRunSizeEstimate
+from pyrit.models.catalog.scenario_preset import ScenarioPreset
+
+__all__ = [
+ "PresetIssue",
+ "ResolveScenarioPresetRequest",
+ "ScenarioPresetListResponse",
+ "ScenarioPresetResponse",
+ "UpdateScenarioPresetRequest",
+]
+
+
+class PresetIssue(BaseModel):
+ """
+ One advisory problem found while checking a preset against the live registry.
+
+ Issues are reported, never enforced. A preset that names a scenario this
+ deployment has not registered is still a valid preset; it simply cannot run
+ here yet. Blocking the save would make presets un-portable between
+ deployments, which is the one property they exist to have.
+ """
+
+ field: str = Field(..., description="Preset field the issue applies to, e.g. 'scenario_name' or 'techniques'")
+ message: str = Field(..., description="Human-readable description of why the reference does not resolve")
+
+
+class ScenarioPresetResponse(BaseModel):
+ """A stored preset, the version of the document it was read from, and its advisory issues."""
+
+ preset: ScenarioPreset = Field(..., description="The stored preset")
+ version: str = Field(..., description="Opaque version of the stored document; required to update it")
+ issues: list[PresetIssue] = Field(
+ default_factory=list,
+ description="Advisory problems resolving this preset against the live registry; empty when it is runnable",
+ )
+ run_size: ScenarioRunSizeEstimate | None = Field(
+ None,
+ description=(
+ "Run size for this preset's own techniques, datasets, and limits rather than the scenario default. "
+ "Only the list endpoint fills this in, and only when estimates are requested; it stays None for a "
+ "preset that cannot be resolved in this deployment."
+ ),
+ )
+
+
+class ScenarioPresetListResponse(BaseModel):
+ """The configured preset storage source and every preset readable from it."""
+
+ source: str = Field(..., description="Credential-free configured preset source")
+ items: list[ScenarioPresetResponse] = Field(..., description="Stored presets, sorted by name")
+
+
+class UpdateScenarioPresetRequest(BaseModel):
+ """
+ Request body for updating an existing preset.
+
+ ``expected_version`` is required rather than optional because updating is a
+ distinct operation from creating: a client that has not read the document it
+ is replacing has nothing to be optimistic about. Creation goes through POST,
+ where no version exists to supply.
+ """
+
+ preset: ScenarioPreset = Field(..., description="The replacement preset")
+ expected_version: str = Field(..., description="Version returned when the preset being edited was read")
+
+
+class ResolveScenarioPresetRequest(BaseModel):
+ """
+ The launch-owned fields a preset deliberately omits.
+
+ A preset answers *what to test*; these answer *how and where*. Resolution is
+ a union of the two, which is why nothing here overlaps a preset field.
+
+ ``expected_version`` is the exception: it is a precondition rather than a
+ launch field. A client that previewed a resolution sends the version it
+ previewed so an edit landing in between is reported instead of silently
+ launching a configuration the operator never saw.
+ """
+
+ expected_version: str | None = Field(
+ None, description="Version the caller resolved against; omit to resolve whatever is stored now"
+ )
+ target_name: str = Field(..., description="Name of a registered target from the TargetRegistry")
+ adversarial_target_name: str | None = Field(
+ None, description="Name of a registered adversarial target, when the scenario uses one"
+ )
+ initializers: list[str] | None = Field(None, description="Initializer names to run before the scenario")
+ initializer_args: dict[str, dict[str, Any]] | None = Field(
+ None, description="Per-initializer parameter overrides keyed by initializer name"
+ )
+ max_concurrency: int | None = Field(
+ None, ge=1, le=100, description="Maximum concurrent operations; omit to use the run default"
+ )
+ max_retries: int | None = Field(
+ None, ge=0, le=20, description="Maximum retry attempts on failure; omit to use the run default"
+ )
+ labels: dict[str, str] | None = Field(None, description="Labels to attach to memory entries")
diff --git a/pyrit/backend/routes/__init__.py b/pyrit/backend/routes/__init__.py
index a14ea13acf..0005923d1f 100644
--- a/pyrit/backend/routes/__init__.py
+++ b/pyrit/backend/routes/__init__.py
@@ -19,6 +19,7 @@
initializers,
labels,
media,
+ scenario_presets,
scenarios,
scorers,
targets,
@@ -33,6 +34,7 @@
"initializers": ("pyrit.backend.routes.initializers", None),
"labels": ("pyrit.backend.routes.labels", None),
"media": ("pyrit.backend.routes.media", None),
+ "scenario_presets": ("pyrit.backend.routes.scenario_presets", None),
"scenarios": ("pyrit.backend.routes.scenarios", None),
"scorers": ("pyrit.backend.routes.scorers", None),
"targets": ("pyrit.backend.routes.targets", None),
diff --git a/pyrit/backend/routes/scenario_presets.py b/pyrit/backend/routes/scenario_presets.py
new file mode 100644
index 0000000000..1a3efd32c2
--- /dev/null
+++ b/pyrit/backend/routes/scenario_presets.py
@@ -0,0 +1,291 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Scenario preset API routes.
+
+Presets are data, not code, so unlike custom initializers they are not gated behind
+an ``allow_`` switch. Reads are open and mutations require an admin, matching how
+the rest of the API treats shared, server-side configuration.
+
+Route structure:
+ GET /api/scenario-presets — list every stored preset
+ GET /api/scenario-presets/{name} — get one preset and its version
+ POST /api/scenario-presets — create a preset that must not exist
+ PUT /api/scenario-presets/{name} — replace a preset the caller has read
+ DELETE /api/scenario-presets/{name} — delete a preset
+ POST /api/scenario-presets/{name}/resolve — combine a preset with launch fields
+"""
+
+from typing import Annotated
+
+from azure.core.exceptions import AzureError
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+
+from pyrit.backend.middleware.auth import current_user_name, require_admin
+from pyrit.backend.models.common import ProblemDetail
+from pyrit.backend.models.scenario_presets import (
+ ResolveScenarioPresetRequest,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+ UpdateScenarioPresetRequest,
+)
+from pyrit.backend.services.scenario_preset_service import (
+ ScenarioPresetNotFoundError,
+ get_scenario_preset_service,
+)
+from pyrit.models.catalog import RunScenarioRequest, ScenarioPreset
+from pyrit.registry import ScenarioPresetConflictError
+
+router = APIRouter(prefix="/scenario-presets", tags=["scenario-presets"])
+
+
+def _storage_unavailable() -> HTTPException:
+ """
+ Create a sanitized response for unavailable preset storage.
+
+ Returns:
+ HTTPException: A service-unavailable response without SDK details.
+ """
+ return HTTPException(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail="Scenario preset storage is temporarily unavailable",
+ )
+
+
+def _not_found(name: str) -> HTTPException:
+ """
+ Create a not-found response for a missing preset.
+
+ Args:
+ name: The requested preset name.
+
+ Returns:
+ HTTPException: A not-found response naming the preset.
+ """
+ return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Scenario preset '{name}' not found")
+
+
+async def _load_preset_or_404_async(name: str) -> ScenarioPresetResponse:
+ """
+ Read one preset, translating storage failures into HTTP responses.
+
+ Args:
+ name: The preset name.
+
+ Returns:
+ ScenarioPresetResponse: The stored preset, its version, and its advisory issues.
+
+ Raises:
+ HTTPException: 404 if no preset is stored, 400 for an illegal name, 503 if storage fails.
+ """
+ try:
+ preset = await get_scenario_preset_service().get_preset_async(name=name)
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
+
+ if preset is None:
+ raise _not_found(name)
+ return preset
+
+
+@router.get("", response_model=ScenarioPresetListResponse)
+async def list_scenario_presets( # pyrit-async-suffix-exempt
+ include_estimates: bool = Query(True, description="Wait for each preset's own run-size estimate"),
+) -> ScenarioPresetListResponse:
+ """
+ List every readable preset from the configured storage source.
+
+ Presets that cannot be parsed are skipped rather than failing the listing.
+
+ Args:
+ include_estimates: Whether to size each preset. Sizing constructs every scenario,
+ so a caller that wants to render immediately can list without it and ask again.
+
+ Returns:
+ ScenarioPresetListResponse: The configured source and the stored presets.
+ """
+ try:
+ return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+
+
+@router.get(
+ "/{name}",
+ response_model=ScenarioPresetResponse,
+ responses={404: {"model": ProblemDetail, "description": "Preset not found"}},
+)
+async def get_scenario_preset(name: str) -> ScenarioPresetResponse: # pyrit-async-suffix-exempt
+ """
+ Get one preset and the version required to update it.
+
+ Args:
+ name: The preset name.
+
+ Returns:
+ ScenarioPresetResponse: The stored preset, its version, and its advisory issues.
+ """
+ return await _load_preset_or_404_async(name)
+
+
+@router.post(
+ "",
+ response_model=ScenarioPresetResponse,
+ status_code=status.HTTP_201_CREATED,
+ dependencies=[Depends(require_admin)],
+ responses={
+ 409: {"model": ProblemDetail, "description": "A preset is already stored under this name"},
+ },
+)
+async def create_scenario_preset( # pyrit-async-suffix-exempt
+ preset: ScenarioPreset,
+ author: Annotated[str | None, Depends(current_user_name)],
+) -> ScenarioPresetResponse:
+ """
+ Create a preset that must not already exist.
+
+ References that do not resolve in this deployment are reported on the response
+ rather than rejected, so a preset authored elsewhere can still be stored here.
+
+ An author supplied in the body is kept, so importing a preset written elsewhere
+ preserves who wrote it; otherwise the signed-in user is recorded.
+
+ Args:
+ preset: The preset to create.
+ author: The signed-in user, or None when the deployment has no authentication.
+
+ Returns:
+ ScenarioPresetResponse: The persisted preset, its version, and its advisory issues.
+ """
+ if preset.author is None and author is not None:
+ preset = preset.model_copy(update={"author": author})
+
+ try:
+ return await get_scenario_preset_service().save_preset_async(preset=preset, expected_version=None)
+ except ScenarioPresetConflictError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Scenario preset '{preset.name}' already exists",
+ ) from exc
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+
+
+@router.put(
+ "/{name}",
+ response_model=ScenarioPresetResponse,
+ dependencies=[Depends(require_admin)],
+ responses={
+ 400: {"model": ProblemDetail, "description": "Body name does not match the path"},
+ 409: {"model": ProblemDetail, "description": "The preset changed after it was read"},
+ },
+)
+async def update_scenario_preset( # pyrit-async-suffix-exempt
+ name: str,
+ body: UpdateScenarioPresetRequest,
+) -> ScenarioPresetResponse:
+ """
+ Replace a preset the caller has read.
+
+ The update fails if the stored document no longer matches ``expected_version``,
+ so a concurrent edit is reported instead of being silently overwritten.
+
+ Args:
+ name: The preset name from the path, which is authoritative.
+ body: The replacement preset and the version it is replacing.
+
+ Returns:
+ ScenarioPresetResponse: The persisted preset, its new version, and its advisory issues.
+
+ Raises:
+ HTTPException: 400 if the body names a different preset, 409 if the stored version moved.
+ """
+ if body.preset.name != name:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Preset name '{body.preset.name}' does not match path '{name}'",
+ )
+
+ try:
+ return await get_scenario_preset_service().save_preset_async(
+ preset=body.preset, expected_version=body.expected_version
+ )
+ except ScenarioPresetConflictError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
+ ) from exc
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+
+
+@router.delete(
+ "/{name}",
+ status_code=status.HTTP_204_NO_CONTENT,
+ dependencies=[Depends(require_admin)],
+ responses={404: {"model": ProblemDetail, "description": "Preset not found"}},
+)
+async def delete_scenario_preset(name: str) -> None: # pyrit-async-suffix-exempt
+ """
+ Delete one stored preset.
+
+ Args:
+ name: The preset name.
+
+ Raises:
+ HTTPException: 404 if no preset is stored, 400 for an illegal name, 503 if storage fails.
+ """
+ try:
+ await get_scenario_preset_service().delete_preset_async(name=name)
+ except ScenarioPresetNotFoundError:
+ raise _not_found(name) from None
+ except AzureError as exc:
+ raise _storage_unavailable() from exc
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
+
+
+@router.post(
+ "/{name}/resolve",
+ response_model=RunScenarioRequest,
+ responses={
+ 404: {"model": ProblemDetail, "description": "Preset not found"},
+ 409: {"model": ProblemDetail, "description": "The preset changed after it was read"},
+ },
+)
+async def resolve_scenario_preset( # pyrit-async-suffix-exempt
+ name: str,
+ body: ResolveScenarioPresetRequest,
+) -> RunScenarioRequest:
+ """
+ Combine a stored preset with the launch-owned fields it omits.
+
+ The result is an ordinary run request for the existing ``POST /scenarios/runs``
+ endpoint. Resolving here rather than in each client keeps one implementation of
+ the merge, so the distinction between "unset" and "set to the default" cannot
+ drift between callers.
+
+ When the caller supplies ``expected_version`` the stored version must still match,
+ so an edit landing between the preview and the launch is reported rather than
+ quietly running a configuration the operator never confirmed.
+
+ Args:
+ name: The preset name.
+ body: The target and execution fields for this launch.
+
+ Returns:
+ RunScenarioRequest: The request to post to the scenario run endpoint.
+
+ Raises:
+ HTTPException: 404 if no preset is stored, 409 if the stored version moved.
+ """
+ stored = await _load_preset_or_404_async(name)
+ if body.expected_version is not None and body.expected_version != stored.version:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
+ )
+ return get_scenario_preset_service().resolve_run_request(preset=stored.preset, launch=body)
diff --git a/pyrit/backend/services/__init__.py b/pyrit/backend/services/__init__.py
index 918bdfcdb0..ed5975bfcf 100644
--- a/pyrit/backend/services/__init__.py
+++ b/pyrit/backend/services/__init__.py
@@ -17,6 +17,7 @@
from pyrit.backend.services.converter_service import ConverterService, get_converter_service
from pyrit.backend.services.dataset_service import DatasetService, get_dataset_service
from pyrit.backend.services.initializer_service import InitializerService, get_initializer_service
+ from pyrit.backend.services.scenario_preset_service import ScenarioPresetService, get_scenario_preset_service
from pyrit.backend.services.scenario_run_service import ScenarioRunService, get_scenario_run_service
from pyrit.backend.services.scenario_service import ScenarioService, get_scenario_service
from pyrit.backend.services.target_service import TargetService, get_target_service
@@ -32,6 +33,8 @@
"get_initializer_service": "pyrit.backend.services.initializer_service",
"ScenarioService": "pyrit.backend.services.scenario_service",
"get_scenario_service": "pyrit.backend.services.scenario_service",
+ "ScenarioPresetService": "pyrit.backend.services.scenario_preset_service",
+ "get_scenario_preset_service": "pyrit.backend.services.scenario_preset_service",
"ScenarioRunService": "pyrit.backend.services.scenario_run_service",
"get_scenario_run_service": "pyrit.backend.services.scenario_run_service",
"TargetService": "pyrit.backend.services.target_service",
diff --git a/pyrit/backend/services/runtime_lifecycle.py b/pyrit/backend/services/runtime_lifecycle.py
index 75632819c4..f41ff84a00 100644
--- a/pyrit/backend/services/runtime_lifecycle.py
+++ b/pyrit/backend/services/runtime_lifecycle.py
@@ -14,6 +14,7 @@
from pyrit.backend.models.initializers import ConfiguredInitializerSetting
from pyrit.backend.services.configuration_file_service import ConfigurationFileService
from pyrit.backend.services.environment_file_service import EnvironmentFileService
+from pyrit.backend.services.scenario_preset_service import get_scenario_preset_service
from pyrit.backend.services.scenario_run_service import get_scenario_run_service, peek_scenario_run_service
from pyrit.backend.services.service_lifecycle import (
close_services_async,
@@ -103,6 +104,9 @@ async def _management_async(self, config: ConfigurationLoader) -> None:
self.app.state.allow_custom_initializers = config.allow_custom_initializers
registry = await asyncio.to_thread(InitializerRegistry.get_registry_singleton)
registry.configure_custom_scripts_source(config.custom_initializers_source)
+ # Constructing the storage creates the default preset directory when no source is
+ # configured, so it stays off the event loop.
+ await asyncio.to_thread(get_scenario_preset_service().configure_source, config.scenario_presets_source)
def _publish(self, config: ConfigurationLoader) -> None:
self.app.state.configured_initializers = [
diff --git a/pyrit/backend/services/scenario_configuration_resolver.py b/pyrit/backend/services/scenario_configuration_resolver.py
index 9b679c6fd2..30d19968cd 100644
--- a/pyrit/backend/services/scenario_configuration_resolver.py
+++ b/pyrit/backend/services/scenario_configuration_resolver.py
@@ -8,6 +8,11 @@
from typing import TYPE_CHECKING, Any
from pyrit.registry import ConverterRegistry, ScenarioRegistry, TargetRegistry
+from pyrit.scenario.core import (
+ CONVERTER_MODIFIER_PREFIX,
+ converter_name_from_modifier,
+ parse_technique_token,
+)
from pyrit.scenario.core.scenario_target_defaults import validate_default_adversarial_target
if TYPE_CHECKING:
@@ -15,8 +20,6 @@
from pyrit.prompt_target import PromptTarget
from pyrit.scenario import Scenario
-_CONVERTER_MODIFIER_PREFIX = "converter."
-
class ScenarioConfigurationResolver:
"""Resolve registry-backed scenario inputs for launch and estimation."""
@@ -188,8 +191,7 @@ def resolve_techniques_and_converters(
technique_enums: list[Any] = []
technique_converters: dict[str, list[Converter]] = {}
for token in tokens:
- base_name, _, remainder = token.partition(":")
- modifiers = [modifier for modifier in remainder.split(":") if modifier] if remainder else []
+ base_name, modifiers = parse_technique_token(token)
try:
technique_enum = technique_class(base_name)
except ValueError:
@@ -207,7 +209,7 @@ def resolve_techniques_and_converters(
return technique_enums, technique_converters
@staticmethod
- def _resolve_converter_modifiers(*, modifiers: list[str], token: str) -> list[Converter]:
+ def _resolve_converter_modifiers(*, modifiers: tuple[str, ...], token: str) -> list[Converter]:
"""
Resolve converter modifiers from one technique token.
@@ -223,13 +225,13 @@ def _resolve_converter_modifiers(*, modifiers: list[str], token: str) -> list[Co
instances = ConverterRegistry.get_registry_singleton().instances
converters: list[Converter] = []
for modifier in modifiers:
- if not modifier.startswith(_CONVERTER_MODIFIER_PREFIX):
+ converter_name = converter_name_from_modifier(modifier)
+ if converter_name is None:
raise ValueError(
f"Unknown technique modifier '{modifier}' in '{token}'. "
- f"Supported modifiers must use the '{_CONVERTER_MODIFIER_PREFIX}' prefix "
- f"(e.g. '{_CONVERTER_MODIFIER_PREFIX}translation_spanish')."
+ f"Supported modifiers must use the '{CONVERTER_MODIFIER_PREFIX}' prefix "
+ f"(e.g. '{CONVERTER_MODIFIER_PREFIX}translation_spanish')."
)
- converter_name = modifier[len(_CONVERTER_MODIFIER_PREFIX) :]
converter = instances.get(converter_name)
if converter is None:
available = instances.get_names()
diff --git a/pyrit/backend/services/scenario_preset_service.py b/pyrit/backend/services/scenario_preset_service.py
new file mode 100644
index 0000000000..9f69d4bb75
--- /dev/null
+++ b/pyrit/backend/services/scenario_preset_service.py
@@ -0,0 +1,406 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Scenario preset service for CRUD and launch resolution.
+"""
+
+import asyncio
+import logging
+from functools import lru_cache
+from typing import Any
+
+from pyrit.backend.models.scenario_presets import (
+ PresetIssue,
+ ResolveScenarioPresetRequest,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+)
+from pyrit.backend.services.scenario_service import get_scenario_service
+from pyrit.models.catalog import (
+ RegisteredScenario,
+ RunScenarioRequest,
+ ScenarioPreset,
+ ScenarioRunSizeEstimate,
+ ScenarioRunSizeEstimateRequest,
+ StoredPreset,
+)
+from pyrit.registry import ConverterRegistry, ScenarioPresetStorage
+from pyrit.scenario.core import (
+ CONVERTER_MODIFIER_PREFIX,
+ converter_name_from_modifier,
+ parse_technique_token,
+)
+
+logger = logging.getLogger(__name__)
+
+
+class ScenarioPresetNotFoundError(KeyError):
+ """No preset is stored under the requested name."""
+
+
+class ScenarioPresetService:
+ """
+ Service for reading, writing, and resolving scenario presets.
+
+ Storage is synchronous file or blob I/O, so every call into it is handed to a
+ worker thread. Running it inline would block the event loop for the whole
+ directory listing on every request.
+ """
+
+ def __init__(self) -> None:
+ """Initialize the service without touching storage."""
+ self._storage: ScenarioPresetStorage | None = None
+
+ def configure_source(self, source: str | None) -> None:
+ """
+ Configure the local directory or Azure Blob source for scenario presets.
+
+ Args:
+ source (str | None): The configured source, or None to use the default directory.
+ """
+ self._storage = ScenarioPresetStorage(source=source)
+
+ async def list_presets_async(self, *, include_estimates: bool = True) -> ScenarioPresetListResponse:
+ """
+ Read every stored preset, newest advisory issues included.
+
+ Args:
+ include_estimates (bool): Whether to size each preset's own configuration. Sizing
+ constructs every scenario, so a caller that wants to paint first can ask for
+ the presets alone and request the sizes separately.
+
+ Returns:
+ ScenarioPresetListResponse: The configured source and every readable preset.
+ """
+ storage = self._get_storage()
+ stored = await asyncio.to_thread(storage.list_presets)
+
+ items = list(
+ await asyncio.gather(
+ *(self._to_response_async(stored_preset) for _, stored_preset in sorted(stored.items(), key=_by_name))
+ )
+ )
+ if include_estimates:
+ estimates = await asyncio.gather(*(self._estimate_run_size_async(preset=item.preset) for item in items))
+ items = [
+ item.model_copy(update={"run_size": estimate})
+ for item, estimate in zip(items, estimates, strict=True)
+ ]
+ return ScenarioPresetListResponse(source=storage.display_source, items=items)
+
+ async def get_preset_async(self, *, name: str) -> ScenarioPresetResponse | None:
+ """
+ Read one stored preset.
+
+ Args:
+ name (str): The preset name.
+
+ Returns:
+ ScenarioPresetResponse | None: The preset, or None if nothing is stored under *name*.
+ """
+ stored = await asyncio.to_thread(self._get_storage().load_preset, name)
+ return None if stored is None else await self._to_response_async(stored)
+
+ async def save_preset_async(
+ self,
+ *,
+ preset: ScenarioPreset,
+ expected_version: str | None,
+ ) -> ScenarioPresetResponse:
+ """
+ Persist one preset.
+
+ Unresolvable references are reported on the response rather than rejected, so a
+ preset authored against one deployment can be saved on another.
+
+ Args:
+ preset (ScenarioPreset): The preset to persist.
+ expected_version (str | None): None to create a preset that must not already exist,
+ or the version returned when the edited preset was read.
+
+ Returns:
+ ScenarioPresetResponse: The persisted preset, its new version, and its advisory issues.
+
+ Raises:
+ ScenarioPresetConflictError: If the stored version does not match *expected_version*.
+ """
+ stored = await asyncio.to_thread(
+ self._get_storage().save_preset, preset=preset, expected_version=expected_version
+ )
+ logger.info("Saved scenario preset: %s", preset.name)
+ return await self._to_response_async(stored)
+
+ async def delete_preset_async(self, *, name: str) -> None:
+ """
+ Delete one stored preset.
+
+ Args:
+ name (str): The preset name.
+
+ Raises:
+ ScenarioPresetNotFoundError: If nothing is stored under *name*.
+ """
+ storage = self._get_storage()
+ if await asyncio.to_thread(storage.get_preset_version, name) is None:
+ raise ScenarioPresetNotFoundError(name)
+
+ await asyncio.to_thread(storage.delete_preset, name)
+ logger.info("Deleted scenario preset: %s", name)
+
+ @staticmethod
+ def resolve_run_request(*, preset: ScenarioPreset, launch: ResolveScenarioPresetRequest) -> RunScenarioRequest:
+ """
+ Combine a preset with the launch-owned fields it omits.
+
+ ``max_concurrency`` and ``max_retries`` are the only run fields that are not
+ tri-state, so an unset value is dropped rather than passed as None. Passing it
+ through would pin the run default at resolution time instead of letting the
+ request model supply it.
+
+ Args:
+ preset (ScenarioPreset): The preset supplying the scenario-owned fields.
+ launch (ResolveScenarioPresetRequest): The target and execution fields for this launch.
+
+ Returns:
+ RunScenarioRequest: The request to post to the existing scenario run endpoint.
+ """
+ run_defaults: dict[str, Any] = {
+ "max_concurrency": launch.max_concurrency,
+ "max_retries": launch.max_retries,
+ }
+
+ return RunScenarioRequest(
+ scenario_name=preset.scenario_name,
+ techniques=preset.techniques,
+ dataset_names=preset.dataset_names,
+ max_dataset_size=preset.max_dataset_size,
+ dataset_filters=preset.dataset_filters,
+ include_baseline=preset.include_baseline,
+ scenario_params=preset.scenario_params,
+ target_name=launch.target_name,
+ adversarial_target_name=launch.adversarial_target_name,
+ initializers=launch.initializers,
+ initializer_args=launch.initializer_args,
+ labels=launch.labels,
+ **{name: value for name, value in run_defaults.items() if value is not None},
+ )
+
+ def _get_storage(self) -> ScenarioPresetStorage:
+ """
+ Return storage for the configured preset source.
+
+ Returns:
+ ScenarioPresetStorage: The configured storage, defaulting to the standard directory.
+ """
+ storage = self._storage
+ if storage is None:
+ storage = ScenarioPresetStorage()
+ self._storage = storage
+ return storage
+
+ async def _to_response_async(self, stored: StoredPreset) -> ScenarioPresetResponse:
+ """
+ Attach advisory issues to a stored preset.
+
+ Args:
+ stored (StoredPreset): The preset and the version it was read from.
+
+ Returns:
+ ScenarioPresetResponse: The wire representation of the preset.
+ """
+ issues = await self._collect_issues_async(preset=stored.preset)
+ return ScenarioPresetResponse(preset=stored.preset, version=stored.version, issues=issues)
+
+ async def _estimate_run_size_async(self, *, preset: ScenarioPreset) -> ScenarioRunSizeEstimate | None:
+ """
+ Size one preset against its own techniques, datasets, and limits.
+
+ A preset is allowed to name things this deployment has not registered, so a
+ preset that cannot be sized reports no size rather than failing the listing
+ that every other preset shares.
+
+ Args:
+ preset (ScenarioPreset): The preset to size.
+
+ Returns:
+ ScenarioRunSizeEstimate | None: The estimate, or None when it cannot be computed here.
+ """
+ request = ScenarioRunSizeEstimateRequest(
+ techniques=preset.techniques,
+ dataset_names=preset.dataset_names,
+ max_dataset_size=preset.max_dataset_size,
+ dataset_filters=preset.dataset_filters,
+ include_baseline=preset.include_baseline,
+ scenario_params=preset.scenario_params,
+ )
+ try:
+ return await get_scenario_service().estimate_scenario_run_size_async(
+ scenario_name=preset.scenario_name, request=request
+ )
+ except Exception:
+ logger.debug("Could not size scenario preset '%s'", preset.name, exc_info=True)
+ return None
+
+ async def _collect_issues_async(self, *, preset: ScenarioPreset) -> list[PresetIssue]:
+ """
+ Check a preset against the live scenario registry.
+
+ Args:
+ preset (ScenarioPreset): The preset to check.
+
+ Returns:
+ list[PresetIssue]: Advisory issues, empty when the preset resolves here.
+ """
+ scenario = await get_scenario_service().get_scenario_async(scenario_name=preset.scenario_name)
+ if scenario is None:
+ return [
+ PresetIssue(
+ field="scenario_name",
+ message=f"Scenario '{preset.scenario_name}' is not registered in this deployment.",
+ )
+ ]
+
+ return [
+ *_unknown_technique_issues(preset=preset, scenario=scenario),
+ *_unknown_parameter_issues(preset=preset, scenario=scenario),
+ *_forbidden_baseline_issues(preset=preset, scenario=scenario),
+ ]
+
+
+def _by_name(item: tuple[str, StoredPreset]) -> str:
+ """
+ Sort key for stored presets.
+
+ Args:
+ item (tuple[str, StoredPreset]): A storage name and the preset read from it.
+
+ Returns:
+ str: The storage name.
+ """
+ return item[0]
+
+
+def _technique_issues(*, names: list[str], description: str) -> list[PresetIssue]:
+ """
+ Build the at-most-one issue describing a kind of unresolvable technique reference.
+
+ Args:
+ names (list[str]): The offending names, possibly with repeats.
+ description (str): What is wrong with them, phrased to read before a name list.
+
+ Returns:
+ list[PresetIssue]: A single issue, or an empty list when nothing was offending.
+ """
+ if not names:
+ return []
+ return [PresetIssue(field="techniques", message=f"{description}: {', '.join(sorted(set(names)))}.")]
+
+
+def _unknown_technique_issues(*, preset: ScenarioPreset, scenario: RegisteredScenario) -> list[PresetIssue]:
+ """
+ Report techniques and converter modifiers this deployment cannot resolve.
+
+ Tokens are parsed with the same grammar the launch path uses, because comparing a
+ whole token against the scenario's technique names would report a runnable preset
+ such as ``role_play:converter.translation_spanish`` as broken.
+
+ Args:
+ preset (ScenarioPreset): The preset to check.
+ scenario (RegisteredScenario): The registered scenario it names.
+
+ Returns:
+ list[PresetIssue]: One issue per kind of unresolvable reference, or an empty list.
+ """
+ if not preset.techniques:
+ return []
+
+ known = set(scenario.all_techniques) | set(scenario.aggregate_techniques)
+ registered_converters = ConverterRegistry.get_registry_singleton().instances
+ unknown_techniques: list[str] = []
+ unknown_converters: list[str] = []
+ malformed_modifiers: list[str] = []
+
+ for token in preset.techniques:
+ base_name, modifiers = parse_technique_token(token)
+ if base_name not in known:
+ unknown_techniques.append(base_name)
+ for modifier in modifiers:
+ converter_name = converter_name_from_modifier(modifier)
+ if converter_name is None:
+ malformed_modifiers.append(modifier)
+ elif registered_converters.get(converter_name) is None:
+ unknown_converters.append(converter_name)
+
+ return [
+ *_technique_issues(
+ names=unknown_techniques, description=f"Scenario '{scenario.scenario_name}' does not define"
+ ),
+ *_technique_issues(
+ names=unknown_converters, description="This deployment has no registered converter named"
+ ),
+ *_technique_issues(
+ names=malformed_modifiers,
+ description=f"Technique modifiers must use the '{CONVERTER_MODIFIER_PREFIX}' prefix; got",
+ ),
+ ]
+
+
+def _unknown_parameter_issues(*, preset: ScenarioPreset, scenario: RegisteredScenario) -> list[PresetIssue]:
+ """
+ Report scenario parameters the scenario does not declare.
+
+ Args:
+ preset (ScenarioPreset): The preset to check.
+ scenario (RegisteredScenario): The registered scenario it names.
+
+ Returns:
+ list[PresetIssue]: One issue naming every undeclared parameter, or an empty list.
+ """
+ if not preset.scenario_params:
+ return []
+
+ declared = {parameter.name for parameter in scenario.supported_parameters}
+ unknown = [name for name in preset.scenario_params if name not in declared]
+ if not unknown:
+ return []
+
+ return [
+ PresetIssue(
+ field="scenario_params",
+ message=f"Scenario '{scenario.scenario_name}' does not declare: {', '.join(sorted(unknown))}.",
+ )
+ ]
+
+
+def _forbidden_baseline_issues(*, preset: ScenarioPreset, scenario: RegisteredScenario) -> list[PresetIssue]:
+ """
+ Report a baseline request the scenario forbids.
+
+ Args:
+ preset (ScenarioPreset): The preset to check.
+ scenario (RegisteredScenario): The registered scenario it names.
+
+ Returns:
+ list[PresetIssue]: A single issue when the scenario forbids a requested baseline.
+ """
+ if not preset.include_baseline or scenario.baseline_policy != "forbidden":
+ return []
+
+ return [
+ PresetIssue(
+ field="include_baseline",
+ message=f"Scenario '{scenario.scenario_name}' does not support a baseline run.",
+ )
+ ]
+
+
+@lru_cache(maxsize=1)
+def get_scenario_preset_service() -> ScenarioPresetService:
+ """
+ Get the global scenario preset service instance.
+
+ Returns:
+ ScenarioPresetService: The singleton scenario preset service instance.
+ """
+ return ScenarioPresetService()
diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py
index 3cfc87e1aa..4f293b7146 100644
--- a/pyrit/models/__init__.py
+++ b/pyrit/models/__init__.py
@@ -49,6 +49,7 @@
ScenarioDatasetSizeCap,
ScenarioDatasetSummary,
ScenarioDefaultRunSizeEstimate,
+ ScenarioPreset,
ScenarioRunListItem,
ScenarioRunSizeComponent,
ScenarioRunSizeEstimate,
@@ -57,6 +58,7 @@
ScenarioRunSizeEstimateStatus,
ScenarioRunSizeFactor,
ScenarioTechniqueSummary,
+ StoredPreset,
)
from pyrit.models.conversation_stats import ConversationStats
from pyrit.models.embeddings import EmbeddingData, EmbeddingResponse, EmbeddingSupport, EmbeddingUsageInformation
@@ -398,6 +400,7 @@
"ScenarioDatasetSizeCap": "pyrit.models.catalog",
"ScenarioDatasetSummary": "pyrit.models.catalog",
"ScenarioDefaultRunSizeEstimate": "pyrit.models.catalog",
+ "ScenarioPreset": "pyrit.models.catalog",
"ScenarioRunListItem": "pyrit.models.catalog",
"ScenarioRunSizeComponent": "pyrit.models.catalog",
"ScenarioRunSizeEstimate": "pyrit.models.catalog",
@@ -457,6 +460,7 @@
"warn_prompt_path_deprecated": "pyrit.models.seeds",
"snake_case_to_class_name": "pyrit.models.identifiers",
"sort_message_pieces": "pyrit.models.messages.message_piece",
+ "StoredPreset": "pyrit.models.catalog",
"StrategyResult": "pyrit.models.results.strategy_result",
"StrategyResultT": "pyrit.models.results.strategy_result",
"StructuredParameterValue": "pyrit.models.parameter",
diff --git a/pyrit/models/catalog/__init__.py b/pyrit/models/catalog/__init__.py
index 3a0740a050..cec085e51f 100644
--- a/pyrit/models/catalog/__init__.py
+++ b/pyrit/models/catalog/__init__.py
@@ -38,6 +38,7 @@
ScenarioRunSummary,
ScenarioTechniqueSummary,
)
+ from pyrit.models.catalog.scenario_preset import ScenarioPreset, StoredPreset
from pyrit.models.catalog.scorer import ScorerInstance
from pyrit.models.catalog.target import TargetInstance
@@ -51,6 +52,7 @@
"ScenarioDatasetSizeCap": "pyrit.models.catalog.scenario",
"ScenarioDatasetSummary": "pyrit.models.catalog.scenario",
"ScenarioDefaultRunSizeEstimate": "pyrit.models.catalog.scenario",
+ "ScenarioPreset": "pyrit.models.catalog.scenario_preset",
"ScenarioRunListItem": "pyrit.models.catalog.scenario",
"ScenarioRunSizeComponent": "pyrit.models.catalog.scenario",
"ScenarioRunSizeEstimate": "pyrit.models.catalog.scenario",
@@ -60,6 +62,7 @@
"ScenarioRunSizeFactor": "pyrit.models.catalog.scenario",
"ScenarioRunSummary": "pyrit.models.catalog.scenario",
"ScenarioTechniqueSummary": "pyrit.models.catalog.scenario",
+ "StoredPreset": "pyrit.models.catalog.scenario_preset",
"TargetInstance": "pyrit.models.catalog.target",
}
diff --git a/pyrit/models/catalog/scenario.py b/pyrit/models/catalog/scenario.py
index 7e9bde5aaa..1e640c8702 100644
--- a/pyrit/models/catalog/scenario.py
+++ b/pyrit/models/catalog/scenario.py
@@ -437,7 +437,7 @@ class RunScenarioRequest(BaseModel):
dataset_names: _RequestNames | None = Field(
None, description="Dataset names to use (uses scenario default if omitted)"
)
- max_dataset_size: int | None = Field(None, ge=1, description="Maximum items per dataset")
+ max_dataset_size: int | None = Field(None, ge=1, description="Maximum selected logical seed groups")
dataset_filters: _RequestFilters | None = Field(
None,
description=(
diff --git a/pyrit/models/catalog/scenario_preset.py b/pyrit/models/catalog/scenario_preset.py
new file mode 100644
index 0000000000..c3ad9d2268
--- /dev/null
+++ b/pyrit/models/catalog/scenario_preset.py
@@ -0,0 +1,113 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Scenario preset model.
+
+A scenario preset is a named, reusable, target-agnostic answer to *what to test*:
+a scenario plus the scenario-owned configuration fields. It deliberately excludes
+everything environment-specific — target, concurrency, retries, and labels — which
+belongs to a launch rather than to the preset. That split is what lets one preset
+run unchanged against dev, staging, and production.
+
+Every configurable field is tri-state. ``None`` means "not set by this preset, use
+the scenario's own default", which is distinct from an explicit value that happens
+to equal that default. Collapsing the two would silently pin a scenario default at
+the moment a preset was saved and stop it tracking upstream changes.
+"""
+
+from pydantic import BaseModel, ConfigDict, Field, field_validator
+
+from pyrit.models.catalog.scenario import (
+ _RequestFilters,
+ _RequestName,
+ _RequestNames,
+ _RequestParams,
+ _RequestTechniques,
+ _validate_dataset_filter_mapping,
+)
+from pyrit.models.identifiers.class_name_utils import validate_registry_name
+
+
+class ScenarioPreset(BaseModel):
+ """
+ A named, reusable, target-agnostic scenario configuration.
+
+ Presets own *what to test*. A launch owns *how and where* — the target,
+ concurrency, retries, and labels — so those fields are absent here by design
+ and the two sets are combined by union rather than by precedence.
+
+ Unknown keys are rejected rather than ignored. These documents are hand-edited, and
+ because an absent field is a meaningful state, a misspelled key would otherwise parse
+ cleanly and leave the preset silently testing the scenario default instead of the
+ value the file plainly states.
+
+ ``description`` and ``author`` describe the preset rather than configure the run, so
+ they are not tri-state: an absent value means unknown, not "use the scenario default".
+ ``author`` is filled in from the signed-in user when a preset is created through the
+ API and is informational only — these documents are hand-editable, so it records
+ provenance rather than proving it.
+ """
+
+ model_config = ConfigDict(extra="forbid")
+
+ name: str = Field(..., description="Unique preset name, used as the storage key and as the reference from scans")
+ scenario_name: _RequestName = Field(..., min_length=1, description="Registered scenario this preset configures")
+ description: str | None = Field(None, description="Human-readable summary of what this preset tests")
+ author: str | None = Field(
+ None,
+ description="Who created this preset; descriptive only, never used to authorize a read or a write",
+ )
+ techniques: _RequestTechniques | None = Field(None, description="Technique names; None uses the scenario default")
+ dataset_names: _RequestNames | None = Field(None, description="Dataset names; None uses the scenario default")
+ max_dataset_size: int | None = Field(None, ge=1, description="Maximum selected logical seed groups")
+ dataset_filters: _RequestFilters | None = Field(
+ None,
+ description="Dataset seed filters keyed by field. Accepted keys: harm_categories, data_types.",
+ )
+ include_baseline: bool | None = Field(None, description="Override the scenario baseline default")
+ scenario_params: _RequestParams | None = Field(
+ None, description="Scenario-declared parameters such as template names and attempt counts"
+ )
+
+ @field_validator("name")
+ @classmethod
+ def _validate_name(cls, value: str) -> str:
+ """
+ Validate that the preset name is a legal registry name.
+
+ Returns:
+ str: The validated name.
+
+ Raises:
+ ValueError: If the name is not a legal registry name.
+ """
+ validate_registry_name(value)
+ return value
+
+ @field_validator("dataset_filters")
+ @classmethod
+ def _validate_dataset_filters(cls, value: dict[str, list[str]] | None) -> dict[str, list[str]] | None:
+ """
+ Validate dataset filters against the shared allow-list.
+
+ Returns:
+ dict[str, list[str]] | None: Validated filters.
+ """
+ return _validate_dataset_filter_mapping(value)
+
+
+class StoredPreset(BaseModel):
+ """
+ A preset together with the version of the document it was read from.
+
+ The version describes the *stored document*, not the preset, so it is paired with
+ the preset rather than carried as a field on it. Storing the token inside the file
+ it guards would let a hand-edit rewrite the very value used to detect that edit.
+
+ The token is opaque. Callers round-trip it from a read back into a write and must
+ not parse, compare, or order it.
+ """
+
+ preset: ScenarioPreset = Field(..., description="The stored preset")
+ version: str = Field(..., description="Opaque version of the document this preset was read from")
diff --git a/pyrit/registry/__init__.py b/pyrit/registry/__init__.py
index 87ba1ad481..e3fa174926 100644
--- a/pyrit/registry/__init__.py
+++ b/pyrit/registry/__init__.py
@@ -33,6 +33,7 @@
)
from pyrit.registry.registry import InstanceHoldingRegistry, ParamBagRegistry, Registry
from pyrit.registry.registry_metadata import RegistryMetadata
+ from pyrit.registry.scenario_preset_storage import ScenarioPresetConflictError, ScenarioPresetStorage
from pyrit.registry.tag_query import TagQuery
_LAZY_EXPORTS: dict[str, str | tuple[str, str | None]] = {
@@ -53,6 +54,8 @@
"InitializerRegistry": "pyrit.registry.components",
"RegistryEntry": "pyrit.registry.instance_registry",
"ScenarioMetadata": "pyrit.registry.components",
+ "ScenarioPresetConflictError": "pyrit.registry.scenario_preset_storage",
+ "ScenarioPresetStorage": "pyrit.registry.scenario_preset_storage",
"ScenarioRegistry": "pyrit.registry.components",
"ScorerRegistry": "pyrit.registry.components",
"ScorerMetadata": "pyrit.registry.components",
diff --git a/pyrit/registry/custom_initializer_storage.py b/pyrit/registry/custom_initializer_storage.py
index 90e63ed7a0..572c838c00 100644
--- a/pyrit/registry/custom_initializer_storage.py
+++ b/pyrit/registry/custom_initializer_storage.py
@@ -5,20 +5,30 @@
from __future__ import annotations
-from contextlib import contextmanager, suppress
-from pathlib import Path, PurePosixPath
-from typing import TYPE_CHECKING
-from urllib.parse import unquote, urlparse
+import logging
-from pyrit.common.azure_storage import has_sas_signature, is_azure_blob_uri, redact_url_credentials
+from pyrit.registry.file_document_storage import FileDocumentStorage
-if TYPE_CHECKING:
- from collections.abc import Generator
+logger = logging.getLogger(__name__)
- from azure.storage.blob import ContainerClient
+def _decode_script(content: bytes) -> str:
+ """
+ Decode stored script bytes with the line endings a text-mode read would produce.
-class CustomInitializerStorage:
+ Callers materialize this source back to a file in text mode, which translates every
+ newline again, so a stored CRLF would round-trip into a corrupted CRCRLF.
+
+ Returns:
+ str: Decoded script source with normalized line endings.
+
+ Raises:
+ UnicodeDecodeError: If the content is not valid UTF-8 text.
+ """
+ return content.decode("utf-8").replace("\r\n", "\n").replace("\r", "\n")
+
+
+class CustomInitializerStorage(FileDocumentStorage):
"""Read and write custom initializer scripts in a directory or blob container."""
def __init__(self, *, source: str) -> None:
@@ -28,21 +38,7 @@ def __init__(self, *, source: str) -> None:
Raises:
ValueError: If the source has an unsupported URI scheme.
"""
- self._source = source
- self._is_blob = is_azure_blob_uri(source)
- if not self._is_blob and urlparse(source).scheme and not Path(source).drive:
- raise ValueError(
- "Custom initializer source must be a local directory or Azure Blob container URI "
- "with an optional blob prefix"
- )
- self._container_url, self._blob_prefix = self._parse_blob_source() if self._is_blob else (None, "")
-
- @property
- def display_source(self) -> str:
- """Storage source without Azure Blob credentials."""
- if not self._is_blob:
- return self._source
- return redact_url_credentials(self._source)
+ super().__init__(source=source, extension=".py", source_label="Custom initializer")
def get_script_source(self, name: str) -> str:
"""
@@ -51,119 +47,30 @@ def get_script_source(self, name: str) -> str:
Returns:
str: Local file path or Azure Blob URI for the script.
"""
- if self._is_blob:
- return f"{self.display_source.rstrip('/')}/{name}.py"
- return str(Path(self._source).expanduser() / f"{name}.py")
+ return self._get_document_source(name)
def list_scripts(self) -> dict[str, str]:
"""
List stored Python scripts by registry name.
+ A script that is not valid UTF-8 text is logged and skipped, so one unreadable
+ file cannot hide every other stored initializer.
+
Returns:
dict[str, str]: Script content keyed by registry name.
"""
- if self._is_blob:
- return self._list_blob_scripts()
-
- directory = Path(self._source).expanduser()
- directory.mkdir(parents=True, exist_ok=True)
- return {path.stem: path.read_text(encoding="utf-8") for path in sorted(directory.glob("*.py"))}
+ scripts: dict[str, str] = {}
+ for name, content in self._list_documents().items():
+ try:
+ scripts[name] = _decode_script(content)
+ except UnicodeDecodeError:
+ logger.warning(f"Skipping stored initializer '{name}': it is not valid UTF-8 text.")
+ return scripts
def save_script(self, *, name: str, content: str) -> None:
"""Persist one custom initializer script."""
- if self._is_blob:
- with self._open_container_client() as client:
- client.upload_blob(name=self._get_blob_name(name), data=content.encode("utf-8"), overwrite=True)
- else:
- directory = Path(self._source).expanduser()
- directory.mkdir(parents=True, exist_ok=True)
- (directory / f"{name}.py").write_text(content, encoding="utf-8")
+ self._save_document(name=name, content=content.encode("utf-8"))
def delete_script(self, name: str) -> None:
"""Delete one custom initializer script if it exists."""
- if self._is_blob:
- from azure.core.exceptions import ResourceNotFoundError
-
- with self._open_container_client() as client:
- with suppress(ResourceNotFoundError):
- client.delete_blob(self._get_blob_name(name))
- else:
- (Path(self._source).expanduser() / f"{name}.py").unlink(missing_ok=True)
-
- def _list_blob_scripts(self) -> dict[str, str]:
- """
- Read Python scripts from the configured Azure Blob container.
-
- Returns:
- dict[str, str]: Script content keyed by blob stem.
- """
- scripts: dict[str, str] = {}
- with self._open_container_client() as client:
- prefix = f"{self._blob_prefix}/" if self._blob_prefix else None
- blobs = client.list_blobs(name_starts_with=prefix) if prefix else client.list_blobs()
- blob_names = sorted(
- blob.name for blob in blobs if self._is_direct_python_blob(blob_name=blob.name, prefix=prefix)
- )
- for blob_name in blob_names:
- relative_name = blob_name.removeprefix(prefix or "")
- scripts[PurePosixPath(relative_name).stem] = client.download_blob(blob_name).readall().decode("utf-8")
- return scripts
-
- def _parse_blob_source(self) -> tuple[str, str]:
- """
- Split the configured source into a container URL and blob prefix.
-
- Returns:
- tuple[str, str]: The container URL and decoded blob prefix.
- """
- parsed_uri = urlparse(self._source)
- container_path, _, prefix = parsed_uri.path.strip("/").partition("/")
- container_url = parsed_uri._replace(path=f"/{container_path}", fragment="").geturl()
- return container_url, unquote(prefix).strip("/")
-
- def _get_blob_name(self, name: str) -> str:
- """
- Build the blob name for a registry entry.
-
- Returns:
- str: The prefixed Python blob name.
- """
- file_name = f"{name}.py"
- return f"{self._blob_prefix}/{file_name}" if self._blob_prefix else file_name
-
- @staticmethod
- def _is_direct_python_blob(*, blob_name: str, prefix: str | None) -> bool:
- """Return whether a blob is a direct Python child of the configured prefix."""
- if prefix and not blob_name.startswith(prefix):
- return False
- relative_name = blob_name.removeprefix(prefix or "")
- return "/" not in relative_name and PurePosixPath(relative_name).suffix == ".py"
-
- @contextmanager
- def _open_container_client(self) -> Generator[ContainerClient, None, None]:
- """
- Yield an Azure Blob container client and close its credential.
-
- Yields:
- ContainerClient: A client scoped to the configured container.
-
- Raises:
- RuntimeError: If called for a non-Blob source.
- """
- from azure.identity import DefaultAzureCredential
- from azure.storage.blob import ContainerClient
-
- if self._container_url is None:
- raise RuntimeError("Azure Blob container URL is not configured")
-
- if has_sas_signature(self._container_url):
- with ContainerClient.from_container_url(container_url=self._container_url) as client:
- yield client
- return
-
- with DefaultAzureCredential() as credential:
- with ContainerClient.from_container_url(
- container_url=self._container_url,
- credential=credential,
- ) as client:
- yield client
+ self._delete_document(name)
diff --git a/pyrit/registry/file_document_storage.py b/pyrit/registry/file_document_storage.py
new file mode 100644
index 0000000000..f4f192a58f
--- /dev/null
+++ b/pyrit/registry/file_document_storage.py
@@ -0,0 +1,631 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Shared local-directory and Azure Blob storage for flat, named documents."""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import os
+import sys
+import tempfile
+import time
+from contextlib import contextmanager, suppress
+from pathlib import Path, PurePosixPath
+from typing import TYPE_CHECKING
+from urllib.parse import unquote, urlparse
+
+from pyrit.common.azure_storage import has_sas_signature, is_azure_blob_uri, redact_url_credentials
+from pyrit.models.identifiers.class_name_utils import validate_registry_name
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+
+ from azure.storage.blob import ContainerClient
+
+logger = logging.getLogger(__name__)
+
+# Both platforms lock a single byte at offset zero, which they allow past the end of an
+# empty file. The lock belongs to the open file handle, so it excludes other threads in
+# this process as well as other processes, and the kernel drops it if the holder exits.
+if sys.platform == "win32":
+ import msvcrt
+
+ def _try_acquire_exclusive_lock(descriptor: int) -> bool:
+ """
+ Try to take the exclusive lock without waiting.
+
+ Returns:
+ bool: Whether the lock was taken.
+ """
+ os.lseek(descriptor, 0, os.SEEK_SET)
+ try:
+ msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
+ except OSError:
+ return False
+ return True
+
+ def _release_exclusive_lock(descriptor: int) -> None:
+ """Release the exclusive lock held on an open descriptor."""
+ os.lseek(descriptor, 0, os.SEEK_SET)
+ with suppress(OSError):
+ msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
+
+else:
+ import fcntl
+
+ def _try_acquire_exclusive_lock(descriptor: int) -> bool:
+ """
+ Try to take the exclusive lock without waiting.
+
+ Returns:
+ bool: Whether the lock was taken.
+ """
+ try:
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError:
+ return False
+ return True
+
+ def _release_exclusive_lock(descriptor: int) -> None:
+ """Release the exclusive lock held on an open descriptor."""
+ with suppress(OSError):
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
+
+
+class DocumentConflictError(ValueError):
+ """A stored document changed after the caller read it."""
+
+ def __init__(
+ self,
+ *,
+ name: str,
+ expected_version: str | None,
+ actual_version: str | None,
+ label: str = "Document",
+ ) -> None:
+ """
+ Initialize the error with the versions that failed to match.
+
+ Args:
+ name (str): The document name the write addressed.
+ expected_version (str | None): The version the caller believed was stored.
+ actual_version (str | None): The version storage actually held.
+ label (str): Human-readable noun for the stored document. Defaults to "Document".
+ """
+ self.name = name
+ self.expected_version = expected_version
+ self.actual_version = actual_version
+ if actual_version is None:
+ detail = "it no longer exists"
+ elif expected_version is None:
+ detail = "it already exists"
+ else:
+ detail = "it was changed by someone else"
+ super().__init__(f"{label} '{name}' could not be saved because {detail}. Reload it and reapply.")
+
+
+class FileDocumentStorage:
+ """
+ Read and write flat, named text documents in a directory or blob container.
+
+ Documents live directly under the configured source, one file per name, with a
+ fixed extension. Nested paths are ignored so a virtual directory prefix behaves
+ the same way in both backends.
+
+ Every operation that addresses a single document validates the name first. The
+ name becomes a path component in both backends, so an unvalidated name would let
+ a caller read, overwrite, or delete a file outside the configured source. The
+ check lives here rather than in each subclass so no document API can omit it.
+
+ Listing applies the same rule before it reads anything, so every name it returns can
+ be passed back to the single-document operations. Names come from the file system
+ rather than from a caller, so the source can hold files this class cannot address or
+ cannot read; those are skipped with a warning rather than failing the whole listing.
+
+ Documents are read and written as raw bytes. Decoding belongs to the subclasses,
+ which know how to report a document they cannot interpret and can skip just that one.
+ Bytes also keep what a caller hashes identical to what is stored, which text mode
+ would not: it rewrites line endings per platform.
+
+ Local writes stage the content beside the destination and move it into place, so an
+ interrupted write leaves the previous document intact rather than truncating it.
+
+ Conditional writes go through ``_save_document_conditional``, which refuses a write
+ whose stored state no longer matches the version the caller read. Atomic replacement
+ and conditional writing solve different problems: replacement stops a reader seeing a
+ half-written document, while the version check stops a second writer silently
+ discarding the first writer's edit. Each backend enforces the check with its own
+ primitive, so the comparison and the write cannot be separated by another writer:
+
+ - Blob creates upload with ``overwrite=False`` and updates send the ETag read moments
+ earlier as an ``If-Match`` precondition, both evaluated by the service.
+ - Local writes hold an OS advisory lock on a sibling file for the whole
+ read-compare-replace sequence, which serializes every writer that goes through this
+ class, including ones in other processes. The kernel owns the lock, so a writer that
+ dies releases it rather than stranding the document.
+
+ The local guarantee is cooperative: it binds writers using this class, not someone
+ editing the file directly. That case is covered instead by the version itself, which
+ hashes stored bytes and so changes under any edit, whoever made it.
+
+ Subclasses supply the extension and a human-readable label for error messages,
+ then expose a domain-specific API over the protected document operations.
+ """
+
+ LOCK_SUFFIX: str = ".lock"
+ LOCK_TIMEOUT_SECONDS: float = 10.0
+ LOCK_POLL_SECONDS: float = 0.05
+
+ def __init__(self, *, source: str, extension: str, source_label: str) -> None:
+ """
+ Initialize storage from a local directory or Azure Blob source URI.
+
+ Args:
+ source (str): Local directory path or Azure Blob container URI with an
+ optional blob prefix.
+ extension (str): File extension including the leading dot, such as ``".py"``.
+ source_label (str): Human-readable label naming the stored documents, used
+ in error messages.
+
+ Raises:
+ ValueError: If the source has an unsupported URI scheme.
+ """
+ self._source = source
+ self._extension = extension
+ self._source_label = source_label
+ self._is_blob = is_azure_blob_uri(source)
+ if not self._is_blob and urlparse(source).scheme and not Path(source).drive:
+ raise ValueError(
+ f"{source_label} source must be a local directory or Azure Blob container URI "
+ "with an optional blob prefix"
+ )
+ self._container_url, self._blob_prefix = self._parse_blob_source() if self._is_blob else (None, "")
+
+ @property
+ def display_source(self) -> str:
+ """Storage source without Azure Blob credentials."""
+ if not self._is_blob:
+ return self._source
+ return redact_url_credentials(self._source)
+
+ def _get_document_source(self, name: str) -> str:
+ """
+ Get the credential-free location of one document.
+
+ Returns:
+ str: Local file path or Azure Blob URI for the document.
+
+ Raises:
+ ValueError: If *name* is not a legal registry name.
+ """
+ validate_registry_name(name)
+ if self._is_blob:
+ return f"{self.display_source.rstrip('/')}/{name}{self._extension}"
+ return str(self._local_directory() / f"{name}{self._extension}")
+
+ def _list_documents(self) -> dict[str, bytes]:
+ """
+ Read every stored document that the single-document operations can address.
+
+ A name is checked before its content is read, so a file this class cannot address
+ is never opened and cannot fail the listing on its way out.
+
+ Returns:
+ dict[str, bytes]: Document content keyed by name.
+ """
+ return self._list_blob_documents() if self._is_blob else self._list_local_documents()
+
+ def _list_local_documents(self) -> dict[str, bytes]:
+ """
+ Read addressable documents from the configured local directory.
+
+ Returns:
+ dict[str, bytes]: Document content keyed by file stem.
+ """
+ directory = self._local_directory(create=True)
+ documents: dict[str, bytes] = {}
+ for path in sorted(directory.glob(f"*{self._extension}")):
+ if not self._is_addressable_name(path.stem):
+ continue
+ try:
+ documents[path.stem] = path.read_bytes()
+ except OSError as error:
+ logger.warning(f"Skipping unreadable document '{path.name}' in {self.display_source}: {error}")
+ return documents
+
+ def _is_addressable_name(self, name: str) -> bool:
+ """
+ Return whether a discovered document name is one this storage can address.
+
+ Ordinary files such as ``__init__.py`` or ``My-Script.py`` can sit alongside
+ valid documents, and a caller that fed such a name back into a read, write, or
+ delete would get a ``ValueError`` it has no way to anticipate.
+
+ Returns:
+ bool: Whether the name is a legal registry name.
+ """
+ try:
+ validate_registry_name(name)
+ except ValueError as error:
+ logger.warning(f"Ignoring stored document '{name}{self._extension}' in {self.display_source}: {error}")
+ return False
+ return True
+
+ def _read_document_bytes(self, name: str) -> bytes | None:
+ """
+ Read the raw bytes of one document.
+
+ Returns:
+ bytes | None: Document content, or ``None`` if it does not exist.
+
+ Raises:
+ ValueError: If *name* is not a legal registry name.
+ """
+ validate_registry_name(name)
+ if self._is_blob:
+ from azure.core.exceptions import ResourceNotFoundError
+
+ with self._open_container_client() as client:
+ try:
+ return client.download_blob(self._get_blob_name(name)).readall()
+ except ResourceNotFoundError:
+ return None
+
+ path = self._local_directory() / f"{name}{self._extension}"
+ return path.read_bytes() if path.is_file() else None
+
+ def _save_document(self, *, name: str, content: bytes) -> None:
+ """
+ Persist one document, replacing any existing content.
+
+ Raises:
+ ValueError: If *name* is not a legal registry name.
+ """
+ validate_registry_name(name)
+ if self._is_blob:
+ with self._open_container_client() as client:
+ client.upload_blob(name=self._get_blob_name(name), data=content, overwrite=True)
+ else:
+ directory = self._local_directory(create=True)
+ self._replace_file(path=directory / f"{name}{self._extension}", content=content)
+
+ @staticmethod
+ def _compute_version(content: bytes) -> str:
+ """
+ Create an opaque version token from stored document content.
+
+ The token hashes the stored bytes rather than recording writes made through this
+ class, so an edit made by hand or by another tool invalidates it too.
+
+ Returns:
+ str: The document-state version token.
+ """
+ return hashlib.sha256(content).hexdigest()
+
+ def _conflict_error(
+ self, *, name: str, expected_version: str | None, actual_version: str | None
+ ) -> DocumentConflictError:
+ """
+ Build the error raised when a conditional write is refused.
+
+ Subclasses override this to surface a domain-specific error type without
+ reimplementing the comparison that detects the conflict.
+
+ Returns:
+ DocumentConflictError: The error describing the version mismatch.
+ """
+ return DocumentConflictError(
+ name=name,
+ expected_version=expected_version,
+ actual_version=actual_version,
+ label=self._source_label,
+ )
+
+ def _save_document_conditional(self, *, name: str, content: bytes, expected_version: str | None) -> str:
+ """
+ Persist one document only while storage still holds *expected_version*.
+
+ Args:
+ name (str): The document name to write.
+ content (bytes): The content to store.
+ expected_version (str | None): ``None`` to create a document that must not
+ already exist, or the version read before the content was edited.
+
+ Returns:
+ str: The version token for the newly stored content.
+
+ Raises:
+ DocumentConflictError: If the stored version does not match *expected_version*.
+ ValueError: If *name* is not a legal registry name.
+ TimeoutError: If a local write could not acquire the document lock.
+ """
+ validate_registry_name(name)
+ if self._is_blob:
+ return self._save_blob_conditional(name=name, content=content, expected_version=expected_version)
+ return self._save_local_conditional(name=name, content=content, expected_version=expected_version)
+
+ def _save_local_conditional(self, *, name: str, content: bytes, expected_version: str | None) -> str:
+ """
+ Write a local document while holding the lock that covers its version check.
+
+ Returns:
+ str: The version token for the newly stored content.
+
+ Raises:
+ DocumentConflictError: If the stored version does not match *expected_version*.
+ TimeoutError: If the document lock could not be acquired.
+ """
+ path = self._local_directory(create=True) / f"{name}{self._extension}"
+ with self._local_document_lock(path):
+ stored = path.read_bytes() if path.is_file() else None
+ actual_version = None if stored is None else self._compute_version(stored)
+ if actual_version != expected_version:
+ raise self._conflict_error(name=name, expected_version=expected_version, actual_version=actual_version)
+ self._replace_file(path=path, content=content)
+ return self._compute_version(content)
+
+ def _save_blob_conditional(self, *, name: str, content: bytes, expected_version: str | None) -> str:
+ """
+ Write a blob behind a precondition the service evaluates, not an unconditional overwrite.
+
+ Returns:
+ str: The version token for the newly stored content.
+
+ Raises:
+ DocumentConflictError: If the stored version does not match *expected_version*.
+ """
+ from azure.core import MatchConditions
+ from azure.core.exceptions import ResourceExistsError, ResourceModifiedError, ResourceNotFoundError
+
+ blob_name = self._get_blob_name(name)
+ with self._open_container_client() as client:
+ try:
+ if expected_version is None:
+ client.upload_blob(name=blob_name, data=content, overwrite=False)
+ else:
+ etag = self._read_unmodified_blob_etag(
+ client=client, blob_name=blob_name, name=name, expected_version=expected_version
+ )
+ client.upload_blob(
+ name=blob_name,
+ data=content,
+ overwrite=True,
+ etag=etag,
+ match_condition=MatchConditions.IfNotModified,
+ )
+ except (ResourceExistsError, ResourceModifiedError, ResourceNotFoundError):
+ raise self._conflict_error(
+ name=name,
+ expected_version=expected_version,
+ actual_version=self._read_blob_version(client=client, blob_name=blob_name),
+ ) from None
+ return self._compute_version(content)
+
+ def _read_unmodified_blob_etag(
+ self, *, client: ContainerClient, blob_name: str, name: str, expected_version: str
+ ) -> str:
+ """
+ Read the ETag of a blob that still holds *expected_version*.
+
+ The ETag is only a race guard: the version the caller holds is compared first, so
+ the public token stays the content hash on both backends.
+
+ Returns:
+ str: The ETag to send as the write precondition.
+
+ Raises:
+ DocumentConflictError: If the blob is absent or no longer holds that version.
+ """
+ from azure.core.exceptions import ResourceNotFoundError
+
+ try:
+ downloader = client.download_blob(blob_name)
+ except ResourceNotFoundError:
+ raise self._conflict_error(name=name, expected_version=expected_version, actual_version=None) from None
+ actual_version = self._compute_version(downloader.readall())
+ if actual_version != expected_version:
+ raise self._conflict_error(name=name, expected_version=expected_version, actual_version=actual_version)
+ return downloader.properties.etag
+
+ def _read_blob_version(self, *, client: ContainerClient, blob_name: str) -> str | None:
+ """
+ Read the stored version of one blob.
+
+ Returns:
+ str | None: The version token, or ``None`` if the blob does not exist.
+ """
+ from azure.core.exceptions import ResourceNotFoundError
+
+ try:
+ return self._compute_version(client.download_blob(blob_name).readall())
+ except ResourceNotFoundError:
+ return None
+
+ @contextmanager
+ def _local_document_lock(self, path: Path) -> Generator[None, None, None]:
+ """
+ Hold an exclusive lock covering one document for the duration of the block.
+
+ The lock is an OS advisory lock taken on a sibling file, so it excludes writers in
+ other processes as well as other threads, and the kernel releases it if the holder
+ exits without cleaning up. Crash recovery therefore needs no timeout heuristic: a
+ lock is held only while its owner is alive. The file itself stays in place because
+ it carries no state and deleting it would let two writers hold what they each
+ believe is the same lock while the path pointed at different files. It does not
+ carry the document extension, so listing never sees it.
+
+ Yields:
+ None: Control while the lock is held.
+
+ Raises:
+ TimeoutError: If the lock could not be acquired.
+ """
+ lock_path = path.with_name(f".{path.name}{self.LOCK_SUFFIX}")
+ descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR)
+ try:
+ self._acquire_document_lock(descriptor=descriptor, lock_path=lock_path)
+ try:
+ yield
+ finally:
+ _release_exclusive_lock(descriptor)
+ finally:
+ os.close(descriptor)
+
+ def _acquire_document_lock(self, *, descriptor: int, lock_path: Path) -> None:
+ """
+ Wait for the exclusive lock on an open lock file until the wait budget runs out.
+
+ Args:
+ descriptor (int): Open descriptor for the lock file.
+ lock_path (Path): Path of the lock file, named in the timeout message.
+
+ Raises:
+ TimeoutError: If the lock is still held when the wait budget runs out.
+ """
+ deadline = time.monotonic() + self.LOCK_TIMEOUT_SECONDS
+ while not _try_acquire_exclusive_lock(descriptor):
+ if time.monotonic() >= deadline:
+ raise TimeoutError(f"Timed out waiting to write '{lock_path.name}'; another writer still holds it.")
+ time.sleep(self.LOCK_POLL_SECONDS)
+
+ @staticmethod
+ def _replace_file(*, path: Path, content: bytes) -> None:
+ """
+ Write *content* to *path* without destroying what is already there on failure.
+
+ Writing in place truncates the destination before the new content lands, so an
+ interrupted write would leave the stored document empty and a concurrent reader
+ could observe a half-written one. Staging the content in a sibling temporary file
+ and moving it over the destination keeps the previous document readable until the
+ new one is complete. The temporary file does not carry the document extension, so
+ a crash between the two steps cannot leave something that listing would pick up.
+
+ Raises:
+ OSError: If the document could not be written.
+ """
+ descriptor, temporary_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
+ temporary_path = Path(temporary_name)
+ try:
+ with os.fdopen(descriptor, "wb") as file:
+ file.write(content)
+ file.flush()
+ os.fsync(file.fileno())
+ os.replace(temporary_path, path)
+ except BaseException:
+ temporary_path.unlink(missing_ok=True)
+ raise
+
+ def _delete_document(self, name: str) -> None:
+ """
+ Delete one document if it exists.
+
+ Raises:
+ ValueError: If *name* is not a legal registry name.
+ """
+ validate_registry_name(name)
+ if self._is_blob:
+ from azure.core.exceptions import ResourceNotFoundError
+
+ with self._open_container_client() as client:
+ with suppress(ResourceNotFoundError):
+ client.delete_blob(self._get_blob_name(name))
+ else:
+ (self._local_directory() / f"{name}{self._extension}").unlink(missing_ok=True)
+
+ def _local_directory(self, *, create: bool = False) -> Path:
+ """
+ Resolve the configured local directory.
+
+ Returns:
+ Path: The expanded directory path.
+ """
+ directory = Path(self._source).expanduser()
+ if create:
+ directory.mkdir(parents=True, exist_ok=True)
+ return directory
+
+ def _list_blob_documents(self) -> dict[str, bytes]:
+ """
+ Read addressable documents from the configured Azure Blob container.
+
+ Returns:
+ dict[str, bytes]: Document content keyed by blob stem.
+ """
+ from azure.core.exceptions import AzureError
+
+ documents: dict[str, bytes] = {}
+ with self._open_container_client() as client:
+ prefix = f"{self._blob_prefix}/" if self._blob_prefix else None
+ blobs = client.list_blobs(name_starts_with=prefix) if prefix else client.list_blobs()
+ blob_names = sorted(
+ blob.name for blob in blobs if self._is_direct_document_blob(blob_name=blob.name, prefix=prefix)
+ )
+ for blob_name in blob_names:
+ name = PurePosixPath(blob_name.removeprefix(prefix or "")).stem
+ if not self._is_addressable_name(name):
+ continue
+ try:
+ documents[name] = client.download_blob(blob_name).readall()
+ except AzureError as error:
+ logger.warning(f"Skipping unreadable document '{blob_name}' in {self.display_source}: {error}")
+ return documents
+
+ def _parse_blob_source(self) -> tuple[str, str]:
+ """
+ Split the configured source into a container URL and blob prefix.
+
+ Returns:
+ tuple[str, str]: The container URL and decoded blob prefix.
+ """
+ parsed_uri = urlparse(self._source)
+ container_path, _, prefix = parsed_uri.path.strip("/").partition("/")
+ container_url = parsed_uri._replace(path=f"/{container_path}", fragment="").geturl()
+ return container_url, unquote(prefix).strip("/")
+
+ def _get_blob_name(self, name: str) -> str:
+ """
+ Build the blob name for one document.
+
+ Returns:
+ str: The prefixed blob name.
+ """
+ file_name = f"{name}{self._extension}"
+ return f"{self._blob_prefix}/{file_name}" if self._blob_prefix else file_name
+
+ def _is_direct_document_blob(self, *, blob_name: str, prefix: str | None) -> bool:
+ """Return whether a blob is a direct child of the configured prefix with the expected extension."""
+ if prefix and not blob_name.startswith(prefix):
+ return False
+ relative_name = blob_name.removeprefix(prefix or "")
+ return "/" not in relative_name and PurePosixPath(relative_name).suffix == self._extension
+
+ @contextmanager
+ def _open_container_client(self) -> Generator[ContainerClient, None, None]:
+ """
+ Yield an Azure Blob container client and close its credential.
+
+ Yields:
+ ContainerClient: A client scoped to the configured container.
+
+ Raises:
+ RuntimeError: If called for a non-Blob source.
+ """
+ from azure.identity import DefaultAzureCredential
+ from azure.storage.blob import ContainerClient
+
+ if self._container_url is None:
+ raise RuntimeError("Azure Blob container URL is not configured")
+
+ if has_sas_signature(self._container_url):
+ with ContainerClient.from_container_url(container_url=self._container_url) as client:
+ yield client
+ return
+
+ with DefaultAzureCredential() as credential:
+ with ContainerClient.from_container_url(
+ container_url=self._container_url,
+ credential=credential,
+ ) as client:
+ yield client
diff --git a/pyrit/registry/scenario_preset_storage.py b/pyrit/registry/scenario_preset_storage.py
new file mode 100644
index 0000000000..00adbf79ac
--- /dev/null
+++ b/pyrit/registry/scenario_preset_storage.py
@@ -0,0 +1,244 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Storage backends for scenario presets."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import TYPE_CHECKING
+
+from pyrit.models.catalog.scenario_preset import ScenarioPreset, StoredPreset
+from pyrit.registry.file_document_storage import DocumentConflictError, FileDocumentStorage
+
+if TYPE_CHECKING:
+ from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+class ScenarioPresetConflictError(DocumentConflictError):
+ """A stored preset changed after the caller read it."""
+
+ def __init__(self, *, name: str, expected_version: str | None, actual_version: str | None) -> None:
+ """Initialize the error with the versions that failed to match."""
+ super().__init__(
+ name=name,
+ expected_version=expected_version,
+ actual_version=actual_version,
+ label="Scenario preset",
+ )
+
+
+class ScenarioPresetStorage(FileDocumentStorage):
+ """
+ Read and write scenario presets as JSON documents.
+
+ Storage is read through on every call rather than cached, because the same directory
+ or blob container is routinely shared between a notebook, the API, and a second
+ process; a cache would serve edits those callers can no longer see.
+
+ Writes are guarded by an optimistic-concurrency check. A caller supplies the version
+ it read, and the save is refused unless storage still holds that version. Enforcing
+ that check without a window for a second writer to slip through is the shared
+ storage layer's job, so this class only serializes, validates, and names documents.
+ """
+
+ def __init__(self, *, source: str | None = None) -> None:
+ """
+ Initialize storage from a local directory or Azure Blob source URI.
+
+ Args:
+ source (str | None): Local directory or Azure Blob source URI. Defaults to
+ ``scenario_presets`` under the PyRIT configuration directory.
+
+ Raises:
+ ValueError: If the source has an unsupported URI scheme.
+ """
+ super().__init__(
+ source=source or str(self._get_default_storage_dir()),
+ extension=".json",
+ source_label="Scenario preset",
+ )
+
+ @staticmethod
+ def _get_default_storage_dir() -> Path:
+ """
+ Get the default directory for storing presets.
+
+ Returns:
+ Path: Path to ``~/.pyrit/scenario_presets/``, created if needed.
+ """
+ # Deferred: importing pyrit.common.path triggers pyrit __init__.py
+ from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
+
+ presets_dir = CONFIGURATION_DIRECTORY_PATH / "scenario_presets"
+ presets_dir.mkdir(parents=True, exist_ok=True)
+ return presets_dir
+
+ def get_preset_source(self, name: str) -> str:
+ """
+ Get the credential-free location of one stored preset.
+
+ Returns:
+ str: Local file path or Azure Blob URI for the preset.
+
+ Raises:
+ ValueError: If *name* is not a legal preset name.
+ """
+ return self._get_document_source(name)
+
+ def list_presets(self) -> dict[str, StoredPreset]:
+ """
+ Read every stored preset, skipping any that cannot be parsed.
+
+ A malformed, unreadable, or hand-edited file must not prevent the rest of the
+ library from loading, so failures are logged and that preset is omitted.
+
+ Returns:
+ dict[str, StoredPreset]: Stored presets keyed by name.
+ """
+ presets: dict[str, StoredPreset] = {}
+ for name, content in self._list_documents().items():
+ preset = self._parse_preset(name=name, content=content)
+ if preset is not None:
+ presets[preset.name] = StoredPreset(preset=preset, version=self._compute_version(content))
+ return presets
+
+ def load_preset(self, name: str) -> StoredPreset | None:
+ """
+ Read one stored preset.
+
+ Returns:
+ StoredPreset | None: The preset and its version, or ``None`` if it is absent
+ or malformed.
+
+ Raises:
+ ValueError: If *name* is not a legal preset name.
+ """
+ content = self._read_document_bytes(name)
+ if content is None:
+ return None
+ preset = self._parse_preset(name=name, content=content)
+ if preset is None:
+ return None
+ return StoredPreset(preset=preset, version=self._compute_version(content))
+
+ def get_preset_version(self, name: str) -> str | None:
+ """
+ Read the version of one stored document without parsing it.
+
+ A document that cannot be parsed is otherwise unreachable: ``list_presets`` skips
+ it, ``load_preset`` returns ``None``, and a create is refused because the document
+ exists. Exposing its version lets a caller offer to overwrite the broken file
+ instead of leaving the name permanently unusable. The document is never decoded
+ here, so a file that is not even valid text can still be replaced.
+
+ Returns:
+ str | None: The document version, or ``None`` if no document is stored.
+
+ Raises:
+ ValueError: If *name* is not a legal preset name.
+ """
+ content = self._read_document_bytes(name)
+ return None if content is None else self._compute_version(content)
+
+ def save_preset(self, *, preset: ScenarioPreset, expected_version: str | None) -> StoredPreset:
+ """
+ Persist one preset.
+
+ The caller states its intent through *expected_version* rather than through
+ anything on *preset*, so creating and updating are never ambiguous and a
+ client-supplied version can never be trusted into storage.
+
+ A document that exists but cannot be parsed still has a version, so creating
+ over a malformed file conflicts rather than silently discarding it.
+
+ Args:
+ preset (ScenarioPreset): The preset to persist.
+ expected_version (str | None): ``None`` to create a preset that must not already
+ exist, or the version returned when the edited preset was read.
+
+ Returns:
+ StoredPreset: The persisted preset and its new version.
+
+ Raises:
+ ScenarioPresetConflictError: If the stored version does not match *expected_version*.
+ ValueError: If the preset name is not a legal preset name.
+ TimeoutError: If a local write could not acquire the document lock.
+ """
+ content = self._serialize_preset(preset)
+ version = self._save_document_conditional(name=preset.name, content=content, expected_version=expected_version)
+ return StoredPreset(preset=preset, version=version)
+
+ def delete_preset(self, name: str) -> None:
+ """
+ Delete one stored preset if it exists.
+
+ Raises:
+ ValueError: If *name* is not a legal preset name.
+ """
+ self._delete_document(name)
+
+ def _conflict_error(
+ self, *, name: str, expected_version: str | None, actual_version: str | None
+ ) -> ScenarioPresetConflictError:
+ """
+ Report a refused write as a preset conflict.
+
+ Returns:
+ ScenarioPresetConflictError: The error describing the version mismatch.
+ """
+ return ScenarioPresetConflictError(name=name, expected_version=expected_version, actual_version=actual_version)
+
+ @staticmethod
+ def _serialize_preset(preset: ScenarioPreset) -> bytes:
+ """
+ Serialize a preset to stored JSON.
+
+ Unset fields are omitted rather than written as ``null`` so a stored preset reads
+ as the set of decisions its author actually made. The name is omitted too: it is
+ the document key, and writing it would invite a hand-editor to change it and
+ expect a rename that cannot happen.
+
+ Returns:
+ bytes: Encoded JSON document content.
+ """
+ payload = preset.model_dump(mode="json", exclude_none=True, exclude={"name"})
+ return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8")
+
+ @staticmethod
+ def _parse_preset(*, name: str, content: bytes) -> ScenarioPreset | None:
+ """
+ Parse one stored preset document.
+
+ The document name is authoritative, overriding any ``name`` inside the payload.
+ It is the storage key, so letting the payload disagree would make a load return a
+ preset whose subsequent save wrote to a different file.
+
+ Returns:
+ ScenarioPreset | None: The parsed preset, or ``None`` if it is malformed.
+ """
+ try:
+ text = content.decode("utf-8")
+ except UnicodeDecodeError:
+ logger.warning(f"Skipping stored scenario preset '{name}': it is not valid UTF-8 text.")
+ return None
+
+ try:
+ payload = json.loads(text)
+ except ValueError:
+ logger.exception(f"Skipping stored scenario preset '{name}': it is not valid JSON.")
+ return None
+
+ if not isinstance(payload, dict):
+ logger.error(f"Skipping stored scenario preset '{name}': it is not a JSON object.")
+ return None
+
+ fields = {**payload, "name": name}
+ try:
+ return ScenarioPreset.model_validate(fields)
+ except Exception:
+ logger.exception(f"Skipping stored scenario preset '{name}': it is not a valid preset.")
+ return None
diff --git a/pyrit/scenario/core/__init__.py b/pyrit/scenario/core/__init__.py
index 992393a906..5538465016 100644
--- a/pyrit/scenario/core/__init__.py
+++ b/pyrit/scenario/core/__init__.py
@@ -15,6 +15,12 @@
resolve_technique_factories,
resolve_technique_factories_for_techniques,
)
+ from pyrit.scenario.core._technique_tokens import (
+ CONVERTER_MODIFIER_PREFIX,
+ TechniqueToken,
+ converter_name_from_modifier,
+ parse_technique_token,
+ )
from pyrit.scenario.core.atomic_attack import AtomicAttack
from pyrit.scenario.core.attack_technique import AttackTechnique
from pyrit.scenario.core.attack_technique_factory import AttackTechniqueFactory, ScorerOverridePolicy
@@ -41,6 +47,7 @@
"AttackTechnique": "pyrit.scenario.core.attack_technique",
"AttackTechniqueFactory": "pyrit.scenario.core.attack_technique_factory",
"BaselineAttackPolicy": "pyrit.scenario.core.scenario",
+ "CONVERTER_MODIFIER_PREFIX": "pyrit.scenario.core._technique_tokens",
"CompoundDatasetAttackConfiguration": "pyrit.scenario.core.dataset_configuration",
"DatasetAttackConfiguration": "pyrit.scenario.core.dataset_configuration",
"DatasetConfiguration": "pyrit.scenario.core.dataset_configuration",
@@ -54,9 +61,12 @@
"ScenarioTechnique": "pyrit.scenario.core.scenario_technique",
"ScorerOverridePolicy": "pyrit.scenario.core.attack_technique_factory",
"TechniqueResolutionError": "pyrit.scenario.core._technique_resolution",
+ "TechniqueToken": "pyrit.scenario.core._technique_tokens",
+ "converter_name_from_modifier": "pyrit.scenario.core._technique_tokens",
"get_default_scorer_target": "pyrit.scenario.core.scenario_target_defaults",
"get_default_adversarial_target": "pyrit.scenario.core.scenario_target_defaults",
"override_default_adversarial_target": "pyrit.scenario.core.scenario_target_defaults",
+ "parse_technique_token": "pyrit.scenario.core._technique_tokens",
"resolve_technique_factories": "pyrit.scenario.core._technique_resolution",
"resolve_technique_factories_for_techniques": "pyrit.scenario.core._technique_resolution",
}
diff --git a/pyrit/scenario/core/_technique_tokens.py b/pyrit/scenario/core/_technique_tokens.py
new file mode 100644
index 0000000000..14be3d4881
--- /dev/null
+++ b/pyrit/scenario/core/_technique_tokens.py
@@ -0,0 +1,59 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Grammar for the technique tokens that scenario runs and presets accept.
+
+A token is a technique name optionally followed by colon-separated modifiers, as in
+``role_play:converter.translation_spanish``. The grammar is shared rather than owned by
+the launch path because every caller that reads a stored token has to agree on where the
+technique name ends: a validator that compares a whole token against the scenario's
+technique names rejects a token the launch path resolves successfully, and the operator
+is told the preset is broken when it is not.
+
+Only the split lives here. Turning a converter name into a converter instance needs the
+``ConverterRegistry``, which scenarios do not depend on.
+"""
+
+from __future__ import annotations
+
+from typing import NamedTuple
+
+CONVERTER_MODIFIER_PREFIX = "converter."
+
+
+class TechniqueToken(NamedTuple):
+ """One parsed technique token."""
+
+ base_name: str
+ modifiers: tuple[str, ...]
+
+
+def parse_technique_token(token: str) -> TechniqueToken:
+ """
+ Split one technique token into its technique name and its modifiers.
+
+ Args:
+ token (str): The token as written in a run request or a stored preset.
+
+ Returns:
+ TechniqueToken: The technique name and the modifiers that follow it, in token order.
+ """
+ base_name, _, remainder = token.partition(":")
+ modifiers = tuple(modifier for modifier in remainder.split(":") if modifier)
+ return TechniqueToken(base_name=base_name, modifiers=modifiers)
+
+
+def converter_name_from_modifier(modifier: str) -> str | None:
+ """
+ Read the converter name out of a modifier.
+
+ Args:
+ modifier (str): One modifier from a technique token.
+
+ Returns:
+ str | None: The converter name, or None when the modifier is not a converter modifier.
+ """
+ if not modifier.startswith(CONVERTER_MODIFIER_PREFIX):
+ return None
+ return modifier[len(CONVERTER_MODIFIER_PREFIX) :]
diff --git a/pyrit/setup/configuration_loader.py b/pyrit/setup/configuration_loader.py
index fb6d6850ed..3432f00bfe 100644
--- a/pyrit/setup/configuration_loader.py
+++ b/pyrit/setup/configuration_loader.py
@@ -112,6 +112,8 @@ class ConfigurationLoader(YamlLoadable):
bootstrap document should fail initialization.
custom_initializers_source: Local directory or Azure Blob container URI,
optionally followed by a blob prefix, used to persist custom initializer Python scripts.
+ scenario_presets_source: Local directory or Azure Blob container URI, optionally
+ followed by a blob prefix, used to persist scenario presets.
silent: Whether to suppress initialization messages.
seed: Optional root seed for deterministic converter operations.
operator: Name for the current operator, e.g. a team or username.
@@ -163,6 +165,7 @@ class ConfigurationLoader(YamlLoadable):
enable_live_reinitialization: bool = False
allow_custom_initializers: bool = False
custom_initializers_source: str | None = None
+ scenario_presets_source: str | None = None
server: dict[str, Any] | None = None
extensions: dict[str, Any] = field(default_factory=dict)
@@ -189,6 +192,7 @@ def __post_init__(self) -> None:
self._normalize_initializers()
self._validate_env_akv_ref()
self._validate_custom_initializers_source()
+ self._validate_scenario_presets_source()
self._normalize_server()
def _validate_allow_custom_initializers(self) -> None:
@@ -208,10 +212,31 @@ def _validate_custom_initializers_source(self) -> None:
Raises:
ValueError: If the source is not a non-empty string.
"""
- if self.custom_initializers_source is not None and (
- not isinstance(self.custom_initializers_source, str) or not self.custom_initializers_source.strip()
- ):
- raise ValueError("custom_initializers_source must be a non-empty local directory or container URI.")
+ self._validate_document_source(name="custom_initializers_source", value=self.custom_initializers_source)
+
+ def _validate_scenario_presets_source(self) -> None:
+ """
+ Validate the optional scenario preset storage source.
+
+ Raises:
+ ValueError: If the source is not a non-empty string.
+ """
+ self._validate_document_source(name="scenario_presets_source", value=self.scenario_presets_source)
+
+ @staticmethod
+ def _validate_document_source(*, name: str, value: Any) -> None:
+ """
+ Validate an optional document storage source.
+
+ Args:
+ name: The configuration key being validated, used in the error message.
+ value: The configured value.
+
+ Raises:
+ ValueError: If the source is not a non-empty string.
+ """
+ if value is not None and (not isinstance(value, str) or not value.strip()):
+ raise ValueError(f"{name} must be a non-empty local directory or container URI.")
def _validate_env_akv_ref(self) -> None:
"""
diff --git a/tests/unit/backend/test_scenario_preset_routes.py b/tests/unit/backend/test_scenario_preset_routes.py
new file mode 100644
index 0000000000..5c5ed146a6
--- /dev/null
+++ b/tests/unit/backend/test_scenario_preset_routes.py
@@ -0,0 +1,393 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Tests for the scenario preset routes."""
+
+from collections.abc import Iterator
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from azure.core.exceptions import AzureError
+from fastapi.testclient import TestClient
+
+from pyrit.backend.main import app
+from pyrit.backend.middleware.auth import current_user_name, require_admin
+from pyrit.backend.models.scenario_presets import (
+ PresetIssue,
+ ScenarioPresetListResponse,
+ ScenarioPresetResponse,
+)
+from pyrit.backend.services.scenario_preset_service import (
+ ScenarioPresetNotFoundError,
+ ScenarioPresetService,
+)
+from pyrit.models.catalog import ScenarioPreset, ScenarioRunSizeEstimate
+from pyrit.models.catalog.scenario import ScenarioRunSizeComponent
+from pyrit.registry import ScenarioPresetConflictError
+
+PRESET_NAME = "quick_scan"
+SCENARIO_NAME = "foundry.red_team_agent"
+
+
+@pytest.fixture
+def client(compatibility_headers: dict[str, str]) -> Iterator[TestClient]:
+ """Create a test client with admin authorization satisfied."""
+ app.dependency_overrides[require_admin] = lambda: None
+ try:
+ yield TestClient(app, headers=compatibility_headers)
+ finally:
+ app.dependency_overrides.pop(require_admin, None)
+
+
+@pytest.fixture
+def anonymous_client(compatibility_headers: dict[str, str]) -> Iterator[TestClient]:
+ """Create a test client without admin authorization."""
+ yield TestClient(app, headers=compatibility_headers)
+
+
+@pytest.fixture
+def service() -> Iterator[MagicMock]:
+ """Patch the preset service the routes resolve."""
+ mock_service = MagicMock(spec=ScenarioPresetService)
+ with patch(
+ "pyrit.backend.routes.scenario_presets.get_scenario_preset_service",
+ return_value=mock_service,
+ ):
+ yield mock_service
+
+
+@pytest.fixture
+def signed_in_user() -> Iterator[None]:
+ """Report a signed-in user to routes that record one."""
+ app.dependency_overrides[current_user_name] = lambda: "Ada Lovelace"
+ try:
+ yield
+ finally:
+ app.dependency_overrides.pop(current_user_name, None)
+
+
+def _estimate(count: int) -> ScenarioRunSizeEstimate:
+ """Build an exact run-size estimate of *count* attacks."""
+ return ScenarioRunSizeEstimate(
+ estimated_attack_count=count,
+ components=[ScenarioRunSizeComponent(label="Preset sweep", count=count)],
+ )
+
+
+def _preset(name: str = PRESET_NAME) -> ScenarioPreset:
+ """Build a minimal preset."""
+ return ScenarioPreset(name=name, scenario_name=SCENARIO_NAME)
+
+
+def _response(
+ *,
+ name: str = PRESET_NAME,
+ version: str = "v1",
+ issues: list[PresetIssue] | None = None,
+ run_size: ScenarioRunSizeEstimate | None = None,
+):
+ """Build a preset response envelope."""
+ return ScenarioPresetResponse(preset=_preset(name), version=version, issues=issues or [], run_size=run_size)
+
+
+class TestListPresets:
+ """GET /api/scenario-presets."""
+
+ def test_list_returns_the_source_and_items(self, client: TestClient, service: MagicMock) -> None:
+ service.list_presets_async = AsyncMock(
+ return_value=ScenarioPresetListResponse(source="/tmp/presets", items=[_response()])
+ )
+
+ response = client.get("/api/scenario-presets")
+
+ assert response.status_code == 200
+ body = response.json()
+ assert body["source"] == "/tmp/presets"
+ assert body["items"][0]["preset"]["name"] == PRESET_NAME
+ assert body["items"][0]["version"] == "v1"
+
+ def test_list_reports_advisory_issues(self, client: TestClient, service: MagicMock) -> None:
+ issue = PresetIssue(field="scenario_name", message="Scenario is not registered in this deployment.")
+ service.list_presets_async = AsyncMock(
+ return_value=ScenarioPresetListResponse(source="/tmp", items=[_response(issues=[issue])])
+ )
+
+ response = client.get("/api/scenario-presets")
+
+ assert response.json()["items"][0]["issues"] == [
+ {"field": "scenario_name", "message": "Scenario is not registered in this deployment."}
+ ]
+
+ def test_list_is_readable_without_admin(self, anonymous_client: TestClient, service: MagicMock) -> None:
+ service.list_presets_async = AsyncMock(return_value=ScenarioPresetListResponse(source="/tmp", items=[]))
+
+ assert anonymous_client.get("/api/scenario-presets").status_code == 200
+
+ def test_storage_failure_is_reported_without_sdk_detail(self, client: TestClient, service: MagicMock) -> None:
+ service.list_presets_async = AsyncMock(side_effect=AzureError("container 'x' key=secret"))
+
+ response = client.get("/api/scenario-presets")
+
+ assert response.status_code == 503
+ assert "secret" not in response.text
+
+ def test_each_preset_carries_its_own_run_size(self, client: TestClient, service: MagicMock) -> None:
+ estimate = _estimate(42)
+ service.list_presets_async = AsyncMock(
+ return_value=ScenarioPresetListResponse(source="/tmp", items=[_response(run_size=estimate)])
+ )
+
+ response = client.get("/api/scenario-presets")
+
+ assert response.json()["items"][0]["run_size"]["total_attack_count"] == 42
+ assert service.list_presets_async.call_args.kwargs["include_estimates"] is True
+
+ def test_sizing_can_be_skipped_so_the_table_paints_first(self, client: TestClient, service: MagicMock) -> None:
+ service.list_presets_async = AsyncMock(
+ return_value=ScenarioPresetListResponse(source="/tmp", items=[_response()])
+ )
+
+ response = client.get("/api/scenario-presets", params={"include_estimates": "false"})
+
+ assert response.status_code == 200
+ assert response.json()["items"][0]["run_size"] is None
+ assert service.list_presets_async.call_args.kwargs["include_estimates"] is False
+
+
+class TestGetPreset:
+ """GET /api/scenario-presets/{name}."""
+
+ def test_get_returns_the_preset_and_version(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response())
+
+ response = client.get(f"/api/scenario-presets/{PRESET_NAME}")
+
+ assert response.status_code == 200
+ assert response.json()["version"] == "v1"
+
+ def test_get_reports_a_missing_preset(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=None)
+
+ response = client.get("/api/scenario-presets/missing")
+
+ assert response.status_code == 404
+
+ def test_an_illegal_name_is_a_client_error(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(side_effect=ValueError("Invalid registry name 'Bad Name'"))
+
+ response = client.get("/api/scenario-presets/BadName")
+
+ assert response.status_code == 400
+
+
+class TestCreatePreset:
+ """POST /api/scenario-presets."""
+
+ def test_create_returns_201_with_the_new_version(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response(version="v2"))
+
+ response = client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert response.status_code == 201
+ assert response.json()["version"] == "v2"
+ assert service.save_preset_async.call_args.kwargs["expected_version"] is None
+
+ def test_create_over_an_existing_name_is_a_conflict(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(
+ side_effect=ScenarioPresetConflictError(name=PRESET_NAME, expected_version=None, actual_version="v1")
+ )
+
+ response = client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert response.status_code == 409
+
+ def test_create_requires_admin(self, anonymous_client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ response = anonymous_client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert response.status_code == 403
+ service.save_preset_async.assert_not_called()
+
+ def test_an_unknown_field_is_rejected(self, client: TestClient, service: MagicMock) -> None:
+ payload = _preset().model_dump()
+ payload["tecniques"] = ["crescendo"]
+
+ response = client.post("/api/scenario-presets", json=payload)
+
+ assert response.status_code == 422
+
+ @pytest.mark.usefixtures("signed_in_user")
+ def test_the_signed_in_user_is_recorded_as_the_author(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert service.save_preset_async.call_args.kwargs["preset"].author == "Ada Lovelace"
+
+ @pytest.mark.usefixtures("signed_in_user")
+ def test_an_author_in_the_body_survives_import(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+ payload = _preset().model_dump()
+ payload["author"] = "Grace Hopper"
+
+ client.post("/api/scenario-presets", json=payload)
+
+ assert service.save_preset_async.call_args.kwargs["preset"].author == "Grace Hopper"
+
+ def test_an_unauthenticated_deployment_leaves_the_author_unset(
+ self, client: TestClient, service: MagicMock
+ ) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ client.post("/api/scenario-presets", json=_preset().model_dump())
+
+ assert service.save_preset_async.call_args.kwargs["preset"].author is None
+
+
+class TestUpdatePreset:
+ """PUT /api/scenario-presets/{name}."""
+
+ def test_update_passes_the_expected_version_through(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response(version="v2"))
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 200
+ assert service.save_preset_async.call_args.kwargs["expected_version"] == "v1"
+
+ def test_a_body_naming_a_different_preset_is_rejected(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ response = client.put(
+ "/api/scenario-presets/other_name",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 400
+ service.save_preset_async.assert_not_called()
+
+ def test_a_stale_version_is_a_conflict(self, client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(
+ side_effect=ScenarioPresetConflictError(name=PRESET_NAME, expected_version="v1", actual_version="v2")
+ )
+
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 409
+
+ def test_an_omitted_version_is_rejected(self, client: TestClient, service: MagicMock) -> None:
+ response = client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump()},
+ )
+
+ assert response.status_code == 422
+
+ def test_update_requires_admin(self, anonymous_client: TestClient, service: MagicMock) -> None:
+ service.save_preset_async = AsyncMock(return_value=_response())
+
+ response = anonymous_client.put(
+ f"/api/scenario-presets/{PRESET_NAME}",
+ json={"preset": _preset().model_dump(), "expected_version": "v1"},
+ )
+
+ assert response.status_code == 403
+ service.save_preset_async.assert_not_called()
+
+
+class TestDeletePreset:
+ """DELETE /api/scenario-presets/{name}."""
+
+ def test_delete_returns_204(self, client: TestClient, service: MagicMock) -> None:
+ service.delete_preset_async = AsyncMock(return_value=None)
+
+ response = client.delete(f"/api/scenario-presets/{PRESET_NAME}")
+
+ assert response.status_code == 204
+
+ def test_delete_reports_a_missing_preset(self, client: TestClient, service: MagicMock) -> None:
+ service.delete_preset_async = AsyncMock(side_effect=ScenarioPresetNotFoundError(PRESET_NAME))
+
+ response = client.delete(f"/api/scenario-presets/{PRESET_NAME}")
+
+ assert response.status_code == 404
+
+ def test_delete_requires_admin(self, anonymous_client: TestClient, service: MagicMock) -> None:
+ service.delete_preset_async = AsyncMock(return_value=None)
+
+ response = anonymous_client.delete(f"/api/scenario-presets/{PRESET_NAME}")
+
+ assert response.status_code == 403
+ service.delete_preset_async.assert_not_called()
+
+
+class TestResolvePreset:
+ """POST /api/scenario-presets/{name}/resolve."""
+
+ def test_resolve_returns_a_run_request(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response())
+ service.resolve_run_request = ScenarioPresetService.resolve_run_request
+
+ response = client.post(
+ f"/api/scenario-presets/{PRESET_NAME}/resolve",
+ json={"target_name": "gpt4"},
+ )
+
+ assert response.status_code == 200
+ body = response.json()
+ assert body["scenario_name"] == SCENARIO_NAME
+ assert body["target_name"] == "gpt4"
+ assert body["max_concurrency"] == 10
+ assert body["techniques"] is None
+
+ def test_resolve_reports_a_missing_preset(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=None)
+
+ response = client.post("/api/scenario-presets/missing/resolve", json={"target_name": "gpt4"})
+
+ assert response.status_code == 404
+
+ def test_resolve_requires_a_target(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response())
+
+ response = client.post(f"/api/scenario-presets/{PRESET_NAME}/resolve", json={})
+
+ assert response.status_code == 422
+
+ def test_resolve_accepts_the_version_the_caller_read(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response(version="v1"))
+ service.resolve_run_request = ScenarioPresetService.resolve_run_request
+
+ response = client.post(
+ f"/api/scenario-presets/{PRESET_NAME}/resolve",
+ json={"target_name": "gpt4", "expected_version": "v1"},
+ )
+
+ assert response.status_code == 200
+
+ def test_a_preset_edited_after_it_was_read_is_a_conflict(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response(version="v2"))
+ service.resolve_run_request = ScenarioPresetService.resolve_run_request
+
+ response = client.post(
+ f"/api/scenario-presets/{PRESET_NAME}/resolve",
+ json={"target_name": "gpt4", "expected_version": "v1"},
+ )
+
+ assert response.status_code == 409
+ assert PRESET_NAME in response.json()["detail"]
+
+ def test_an_omitted_version_resolves_whatever_is_stored(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response(version="v9"))
+ service.resolve_run_request = ScenarioPresetService.resolve_run_request
+
+ response = client.post(f"/api/scenario-presets/{PRESET_NAME}/resolve", json={"target_name": "gpt4"})
+
+ assert response.status_code == 200
diff --git a/tests/unit/backend/test_scenario_preset_service.py b/tests/unit/backend/test_scenario_preset_service.py
new file mode 100644
index 0000000000..c442018ac0
--- /dev/null
+++ b/tests/unit/backend/test_scenario_preset_service.py
@@ -0,0 +1,490 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Tests for the scenario preset service."""
+
+from pathlib import Path
+from typing import cast
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from pyrit.backend.models.scenario_presets import ResolveScenarioPresetRequest
+from pyrit.backend.services import scenario_preset_service as preset_service_module
+from pyrit.backend.services.scenario_preset_service import (
+ ScenarioPresetNotFoundError,
+ ScenarioPresetService,
+ get_scenario_preset_service,
+)
+from pyrit.models import Parameter
+from pyrit.models.catalog import RegisteredScenario, ScenarioPreset
+from pyrit.models.catalog.scenario import ScenarioRunSizeComponent, ScenarioRunSizeEstimate
+from pyrit.registry import ScenarioPresetConflictError
+
+SCENARIO_NAME = "foundry.red_team_agent"
+
+
+def _registered_scenario(
+ *,
+ all_techniques: list[str] | None = None,
+ aggregate_techniques: list[str] | None = None,
+ supported_parameters: list[str] | None = None,
+ baseline_policy: str = "enabled",
+) -> RegisteredScenario:
+ """Build a registered scenario with only the fields preset validation reads."""
+ techniques = all_techniques if all_techniques is not None else ["crescendo", "flip"]
+ return RegisteredScenario(
+ scenario_name=SCENARIO_NAME,
+ scenario_type="RedTeamAgentScenario",
+ description="Test scenario",
+ default_technique=techniques[0],
+ all_techniques=techniques,
+ aggregate_techniques=aggregate_techniques or [],
+ default_datasets=["harmbench"],
+ baseline_policy=baseline_policy,
+ supported_parameters=[
+ Parameter(name=name, description="", param_type=str) for name in (supported_parameters or ["max_turns"])
+ ],
+ default_run_size=ScenarioRunSizeEstimate.unavailable(),
+ )
+
+
+def _estimate(count: int) -> ScenarioRunSizeEstimate:
+ """Build an exact run-size estimate of *count* attacks."""
+ return ScenarioRunSizeEstimate(
+ estimated_attack_count=count,
+ components=[ScenarioRunSizeComponent(label="Preset sweep", count=count)],
+ )
+
+
+@pytest.fixture
+def service(tmp_path: Path) -> ScenarioPresetService:
+ """Create a service backed by an isolated preset directory."""
+ instance = ScenarioPresetService()
+ instance.configure_source(str(tmp_path))
+ return instance
+
+
+@pytest.fixture
+def registered_scenario() -> RegisteredScenario:
+ """Patch the scenario lookup so presets resolve against a known scenario."""
+ scenario = _registered_scenario()
+ with patch("pyrit.backend.services.scenario_preset_service.get_scenario_service") as mock_factory:
+ mock_factory.return_value.get_scenario_async = AsyncMock(return_value=scenario)
+ mock_factory.return_value.estimate_scenario_run_size_async = AsyncMock(return_value=_estimate(12))
+ yield scenario
+
+
+@pytest.fixture
+def scenario_service(registered_scenario: RegisteredScenario) -> MagicMock:
+ """Return the scenario service the preset service calls while it is patched."""
+ return cast("MagicMock", preset_service_module.get_scenario_service())
+
+
+def _preset(name: str = "quick_scan", **overrides: object) -> ScenarioPreset:
+ """Build a preset that resolves cleanly against the fixture scenario."""
+ fields: dict[str, object] = {"name": name, "scenario_name": SCENARIO_NAME}
+ fields.update(overrides)
+ return ScenarioPreset(**fields) # type: ignore[arg-type]
+
+
+def _converter_registry(*registered_names: str) -> MagicMock:
+ """Patch target standing in for the converter instances this deployment registered."""
+ registry = MagicMock()
+ registry.get_registry_singleton.return_value.instances.get.side_effect = (
+ lambda name: MagicMock() if name in registered_names else None
+ )
+ return registry
+
+
+class TestPresetCrud:
+ """CRUD behavior over the configured storage source."""
+
+ async def test_list_presets_is_empty_when_nothing_is_stored(
+ self, service: ScenarioPresetService, tmp_path: Path, registered_scenario: RegisteredScenario
+ ) -> None:
+ response = await service.list_presets_async()
+
+ assert response.items == []
+ assert response.source == str(tmp_path)
+
+ async def test_list_presets_is_sorted_by_name(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ for name in ("zebra", "alpha", "middle"):
+ await service.save_preset_async(preset=_preset(name), expected_version=None)
+
+ response = await service.list_presets_async()
+
+ assert [item.preset.name for item in response.items] == ["alpha", "middle", "zebra"]
+
+ async def test_get_preset_returns_none_when_not_stored(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ assert await service.get_preset_async(name="missing") is None
+
+ async def test_saved_preset_round_trips_every_field(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ preset = _preset(
+ description="Nightly smoke",
+ techniques=["crescendo"],
+ dataset_names=["harmbench"],
+ max_dataset_size=25,
+ dataset_filters={"harm_categories": ["violence"]},
+ include_baseline=True,
+ scenario_params={"max_turns": 3},
+ )
+
+ await service.save_preset_async(preset=preset, expected_version=None)
+ read_back = await service.get_preset_async(name=preset.name)
+
+ assert read_back is not None
+ assert read_back.preset == preset
+
+ async def test_update_with_the_version_from_a_read_succeeds(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ created = await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ updated = await service.save_preset_async(
+ preset=_preset(description="changed"), expected_version=created.version
+ )
+
+ assert updated.preset.description == "changed"
+ assert updated.version != created.version
+
+ async def test_update_with_a_stale_version_conflicts(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ created = await service.save_preset_async(preset=_preset(), expected_version=None)
+ await service.save_preset_async(preset=_preset(description="first"), expected_version=created.version)
+
+ with pytest.raises(ScenarioPresetConflictError):
+ await service.save_preset_async(preset=_preset(description="second"), expected_version=created.version)
+
+ async def test_create_over_an_existing_preset_conflicts(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ with pytest.raises(ScenarioPresetConflictError):
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ async def test_delete_removes_the_stored_preset(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ await service.delete_preset_async(name="quick_scan")
+
+ assert await service.get_preset_async(name="quick_scan") is None
+
+ async def test_delete_reports_a_missing_preset_rather_than_succeeding_silently(
+ self, service: ScenarioPresetService
+ ) -> None:
+ with pytest.raises(ScenarioPresetNotFoundError):
+ await service.delete_preset_async(name="missing")
+
+
+class TestAdvisoryValidation:
+ """Unresolvable references are reported, never enforced."""
+
+ async def test_a_resolvable_preset_has_no_issues(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ saved = await service.save_preset_async(
+ preset=_preset(techniques=["crescendo"], scenario_params={"max_turns": 2}), expected_version=None
+ )
+
+ assert saved.issues == []
+
+ async def test_an_unregistered_scenario_is_saved_and_reported(self, service: ScenarioPresetService) -> None:
+ with patch("pyrit.backend.services.scenario_preset_service.get_scenario_service") as mock_factory:
+ mock_factory.return_value.get_scenario_async = AsyncMock(return_value=None)
+ saved = await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ read_back = await service.get_preset_async(name="quick_scan")
+
+ assert read_back is not None
+ assert [issue.field for issue in saved.issues] == ["scenario_name"]
+ assert "not registered" in saved.issues[0].message
+
+ async def test_unknown_techniques_are_reported_without_blocking_the_save(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ saved = await service.save_preset_async(
+ preset=_preset(techniques=["crescendo", "not_a_technique"]), expected_version=None
+ )
+
+ assert [issue.field for issue in saved.issues] == ["techniques"]
+ assert "not_a_technique" in saved.issues[0].message
+ assert await service.get_preset_async(name="quick_scan") is not None
+
+ async def test_an_aggregate_technique_is_not_reported_as_unknown(self, service: ScenarioPresetService) -> None:
+ scenario = _registered_scenario(aggregate_techniques=["all"])
+ with patch("pyrit.backend.services.scenario_preset_service.get_scenario_service") as mock_factory:
+ mock_factory.return_value.get_scenario_async = AsyncMock(return_value=scenario)
+ saved = await service.save_preset_async(preset=_preset(techniques=["all"]), expected_version=None)
+
+ assert saved.issues == []
+
+ async def test_a_converter_modifier_does_not_make_a_known_technique_look_unknown(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ with patch(
+ "pyrit.backend.services.scenario_preset_service.ConverterRegistry",
+ _converter_registry("translation_spanish"),
+ ):
+ saved = await service.save_preset_async(
+ preset=_preset(techniques=["crescendo:converter.translation_spanish"]), expected_version=None
+ )
+
+ assert saved.issues == []
+
+ async def test_a_converter_this_deployment_has_not_registered_is_reported(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ with patch("pyrit.backend.services.scenario_preset_service.ConverterRegistry", _converter_registry()):
+ saved = await service.save_preset_async(
+ preset=_preset(techniques=["crescendo:converter.translation_spanish"]), expected_version=None
+ )
+
+ assert [issue.field for issue in saved.issues] == ["techniques"]
+ assert "translation_spanish" in saved.issues[0].message
+
+ async def test_a_modifier_the_launch_path_cannot_parse_is_reported(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ with patch("pyrit.backend.services.scenario_preset_service.ConverterRegistry", _converter_registry()):
+ saved = await service.save_preset_async(
+ preset=_preset(techniques=["crescendo:scorer.refusal"]), expected_version=None
+ )
+
+ assert [issue.field for issue in saved.issues] == ["techniques"]
+ assert "scorer.refusal" in saved.issues[0].message
+
+ async def test_undeclared_scenario_parameters_are_reported(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ saved = await service.save_preset_async(
+ preset=_preset(scenario_params={"max_turns": 1, "mystery": 2}), expected_version=None
+ )
+
+ assert [issue.field for issue in saved.issues] == ["scenario_params"]
+ assert "mystery" in saved.issues[0].message
+
+ async def test_a_baseline_the_scenario_forbids_is_reported(self, service: ScenarioPresetService) -> None:
+ scenario = _registered_scenario(baseline_policy="forbidden")
+ with patch("pyrit.backend.services.scenario_preset_service.get_scenario_service") as mock_factory:
+ mock_factory.return_value.get_scenario_async = AsyncMock(return_value=scenario)
+ saved = await service.save_preset_async(preset=_preset(include_baseline=True), expected_version=None)
+
+ assert [issue.field for issue in saved.issues] == ["include_baseline"]
+
+ async def test_an_omitted_baseline_is_not_reported_when_the_scenario_forbids_one(
+ self, service: ScenarioPresetService
+ ) -> None:
+ scenario = _registered_scenario(baseline_policy="forbidden")
+ with patch("pyrit.backend.services.scenario_preset_service.get_scenario_service") as mock_factory:
+ mock_factory.return_value.get_scenario_async = AsyncMock(return_value=scenario)
+ saved = await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ assert saved.issues == []
+
+
+class TestRunRequestResolution:
+ """A preset plus launch fields becomes an ordinary run request."""
+
+ def test_unset_run_fields_fall_back_to_the_request_defaults(self) -> None:
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=_preset(), launch=ResolveScenarioPresetRequest(target_name="gpt4")
+ )
+
+ assert resolved.max_concurrency == 10
+ assert resolved.max_retries == 0
+
+ def test_explicit_run_fields_are_applied(self) -> None:
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=_preset(),
+ launch=ResolveScenarioPresetRequest(target_name="gpt4", max_concurrency=4, max_retries=2),
+ )
+
+ assert resolved.max_concurrency == 4
+ assert resolved.max_retries == 2
+
+ def test_unset_preset_fields_stay_unset_so_the_scenario_default_still_applies(self) -> None:
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=_preset(), launch=ResolveScenarioPresetRequest(target_name="gpt4")
+ )
+
+ assert resolved.techniques is None
+ assert resolved.dataset_names is None
+ assert resolved.max_dataset_size is None
+ assert resolved.dataset_filters is None
+ assert resolved.include_baseline is None
+ assert resolved.scenario_params is None
+
+ def test_a_baseline_explicitly_disabled_by_the_preset_is_preserved(self) -> None:
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=_preset(include_baseline=False), launch=ResolveScenarioPresetRequest(target_name="gpt4")
+ )
+
+ assert resolved.include_baseline is False
+
+ def test_every_preset_field_reaches_the_run_request(self) -> None:
+ preset = _preset(
+ techniques=["crescendo"],
+ dataset_names=["harmbench"],
+ max_dataset_size=25,
+ dataset_filters={"harm_categories": ["violence"]},
+ include_baseline=True,
+ scenario_params={"max_turns": 3},
+ )
+
+ resolved = ScenarioPresetService.resolve_run_request(
+ preset=preset, launch=ResolveScenarioPresetRequest(target_name="gpt4")
+ )
+
+ assert resolved.scenario_name == preset.scenario_name
+ assert resolved.techniques == preset.techniques
+ assert resolved.dataset_names == preset.dataset_names
+ assert resolved.max_dataset_size == preset.max_dataset_size
+ assert resolved.dataset_filters == preset.dataset_filters
+ assert resolved.include_baseline == preset.include_baseline
+ assert resolved.scenario_params == preset.scenario_params
+
+ def test_launch_fields_reach_the_run_request(self) -> None:
+ launch = ResolveScenarioPresetRequest(
+ target_name="gpt4",
+ adversarial_target_name="adversary",
+ initializers=["scorer"],
+ initializer_args={"scorer": {"threshold": 0.5}},
+ labels={"operator": "red"},
+ )
+
+ resolved = ScenarioPresetService.resolve_run_request(preset=_preset(), launch=launch)
+
+ assert resolved.target_name == "gpt4"
+ assert resolved.adversarial_target_name == "adversary"
+ assert resolved.initializers == ["scorer"]
+ assert resolved.initializer_args == {"scorer": {"threshold": 0.5}}
+ assert resolved.labels == {"operator": "red"}
+
+
+class TestRunSizeEstimates:
+ """Sizing a preset against its own configuration rather than the scenario default."""
+
+ async def test_every_listed_preset_carries_its_own_size(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ response = await service.list_presets_async()
+
+ assert response.items[0].run_size is not None
+ assert response.items[0].run_size.total_attack_count == 12
+
+ async def test_the_preset_configuration_is_what_gets_sized(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ preset = _preset(
+ techniques=["crescendo"],
+ dataset_names=["harmbench"],
+ max_dataset_size=25,
+ dataset_filters={"harm_categories": ["violence"]},
+ include_baseline=True,
+ scenario_params={"max_turns": 3},
+ )
+ await service.save_preset_async(preset=preset, expected_version=None)
+
+ await service.list_presets_async()
+
+ estimate_call = scenario_service.estimate_scenario_run_size_async.call_args
+ assert estimate_call.kwargs["scenario_name"] == SCENARIO_NAME
+ request = estimate_call.kwargs["request"]
+ assert request.techniques == ["crescendo"]
+ assert request.dataset_names == ["harmbench"]
+ assert request.max_dataset_size == 25
+ assert request.dataset_filters == {"harm_categories": ["violence"]}
+ assert request.include_baseline is True
+ assert request.scenario_params == {"max_turns": 3}
+
+ async def test_a_preset_that_sets_nothing_is_sized_without_overrides(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ await service.list_presets_async()
+
+ request = scenario_service.estimate_scenario_run_size_async.call_args.kwargs["request"]
+ assert request.techniques is None
+ assert request.dataset_names is None
+ assert request.target_name is None
+
+ async def test_skipping_estimates_does_no_sizing_work(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+
+ response = await service.list_presets_async(include_estimates=False)
+
+ assert response.items[0].run_size is None
+ scenario_service.estimate_scenario_run_size_async.assert_not_called()
+
+ async def test_one_preset_that_cannot_be_sized_does_not_hide_the_others(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ for name in ("alpha", "broken"):
+ await service.save_preset_async(preset=_preset(name), expected_version=None)
+ scenario_service.estimate_scenario_run_size_async = AsyncMock(
+ side_effect=[_estimate(7), ValueError("unknown technique 'nope'")]
+ )
+
+ response = await service.list_presets_async()
+
+ assert [item.preset.name for item in response.items] == ["alpha", "broken"]
+ assert response.items[0].run_size is not None
+ assert response.items[0].run_size.total_attack_count == 7
+ assert response.items[1].run_size is None
+
+ async def test_an_unknown_scenario_is_reported_as_an_issue_not_a_size(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+ scenario_service.estimate_scenario_run_size_async = AsyncMock(return_value=None)
+
+ response = await service.list_presets_async()
+
+ assert response.items[0].run_size is None
+
+ async def test_a_single_read_stays_cheap(
+ self, service: ScenarioPresetService, scenario_service: MagicMock
+ ) -> None:
+ await service.save_preset_async(preset=_preset(), expected_version=None)
+ scenario_service.estimate_scenario_run_size_async.reset_mock()
+
+ read_back = await service.get_preset_async(name="quick_scan")
+
+ assert read_back is not None
+ assert read_back.run_size is None
+ scenario_service.estimate_scenario_run_size_async.assert_not_called()
+
+
+class TestServiceConfiguration:
+ """Storage source selection."""
+
+ def test_the_service_is_a_singleton(self) -> None:
+ assert get_scenario_preset_service() is get_scenario_preset_service()
+
+ def test_configure_source_replaces_the_storage_location(self, tmp_path: Path) -> None:
+ instance = ScenarioPresetService()
+
+ instance.configure_source(str(tmp_path))
+
+ assert instance._get_storage().display_source == str(tmp_path)
+
+ def test_an_unconfigured_service_falls_back_to_the_default_directory(self) -> None:
+ instance = ScenarioPresetService()
+
+ assert instance._get_storage().display_source.endswith("scenario_presets")
diff --git a/tests/unit/models/test_scenario_preset.py b/tests/unit/models/test_scenario_preset.py
new file mode 100644
index 0000000000..126df24ec7
--- /dev/null
+++ b/tests/unit/models/test_scenario_preset.py
@@ -0,0 +1,111 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Tests for the scenario preset model."""
+
+import pytest
+
+from pyrit.models.catalog.scenario_preset import ScenarioPreset
+from pyrit.models.request_limits import MAX_IDENTIFIER_LENGTH, MAX_ITEMS
+
+
+def test_init_defaults_every_optional_field_to_none() -> None:
+ """Test that an unset preset field is None rather than a concrete default."""
+ preset = ScenarioPreset(name="nightly", scenario_name="foundry.red_team_agent")
+
+ assert preset.techniques is None
+ assert preset.dataset_names is None
+ assert preset.max_dataset_size is None
+ assert preset.dataset_filters is None
+ assert preset.include_baseline is None
+ assert preset.scenario_params is None
+ assert preset.description is None
+
+
+def test_preset_carries_no_storage_version() -> None:
+ """Test that storage metadata stays out of the model it describes."""
+ assert "version" not in ScenarioPreset.model_fields
+
+
+def test_init_rejects_unknown_field() -> None:
+ """Test that a misspelled key fails loudly instead of silently falling back to the default."""
+ with pytest.raises(ValueError, match="techinques"):
+ ScenarioPreset(
+ name="nightly",
+ scenario_name="foundry.red_team_agent",
+ techinques=["crescendo"], # type: ignore[call-arg]
+ )
+
+
+@pytest.mark.parametrize("name", ["Nightly", "nightly-scan", "1nightly", "", "a" * 65])
+def test_init_rejects_invalid_registry_names(name: str) -> None:
+ """Test that a preset name must be a legal registry name."""
+ with pytest.raises(ValueError, match="Invalid registry name"):
+ ScenarioPreset(name=name, scenario_name="foundry.red_team_agent")
+
+
+def test_init_rejects_unknown_dataset_filter() -> None:
+ """Test that dataset filters are validated against the shared allow-list."""
+ with pytest.raises(ValueError, match="Unknown dataset filter"):
+ ScenarioPreset(
+ name="nightly",
+ scenario_name="foundry.red_team_agent",
+ dataset_filters={"not_a_field": ["x"]},
+ )
+
+
+def test_init_accepts_known_dataset_filters() -> None:
+ """Test that allow-listed dataset filters are preserved."""
+ preset = ScenarioPreset(
+ name="nightly",
+ scenario_name="foundry.red_team_agent",
+ dataset_filters={"harm_categories": ["violence"], "data_types": ["text"]},
+ )
+
+ assert preset.dataset_filters == {"harm_categories": ["violence"], "data_types": ["text"]}
+
+
+def test_init_rejects_zero_max_dataset_size() -> None:
+ """Test that a dataset cap must select at least one item."""
+ with pytest.raises(ValueError):
+ ScenarioPreset(name="nightly", scenario_name="foundry.red_team_agent", max_dataset_size=0)
+
+
+def test_init_rejects_empty_scenario_name() -> None:
+ """Test that a preset must name the scenario it configures."""
+ with pytest.raises(ValueError):
+ ScenarioPreset(name="nightly", scenario_name="")
+
+
+def test_include_baseline_distinguishes_unset_from_false() -> None:
+ """Test the tri-state contract that an unset override is not a disabled override."""
+ unset = ScenarioPreset(name="nightly", scenario_name="foundry.red_team_agent")
+ disabled = ScenarioPreset(name="nightly", scenario_name="foundry.red_team_agent", include_baseline=False)
+
+ assert unset.include_baseline is None
+ assert disabled.include_baseline is False
+ assert unset.include_baseline != disabled.include_baseline
+
+
+def test_init_rejects_a_preset_too_large_to_launch() -> None:
+ """Test that the stored preset enforces the same request limits the launch request does."""
+ with pytest.raises(ValueError):
+ ScenarioPreset(
+ name="nightly",
+ scenario_name="foundry.red_team_agent",
+ techniques=[f"technique_{index}" for index in range(MAX_ITEMS + 1)],
+ )
+
+ with pytest.raises(ValueError):
+ ScenarioPreset(
+ name="nightly",
+ scenario_name="foundry.red_team_agent",
+ dataset_names=["x" * (MAX_IDENTIFIER_LENGTH + 1)],
+ )
+
+ with pytest.raises(ValueError):
+ ScenarioPreset(
+ name="nightly",
+ scenario_name="foundry.red_team_agent",
+ scenario_params={f"param_{index}": index for index in range(MAX_ITEMS + 1)},
+ )
\ No newline at end of file
diff --git a/tests/unit/registry/test_custom_initializer_storage.py b/tests/unit/registry/test_custom_initializer_storage.py
index f739375ff0..bffed96216 100644
--- a/tests/unit/registry/test_custom_initializer_storage.py
+++ b/tests/unit/registry/test_custom_initializer_storage.py
@@ -135,11 +135,63 @@ def test_local_storage_reads_latest_script_content(tmp_path: Path) -> None:
assert storage.list_scripts() == {"example": "VALUE = 2\n"}
-def test_direct_python_blob_rejects_name_outside_prefix() -> None:
- """Test that blobs outside the configured virtual directory are ignored."""
- assert not CustomInitializerStorage._is_direct_python_blob(
- blob_name="other/example.py", prefix="custom-initializers/"
+def test_listing_skips_scripts_it_cannot_address(tmp_path: Path) -> None:
+ """Test that every listed name can be passed back into the single-document operations."""
+ for file_name in ["good_one.py", "My-Script.py", "__init__.py", "test-helper.py"]:
+ (tmp_path / file_name).write_text("VALUE = 1\n", encoding="utf-8")
+ storage = CustomInitializerStorage(source=str(tmp_path))
+
+ listed = storage.list_scripts()
+
+ assert sorted(listed) == ["good_one"]
+ for name in listed:
+ storage.get_script_source(name)
+
+
+def test_listing_skips_a_script_that_is_not_text(tmp_path: Path) -> None:
+ """Test that one undecodable file does not hide every other stored initializer."""
+ (tmp_path / "good_one.py").write_bytes(b"VALUE = 1\n")
+ (tmp_path / "broken.py").write_bytes(b"\xff\xfe VALUE = 1")
+ storage = CustomInitializerStorage(source=str(tmp_path))
+
+ assert storage.list_scripts() == {"good_one": "VALUE = 1\n"}
+
+
+def test_stored_crlf_script_is_normalized_before_it_is_handed_back(tmp_path: Path) -> None:
+ """Test that stored CRLF cannot round-trip into the CRCRLF a text-mode rewrite would produce."""
+ (tmp_path / "windows_authored.py").write_bytes(b"VALUE = 1\r\nOTHER = 2\r\n")
+ storage = CustomInitializerStorage(source=str(tmp_path))
+
+ assert storage.list_scripts() == {"windows_authored": "VALUE = 1\nOTHER = 2\n"}
+
+
+def test_blob_listing_skips_a_script_that_is_not_text() -> None:
+ """Test that one undecodable blob does not hide every other stored initializer."""
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.list_blobs.return_value = [SimpleNamespace(name="broken.py"), SimpleNamespace(name="good_one.py")]
+ client.download_blob.side_effect = lambda blob_name: SimpleNamespace(
+ readall=lambda: b"\xff\xfe" if blob_name == "broken.py" else b"VALUE = 1\n"
)
+ storage = CustomInitializerStorage(source="https://account.blob.core.windows.net/initializers?sig=secret")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ assert storage.list_scripts() == {"good_one": "VALUE = 1\n"}
+
+
+def test_direct_python_blob_rejects_name_outside_prefix(tmp_path: Path) -> None:
+ """Test that blobs outside the configured virtual directory are ignored."""
+ storage = CustomInitializerStorage(source=str(tmp_path))
+
+ assert not storage._is_direct_document_blob(blob_name="other/example.py", prefix="custom-initializers/")
+
+
+def test_direct_document_blob_rejects_other_extensions(tmp_path: Path) -> None:
+ """Test that only blobs with the configured extension are listed."""
+ storage = CustomInitializerStorage(source=str(tmp_path))
+
+ assert storage._is_direct_document_blob(blob_name="example.py", prefix=None)
+ assert not storage._is_direct_document_blob(blob_name="example.json", prefix=None)
def test_local_storage_cannot_open_blob_client(tmp_path: Path) -> None:
diff --git a/tests/unit/registry/test_initializer_registry.py b/tests/unit/registry/test_initializer_registry.py
index a4991c377a..9aae284e3e 100644
--- a/tests/unit/registry/test_initializer_registry.py
+++ b/tests/unit/registry/test_initializer_registry.py
@@ -265,6 +265,21 @@ def test_list_stored_initializer_sources_includes_display_paths(lazy_registry: I
)
+def test_list_stored_initializer_sources_tolerates_unaddressable_files(
+ lazy_registry: InitializerRegistry, tmp_path: Path
+) -> None:
+ """Test that a stray file in the source directory does not fail the whole listing."""
+ (tmp_path / "good_one.py").write_text(_VALID_SCRIPT, encoding="utf-8")
+ (tmp_path / "__init__.py").write_text(_VALID_SCRIPT, encoding="utf-8")
+ (tmp_path / "My-Script.py").write_text(_VALID_SCRIPT, encoding="utf-8")
+ lazy_registry.configure_custom_scripts_source(str(tmp_path))
+
+ source, items = lazy_registry.list_stored_initializer_sources()
+
+ assert source == str(tmp_path)
+ assert [name for name, _, _ in items] == ["good_one"]
+
+
def test_unregister_and_cleanup_rejects_builtin(lazy_registry):
"""Test that unregister_and_cleanup raises ValueError for built-in initializers."""
diff --git a/tests/unit/registry/test_scenario_preset_storage.py b/tests/unit/registry/test_scenario_preset_storage.py
new file mode 100644
index 0000000000..c87c7fcbbb
--- /dev/null
+++ b/tests/unit/registry/test_scenario_preset_storage.py
@@ -0,0 +1,690 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Tests for scenario preset storage."""
+
+import hashlib
+import json
+import os
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from pyrit.models.catalog.scenario_preset import ScenarioPreset
+from pyrit.registry.file_document_storage import _release_exclusive_lock, _try_acquire_exclusive_lock
+from pyrit.registry.scenario_preset_storage import ScenarioPresetConflictError, ScenarioPresetStorage
+
+
+def _make_preset(**overrides: object) -> ScenarioPreset:
+ """
+ Build a preset with test defaults.
+
+ Returns:
+ ScenarioPreset: The constructed preset.
+ """
+ fields: dict[str, object] = {"name": "nightly", "scenario_name": "foundry.red_team_agent"}
+ fields.update(overrides)
+ return ScenarioPreset(**fields) # type: ignore[arg-type]
+
+
+def test_local_storage_round_trips_a_preset(tmp_path: Path) -> None:
+ """Test that a saved preset loads back with its fields intact."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ preset = _make_preset(
+ techniques=["crescendo"],
+ dataset_names=["harmbench"],
+ max_dataset_size=25,
+ dataset_filters={"harm_categories": ["violence"]},
+ include_baseline=True,
+ scenario_params={"max_turns": 3},
+ description="Nightly smoke suite",
+ )
+
+ storage.save_preset(preset=preset, expected_version=None)
+ loaded = storage.load_preset("nightly")
+
+ assert loaded is not None
+ assert loaded.preset.techniques == ["crescendo"]
+ assert loaded.preset.dataset_names == ["harmbench"]
+ assert loaded.preset.max_dataset_size == 25
+ assert loaded.preset.dataset_filters == {"harm_categories": ["violence"]}
+ assert loaded.preset.include_baseline is True
+ assert loaded.preset.scenario_params == {"max_turns": 3}
+ assert loaded.preset.description == "Nightly smoke suite"
+
+
+def test_unset_fields_round_trip_as_none_not_false(tmp_path: Path) -> None:
+ """Test the tri-state regression: an unset override must not deserialize as a disabled one."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+ loaded = storage.load_preset("nightly")
+
+ assert loaded is not None
+ assert loaded.preset.include_baseline is None
+ assert loaded.preset.max_dataset_size is None
+ assert loaded.preset.techniques is None
+
+ stored = json.loads((tmp_path / "nightly.json").read_text(encoding="utf-8"))
+ assert "include_baseline" not in stored
+ assert "max_dataset_size" not in stored
+
+
+def test_explicit_false_round_trips_as_false(tmp_path: Path) -> None:
+ """Test that an explicitly disabled override survives storage."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ storage.save_preset(preset=_make_preset(include_baseline=False), expected_version=None)
+ loaded = storage.load_preset("nightly")
+
+ assert loaded is not None
+ assert loaded.preset.include_baseline is False
+
+
+def test_version_is_not_stored_inside_the_document(tmp_path: Path) -> None:
+ """Test that the conflict token lives beside the document, never inside the file it guards."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ saved = storage.save_preset(preset=_make_preset(), expected_version=None)
+
+ assert saved.version
+ assert "version" not in json.loads((tmp_path / "nightly.json").read_text(encoding="utf-8"))
+
+
+def test_each_save_produces_a_new_version(tmp_path: Path) -> None:
+ """Test that an accepted save supersedes the token the caller passed in."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ first = storage.save_preset(preset=_make_preset(), expected_version=None)
+
+ second = storage.save_preset(preset=_make_preset(description="changed"), expected_version=first.version)
+
+ assert second.version != first.version
+ reloaded = storage.load_preset("nightly")
+ assert reloaded is not None
+ assert reloaded.version == second.version
+
+
+def test_identical_content_keeps_the_same_version(tmp_path: Path) -> None:
+ """Test that the token describes stored content, so a no-op save does not invalidate other readers."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ first = storage.save_preset(preset=_make_preset(), expected_version=None)
+
+ second = storage.save_preset(preset=_make_preset(), expected_version=first.version)
+
+ assert second.version == first.version
+
+
+def test_stale_version_save_is_rejected(tmp_path: Path) -> None:
+ """Test that a save based on a superseded version loses the concurrency check."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ created = storage.save_preset(preset=_make_preset(), expected_version=None)
+ winner = storage.save_preset(preset=_make_preset(description="first writer"), expected_version=created.version)
+
+ with pytest.raises(ScenarioPresetConflictError) as error:
+ storage.save_preset(preset=_make_preset(description="second writer"), expected_version=created.version)
+
+ assert error.value.expected_version == created.version
+ assert error.value.actual_version == winner.version
+
+
+def test_out_of_band_edit_invalidates_the_version(tmp_path: Path) -> None:
+ """Test that a file edited outside this class is detected, not silently overwritten."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ created = storage.save_preset(preset=_make_preset(description="original"), expected_version=None)
+ (tmp_path / "nightly.json").write_text(
+ json.dumps({"scenario_name": "foundry.red_team_agent", "description": "edited by hand"}),
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ScenarioPresetConflictError):
+ storage.save_preset(preset=_make_preset(description="stale client"), expected_version=created.version)
+
+ loaded = storage.load_preset("nightly")
+ assert loaded is not None
+ assert loaded.preset.description == "edited by hand"
+
+
+def test_stale_save_does_not_overwrite_the_winner(tmp_path: Path) -> None:
+ """Test that a rejected save leaves stored content untouched."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ storage.save_preset(preset=_make_preset(description="original"), expected_version=None)
+
+ with pytest.raises(ScenarioPresetConflictError):
+ storage.save_preset(preset=_make_preset(description="clobber"), expected_version="not_the_stored_version")
+
+ loaded = storage.load_preset("nightly")
+ assert loaded is not None
+ assert loaded.preset.description == "original"
+
+
+def test_create_over_existing_name_is_rejected(tmp_path: Path) -> None:
+ """Test that creating a preset that already exists is a conflict, not an overwrite."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ created = storage.save_preset(preset=_make_preset(), expected_version=None)
+
+ with pytest.raises(ScenarioPresetConflictError) as error:
+ storage.save_preset(preset=_make_preset(description="second"), expected_version=None)
+
+ assert error.value.expected_version is None
+ assert error.value.actual_version == created.version
+
+
+def test_create_over_malformed_file_is_rejected(tmp_path: Path) -> None:
+ """Test that an unreadable file still blocks a create, so hand-written content is not discarded."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ (tmp_path / "nightly.json").write_text("{not json", encoding="utf-8")
+
+ with pytest.raises(ScenarioPresetConflictError):
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+
+ assert (tmp_path / "nightly.json").read_text(encoding="utf-8") == "{not json"
+
+
+def test_update_of_missing_preset_is_rejected(tmp_path: Path) -> None:
+ """Test that updating a preset deleted by someone else is a conflict."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ with pytest.raises(ScenarioPresetConflictError) as error:
+ storage.save_preset(preset=_make_preset(), expected_version="some_version")
+
+ assert error.value.actual_version is None
+
+
+def test_default_source_is_under_the_configuration_directory(tmp_path: Path) -> None:
+ """Test that omitting the source stores presets under the PyRIT configuration directory."""
+ with patch("pyrit.common.path.CONFIGURATION_DIRECTORY_PATH", tmp_path):
+ storage = ScenarioPresetStorage()
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+
+ assert (tmp_path / "scenario_presets" / "nightly.json").is_file()
+
+
+def test_presets_written_by_another_process_are_visible(tmp_path: Path) -> None:
+ """Test that reads go through to the source, so a shared directory is never served stale."""
+ reader = ScenarioPresetStorage(source=str(tmp_path))
+ assert reader.load_preset("nightly") is None
+
+ ScenarioPresetStorage(source=str(tmp_path)).save_preset(preset=_make_preset(), expected_version=None)
+
+ assert reader.load_preset("nightly") is not None
+ assert list(reader.list_presets()) == ["nightly"]
+
+
+def test_presets_deleted_by_another_process_disappear(tmp_path: Path) -> None:
+ """Test that a preset removed outside this instance stops resolving."""
+ reader = ScenarioPresetStorage(source=str(tmp_path))
+ reader.save_preset(preset=_make_preset(), expected_version=None)
+
+ ScenarioPresetStorage(source=str(tmp_path)).delete_preset("nightly")
+
+ assert reader.load_preset("nightly") is None
+
+
+def test_load_missing_preset_returns_none(tmp_path: Path) -> None:
+ """Test that an absent preset loads as None rather than raising."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ assert storage.load_preset("absent") is None
+
+
+def test_list_and_delete_presets(tmp_path: Path) -> None:
+ """Test the storage lifecycle for multiple presets."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ storage.save_preset(preset=_make_preset(name="nightly"), expected_version=None)
+ storage.save_preset(preset=_make_preset(name="weekly"), expected_version=None)
+
+ assert sorted(storage.list_presets()) == ["nightly", "weekly"]
+ storage.delete_preset("nightly")
+ assert sorted(storage.list_presets()) == ["weekly"]
+
+
+def test_delete_missing_preset_is_silent(tmp_path: Path) -> None:
+ """Test that deleting an absent preset is not an error."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ storage.delete_preset("absent")
+
+
+@pytest.mark.parametrize("name", ["../victim", "..\\victim", "nested/victim", "Nightly", "night-ly", ""])
+def test_document_operations_reject_illegal_names(tmp_path: Path, name: str) -> None:
+ """Test that a name which would escape the configured source is refused on every path."""
+ source = tmp_path / "presets"
+ source.mkdir()
+ victim = tmp_path / "victim.json"
+ victim.write_text("do not touch", encoding="utf-8")
+ storage = ScenarioPresetStorage(source=str(source))
+
+ with pytest.raises(ValueError, match="Invalid registry name"):
+ storage.load_preset(name)
+ with pytest.raises(ValueError, match="Invalid registry name"):
+ storage.delete_preset(name)
+ with pytest.raises(ValueError, match="Invalid registry name"):
+ storage.get_preset_source(name)
+
+ assert victim.read_text(encoding="utf-8") == "do not touch"
+
+
+def test_malformed_preset_is_skipped_not_fatal(tmp_path: Path) -> None:
+ """Test that one unparseable file does not prevent the rest of the library from loading."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ storage.save_preset(preset=_make_preset(name="valid"), expected_version=None)
+ (tmp_path / "broken.json").write_text("{not json", encoding="utf-8")
+ (tmp_path / "wrong_shape.json").write_text('["a list"]', encoding="utf-8")
+
+ presets = storage.list_presets()
+
+ assert sorted(presets) == ["valid"]
+ assert storage.load_preset("broken") is None
+
+
+def test_document_name_overrides_payload_name(tmp_path: Path) -> None:
+ """Test that the file name is authoritative, so a load and its later save agree on the key."""
+ (tmp_path / "actual_key.json").write_text(
+ json.dumps({"name": "different", "scenario_name": "foundry.red_team_agent"}),
+ encoding="utf-8",
+ )
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ loaded = storage.load_preset("actual_key")
+
+ assert loaded is not None
+ assert loaded.preset.name == "actual_key"
+ assert sorted(storage.list_presets()) == ["actual_key"]
+
+
+def test_local_storage_returns_preset_path(tmp_path: Path) -> None:
+ """Test resolving the displayed path for a local preset."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ assert storage.get_preset_source("nightly") == str(tmp_path / "nightly.json")
+ assert storage.display_source == str(tmp_path)
+
+
+@pytest.mark.parametrize(
+ "source",
+ [
+ "https://account.blob.attacker.example/presets",
+ "https://user@account.blob.core.windows.net/presets",
+ "https://blob.core.windows.net/presets",
+ ],
+)
+def test_blob_storage_rejects_untrusted_authorities(source: str) -> None:
+ """Test rejecting Blob lookalikes before Azure credentials are acquired."""
+ with pytest.raises(ValueError, match="local directory or Azure Blob container URI"):
+ ScenarioPresetStorage(source=source)
+
+
+def test_blob_storage_round_trips_and_ignores_other_extensions() -> None:
+ """Test container storage operations and that only JSON documents are listed."""
+ document = json.dumps({"name": "nightly", "scenario_name": "foundry.red_team_agent"})
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.list_blobs.return_value = [
+ SimpleNamespace(name="nightly.json"),
+ SimpleNamespace(name="notes.txt"),
+ SimpleNamespace(name="archive/ignored.json"),
+ ]
+ client.download_blob.return_value.readall.return_value = document.encode("utf-8")
+ source = "https://account.blob.core.windows.net/presets?sp=rwd&sig=secret"
+ storage = ScenarioPresetStorage(source=source)
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ presets = storage.list_presets()
+ saved = storage.save_preset(
+ preset=_make_preset(description="updated"), expected_version=presets["nightly"].version
+ )
+
+ assert sorted(presets) == ["nightly"]
+ assert saved.version != presets["nightly"].version
+ assert storage.display_source == "https://account.blob.core.windows.net/presets"
+ assert client.upload_blob.call_args.kwargs["name"] == "nightly.json"
+
+
+def test_blob_storage_reads_missing_document_as_none() -> None:
+ """Test that a missing blob loads as None instead of propagating an Azure error."""
+ from azure.core.exceptions import ResourceNotFoundError
+
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.download_blob.side_effect = ResourceNotFoundError("missing")
+ storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ assert storage.load_preset("absent") is None
+
+
+def test_name_is_not_written_into_the_document(tmp_path: Path) -> None:
+ """Test that the storage key appears only in the file name, so no hand-edit can contradict it."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+
+ assert "name" not in json.loads((tmp_path / "nightly.json").read_text(encoding="utf-8"))
+ loaded = storage.load_preset("nightly")
+ assert loaded is not None
+ assert loaded.preset.name == "nightly"
+
+
+def test_misspelled_key_is_skipped_not_silently_dropped(tmp_path: Path) -> None:
+ """Test that a typo in a hand-edited file is refused rather than quietly using the scenario default."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ (tmp_path / "nightly.json").write_text(
+ json.dumps({"scenario_name": "foundry.red_team_agent", "techinques": ["crescendo"]}),
+ encoding="utf-8",
+ )
+
+ assert storage.load_preset("nightly") is None
+ assert storage.list_presets() == {}
+
+
+def test_malformed_document_can_be_overwritten_through_its_version(tmp_path: Path) -> None:
+ """Test that a file which cannot be parsed still has a recovery path rather than burning the name."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ (tmp_path / "nightly.json").write_text("{not json", encoding="utf-8")
+
+ version = storage.get_preset_version("nightly")
+ assert version is not None
+
+ saved = storage.save_preset(preset=_make_preset(), expected_version=version)
+
+ assert saved.preset.name == "nightly"
+ assert storage.load_preset("nightly") is not None
+
+
+def test_get_preset_version_returns_none_for_absent_document(tmp_path: Path) -> None:
+ """Test that an absent document reports no version, so a create still reads as a create."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ assert storage.get_preset_version("nightly") is None
+
+
+def test_listing_skips_documents_it_cannot_address(tmp_path: Path) -> None:
+ """Test that a stray file whose stem is not a legal name does not fail the whole listing."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+ (tmp_path / "My-Preset.json").write_text(json.dumps({"scenario_name": "foundry.red_team_agent"}), encoding="utf-8")
+
+ assert sorted(storage.list_presets()) == ["nightly"]
+
+
+def test_document_that_is_not_text_does_not_hide_valid_presets(tmp_path: Path) -> None:
+ """Test that a file which is not UTF-8 is skipped rather than failing the whole listing."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+ (tmp_path / "broken.json").write_bytes(b"\xff\xfe not utf-8")
+
+ assert sorted(storage.list_presets()) == ["nightly"]
+ assert storage.load_preset("broken") is None
+
+
+def test_unreadable_document_with_an_ignorable_name_is_never_read(tmp_path: Path) -> None:
+ """Test that a name the storage would refuse is skipped before its bytes are ever touched."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+ (tmp_path / "My-Preset.json").write_bytes(b"\xff")
+
+ assert sorted(storage.list_presets()) == ["nightly"]
+
+
+def test_version_is_readable_for_a_document_that_is_not_text(tmp_path: Path) -> None:
+ """Test that the recovery path reaches a file too broken to decode, not just too broken to parse."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ (tmp_path / "nightly.json").write_bytes(b"\xff\xfe not utf-8")
+
+ version = storage.get_preset_version("nightly")
+ assert version is not None
+
+ storage.save_preset(preset=_make_preset(), expected_version=version)
+
+ loaded = storage.load_preset("nightly")
+ assert loaded is not None
+
+
+def test_version_matches_the_bytes_on_disk(tmp_path: Path) -> None:
+ """Test that the token describes stored bytes, so a later read cannot disagree with the save."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+
+ saved = storage.save_preset(preset=_make_preset(), expected_version=None)
+
+ assert saved.version == hashlib.sha256((tmp_path / "nightly.json").read_bytes()).hexdigest()
+ assert storage.get_preset_version("nightly") == saved.version
+
+
+def test_failed_write_preserves_the_previous_preset(tmp_path: Path) -> None:
+ """Test that a write that dies partway leaves the stored preset readable instead of empty."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ first = storage.save_preset(preset=_make_preset(description="first"), expected_version=None)
+
+ def fail(source: object, target: object) -> None:
+ raise OSError(28, "No space left on device")
+
+ with patch("os.replace", fail):
+ with pytest.raises(OSError):
+ storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
+
+ loaded = storage.load_preset("nightly")
+ assert loaded is not None
+ assert loaded.preset.description == "first"
+ assert loaded.version == first.version
+ assert list(tmp_path.glob("*.tmp")) == []
+
+
+def test_a_reader_sees_the_previous_document_until_the_write_completes(tmp_path: Path) -> None:
+ """Test that an update never truncates the destination, so a reader sees the old or new document."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ first = storage.save_preset(preset=_make_preset(description="first"), expected_version=None)
+ observed: list[str] = []
+ listed: list[list[str]] = []
+ real_replace = os.replace
+
+ def observe_then_replace(source: object, target: object) -> None:
+ observed.append((tmp_path / "nightly.json").read_text(encoding="utf-8"))
+ listed.append(sorted(path.name for path in tmp_path.glob("*.json")))
+ real_replace(source, target) # type: ignore[arg-type]
+
+ with patch("os.replace", observe_then_replace):
+ storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
+
+ assert json.loads(observed[0])["description"] == "first"
+ assert listed == [["nightly.json"]]
+ loaded = storage.load_preset("nightly")
+ assert loaded is not None
+ assert loaded.preset.description == "second"
+
+
+def test_blob_listing_skips_a_document_that_is_not_text() -> None:
+ """Test that one undecodable blob does not hide every other stored preset."""
+ document = json.dumps({"scenario_name": "foundry.red_team_agent"}).encode("utf-8")
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.list_blobs.return_value = [SimpleNamespace(name="broken.json"), SimpleNamespace(name="nightly.json")]
+ client.download_blob.side_effect = lambda blob_name: SimpleNamespace(
+ readall=lambda: b"\xff\xfe" if blob_name == "broken.json" else document
+ )
+ storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ presets = storage.list_presets()
+
+ assert sorted(presets) == ["nightly"]
+
+
+def test_blob_listing_does_not_download_a_name_it_would_refuse() -> None:
+ """Test that an ignorable blob name is filtered before the download that could fail on it."""
+ document = json.dumps({"scenario_name": "foundry.red_team_agent"}).encode("utf-8")
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.list_blobs.return_value = [SimpleNamespace(name="My-Preset.json"), SimpleNamespace(name="nightly.json")]
+ client.download_blob.return_value.readall.return_value = document
+ storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ presets = storage.list_presets()
+
+ assert sorted(presets) == ["nightly"]
+ assert [call.args[0] for call in client.download_blob.call_args_list] == ["nightly.json"]
+
+
+def test_a_second_writer_cannot_enter_while_a_save_is_in_flight(tmp_path: Path) -> None:
+ """Test that the version check and the write cannot be separated by a competing writer."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ first = storage.save_preset(preset=_make_preset(description="first"), expected_version=None)
+ competitor = ScenarioPresetStorage(source=str(tmp_path))
+ refusals: list[str] = []
+ real_replace_file = ScenarioPresetStorage._replace_file
+
+ def replace_once_a_competitor_has_tried(*, path: Path, content: bytes) -> None:
+ with pytest.raises(TimeoutError) as error:
+ competitor.save_preset(preset=_make_preset(description="racer"), expected_version=first.version)
+ refusals.append(str(error.value))
+ real_replace_file(path=path, content=content)
+
+ with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
+ with patch.object(ScenarioPresetStorage, "_replace_file", staticmethod(replace_once_a_competitor_has_tried)):
+ storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
+
+ assert len(refusals) == 1
+ loaded = storage.load_preset("nightly")
+ assert loaded is not None
+ assert loaded.preset.description == "second"
+
+
+def test_a_completed_save_releases_its_lock(tmp_path: Path) -> None:
+ """Test that a save leaves nothing behind that would block the next writer."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ first = storage.save_preset(preset=_make_preset(description="first"), expected_version=None)
+
+ with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
+ saved = storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
+
+ assert saved.preset.description == "second"
+
+
+def test_a_lock_held_by_a_live_writer_is_respected(tmp_path: Path) -> None:
+ """Test that a writer still holding the lock is waited for rather than overridden."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ held_lock = tmp_path / ".nightly.json.lock"
+ descriptor = os.open(held_lock, os.O_CREAT | os.O_RDWR)
+ assert _try_acquire_exclusive_lock(descriptor)
+
+ try:
+ with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
+ with pytest.raises(TimeoutError, match="nightly.json.lock"):
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+ finally:
+ _release_exclusive_lock(descriptor)
+ os.close(descriptor)
+
+ assert storage.load_preset("nightly") is None
+
+
+def test_a_lock_left_by_a_dead_writer_is_reclaimed(tmp_path: Path) -> None:
+ """Test that a writer that died holding the lock does not make a preset permanently unwritable."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ first = storage.save_preset(preset=_make_preset(description="first"), expected_version=None)
+ # A writer that died leaves the file behind but not the lock, which the kernel released.
+ (tmp_path / ".nightly.json.lock").write_text("4242", encoding="utf-8")
+
+ with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
+ saved = storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
+
+ assert saved.preset.description == "second"
+
+
+def test_lock_files_are_not_listed_as_presets(tmp_path: Path) -> None:
+ """Test that the lock taken during a write can never be read back as a stored preset."""
+ storage = ScenarioPresetStorage(source=str(tmp_path))
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+ (tmp_path / ".nightly.json.lock").write_text("4242", encoding="utf-8")
+
+ assert sorted(storage.list_presets()) == ["nightly"]
+
+
+def test_blob_update_is_conditional_on_the_version_that_was_read() -> None:
+ """Test that a blob update asks the service to reject a write over content that moved."""
+ from azure.core import MatchConditions
+
+ document = json.dumps({"scenario_name": "foundry.red_team_agent"}).encode("utf-8")
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.download_blob.return_value.readall.return_value = document
+ client.download_blob.return_value.properties.etag = '"0x8DCAFE"'
+ storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ storage.save_preset(
+ preset=_make_preset(description="updated"),
+ expected_version=hashlib.sha256(document).hexdigest(),
+ )
+
+ assert client.upload_blob.call_args.kwargs["etag"] == '"0x8DCAFE"'
+ assert client.upload_blob.call_args.kwargs["match_condition"] is MatchConditions.IfNotModified
+
+
+def test_blob_create_refuses_to_overwrite_a_document_another_writer_just_created() -> None:
+ """Test that a container create cannot clobber a preset that appeared after the check."""
+ from azure.core.exceptions import ResourceExistsError
+
+ document = json.dumps({"scenario_name": "foundry.red_team_agent"}).encode("utf-8")
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.upload_blob.side_effect = ResourceExistsError("exists")
+ client.download_blob.return_value.readall.return_value = document
+ storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(ScenarioPresetConflictError, match="it already exists") as error:
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+
+ assert client.upload_blob.call_args.kwargs["overwrite"] is False
+ assert error.value.actual_version == hashlib.sha256(document).hexdigest()
+
+
+def test_blob_update_rejected_by_the_service_is_reported_as_a_conflict() -> None:
+ """Test that losing the precondition race surfaces as a conflict, not a raw Azure error."""
+ from azure.core.exceptions import ResourceModifiedError
+
+ document = json.dumps({"scenario_name": "foundry.red_team_agent"}).encode("utf-8")
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.download_blob.return_value.readall.return_value = document
+ client.upload_blob.side_effect = ResourceModifiedError("changed")
+ storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(ScenarioPresetConflictError, match="changed by someone else"):
+ storage.save_preset(preset=_make_preset(), expected_version=hashlib.sha256(document).hexdigest())
+
+
+def test_blob_update_of_changed_content_never_reaches_upload() -> None:
+ """Test that a stale container update is refused before any write is attempted."""
+ document = json.dumps({"scenario_name": "foundry.red_team_agent"}).encode("utf-8")
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.download_blob.return_value.readall.return_value = document
+ storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(ScenarioPresetConflictError, match="changed by someone else"):
+ storage.save_preset(preset=_make_preset(), expected_version="stale_version")
+
+ client.upload_blob.assert_not_called()
+
+
+def test_blob_update_of_a_deleted_document_is_a_conflict() -> None:
+ """Test that updating a blob someone else removed reports the deletion instead of recreating it."""
+ from azure.core.exceptions import ResourceNotFoundError
+
+ client = MagicMock()
+ client.__enter__.return_value = client
+ client.download_blob.side_effect = ResourceNotFoundError("missing")
+ storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(ScenarioPresetConflictError, match="no longer exists") as error:
+ storage.save_preset(preset=_make_preset(), expected_version="some_version")
+
+ assert error.value.actual_version is None
+ client.upload_blob.assert_not_called()