diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ae9a572dee..ec49e60dcc 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -7,7 +7,7 @@ import MainLayout from './components/Layout/MainLayout'
import ChatWindow from './components/Chat/ChatWindow'
import AttackNotFound from './components/Chat/AttackNotFound'
import Home from './components/Home/Home'
-import TargetConfig from './components/Config/TargetConfig'
+import ConfigPage from './components/Config/ConfigPage'
import AttackHistory from './components/History/AttackHistory'
import FeedbackDialog from './components/Feedback/FeedbackDialog'
import type { HistoryFilters } from './components/History/historyFilters'
@@ -386,7 +386,7 @@ function App() {
diff --git a/frontend/src/components/Config/ConfigPage.test.tsx b/frontend/src/components/Config/ConfigPage.test.tsx
new file mode 100644
index 0000000000..1e1aae339f
--- /dev/null
+++ b/frontend/src/components/Config/ConfigPage.test.tsx
@@ -0,0 +1,30 @@
+import { render, screen } from '@testing-library/react'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+
+import ConfigPage from './ConfigPage'
+
+jest.mock('./TargetConfig', () => {
+ const MockTargetConfig = () =>
+ MockTargetConfig.displayName = 'MockTargetConfig'
+ return {
+ __esModule: true,
+ default: MockTargetConfig,
+ }
+})
+
+const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+ {children}
+)
+
+describe('ConfigPage', () => {
+ it('renders the target configuration panel', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByTestId('target-config-panel')).toBeInTheDocument()
+ })
+})
+
diff --git a/frontend/src/components/Config/ConfigPage.tsx b/frontend/src/components/Config/ConfigPage.tsx
new file mode 100644
index 0000000000..1b56392db2
--- /dev/null
+++ b/frontend/src/components/Config/ConfigPage.tsx
@@ -0,0 +1,12 @@
+import type { TargetInstance } from '../../types'
+import TargetConfig from './TargetConfig'
+
+interface ConfigPageProps {
+ activeTarget: TargetInstance | null
+ onSetActiveTarget: (target: TargetInstance) => void
+}
+
+export default function ConfigPage({ activeTarget, onSetActiveTarget }: ConfigPageProps) {
+ return
+}
+
diff --git a/frontend/src/components/Config/InitializerConfig.styles.ts b/frontend/src/components/Config/InitializerConfig.styles.ts
new file mode 100644
index 0000000000..9d023a26ef
--- /dev/null
+++ b/frontend/src/components/Config/InitializerConfig.styles.ts
@@ -0,0 +1,38 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+export const useInitializerConfigStyles = makeStyles({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalL,
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ flexWrap: 'wrap',
+ gap: tokens.spacingVerticalM,
+ },
+ headerText: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ },
+ headerActions: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: tokens.spacingHorizontalM,
+ },
+ loadingState: {
+ display: 'flex',
+ justifyContent: 'center',
+ padding: tokens.spacingVerticalXXXL,
+ },
+ emptyState: {
+ padding: tokens.spacingVerticalXL,
+ color: tokens.colorNeutralForeground3,
+ },
+ message: {
+ width: '100%',
+ },
+})
diff --git a/frontend/src/components/Config/InitializerConfig.test.tsx b/frontend/src/components/Config/InitializerConfig.test.tsx
new file mode 100644
index 0000000000..cf258b10f0
--- /dev/null
+++ b/frontend/src/components/Config/InitializerConfig.test.tsx
@@ -0,0 +1,193 @@
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+
+import { initializersApi } from '@/services/api'
+
+import InitializerConfig from './InitializerConfig'
+
+jest.mock('@/services/api', () => ({
+ initializersApi: {
+ getSettings: jest.fn(),
+ updateSettings: jest.fn(),
+ clearSettings: jest.fn(),
+ applyNow: jest.fn(),
+ },
+}))
+
+jest.mock('./InitializerList', () => {
+ const MockInitializerList = ({
+ items,
+ onSave,
+ onApply,
+ onReset,
+ }: {
+ items: Array<{ initializer_name: string }>
+ onSave: (initializerName: string, request: { enabled: boolean }) => Promise
+ onApply: (initializerName: string, parameters?: Record | null) => Promise
+ onReset: (initializerName: string) => Promise
+ }) => (
+
+ {items.length}
+
+
+
+
+ )
+ MockInitializerList.displayName = 'MockInitializerList'
+ return {
+ __esModule: true,
+ default: MockInitializerList,
+ }
+})
+
+const mockedInitializersApi = initializersApi as jest.Mocked
+
+const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+ {children}
+)
+
+const SAMPLE_RESPONSE = {
+ items: [
+ {
+ initializer_name: 'target',
+ initializer_type: 'TargetInitializer',
+ description: 'Registers targets.',
+ required_env_vars: [],
+ supported_parameters: [],
+ enabled: true,
+ parameters: null,
+ order_index: 0,
+ saved_order_index: null,
+ source: 'baseline' as const,
+ },
+ ],
+}
+
+describe('InitializerConfig', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockedInitializersApi.getSettings.mockResolvedValue(SAMPLE_RESPONSE)
+ mockedInitializersApi.updateSettings.mockResolvedValue({
+ initializer_name: 'target',
+ enabled: false,
+ parameters: null,
+ order_index: null,
+ })
+ mockedInitializersApi.applyNow.mockResolvedValue({
+ initializer_name: 'target',
+ status: 'applied',
+ applied_parameters: { tags: ['extra'] },
+ })
+ mockedInitializersApi.clearSettings.mockResolvedValue()
+ })
+
+ it('should show loading state initially', () => {
+ mockedInitializersApi.getSettings.mockReturnValue(new Promise(() => {}))
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Loading initializer settings...')).toBeInTheDocument()
+ })
+
+ it('should render fetched initializer settings', async () => {
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('initializer-table')).toBeInTheDocument()
+ expect(screen.getByTestId('initializer-count')).toHaveTextContent('1')
+ })
+ })
+
+ it('should refresh settings when the refresh button is clicked', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(mockedInitializersApi.getSettings).toHaveBeenCalledTimes(1)
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Refresh' }))
+
+ await waitFor(() => {
+ expect(mockedInitializersApi.getSettings).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ it('should save initializer settings and show success feedback', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('initializer-table')).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Save target' }))
+
+ await waitFor(() => {
+ expect(mockedInitializersApi.updateSettings).toHaveBeenCalledWith('target', { enabled: false })
+ expect(screen.getByText('Saved settings for target.')).toBeInTheDocument()
+ })
+ })
+
+ it('should apply an initializer and show success feedback', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('initializer-table')).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Apply target' }))
+
+ await waitFor(() => {
+ expect(mockedInitializersApi.applyNow).toHaveBeenCalledWith('target', { parameters: { tags: ['extra'] } })
+ expect(screen.getByText('Applied target.')).toBeInTheDocument()
+ })
+ })
+
+ it('should clear saved settings and show success feedback', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('initializer-table')).toBeInTheDocument()
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Reset target' }))
+
+ await waitFor(() => {
+ expect(mockedInitializersApi.clearSettings).toHaveBeenCalledWith('target')
+ expect(screen.getByText('Cleared saved settings for target.')).toBeInTheDocument()
+ })
+ })
+})
+
diff --git a/frontend/src/components/Config/InitializerConfig.tsx b/frontend/src/components/Config/InitializerConfig.tsx
new file mode 100644
index 0000000000..d64f29f24c
--- /dev/null
+++ b/frontend/src/components/Config/InitializerConfig.tsx
@@ -0,0 +1,168 @@
+import { useEffect, useState } from 'react'
+import {
+ Button,
+ MessageBar,
+ MessageBarBody,
+ Spinner,
+ Text,
+} from '@fluentui/react-components'
+import { ArrowSyncRegular } from '@fluentui/react-icons'
+
+import { initializersApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type {
+ EffectiveInitializerSetting,
+ UpdateInitializerSettingRequest,
+} from '@/types'
+
+import InitializerList from './InitializerList'
+import { useInitializerConfigStyles } from './InitializerConfig.styles'
+
+interface StatusMessage {
+ intent: 'success' | 'error'
+ text: string
+}
+
+export default function InitializerConfig() {
+ const styles = useInitializerConfigStyles()
+ const [items, setItems] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [statusMessage, setStatusMessage] = useState(null)
+ const [refetchCount, setRefetchCount] = useState(0)
+ const [savingInitializerName, setSavingInitializerName] = useState(null)
+ const [applyingInitializerName, setApplyingInitializerName] = useState(null)
+ const [resettingInitializerName, setResettingInitializerName] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+
+ const loadSettingsAsync = async (): Promise => {
+ try {
+ const response = await initializersApi.getSettings()
+ if (cancelled) {
+ return
+ }
+ setItems(response.items)
+ } catch (error) {
+ if (cancelled) {
+ return
+ }
+ setStatusMessage({ intent: 'error', text: toApiError(error).detail })
+ } finally {
+ if (!cancelled) {
+ setLoading(false)
+ }
+ }
+ }
+
+ void loadSettingsAsync()
+ return () => {
+ cancelled = true
+ }
+ }, [refetchCount])
+
+ const refreshSettings = (clearStatusMessage: boolean = true): void => {
+ setLoading(true)
+ if (clearStatusMessage) {
+ setStatusMessage(null)
+ }
+ setRefetchCount((currentCount) => currentCount + 1)
+ }
+
+ const handleSave = async (
+ initializerName: string,
+ request: UpdateInitializerSettingRequest,
+ ): Promise => {
+ setSavingInitializerName(initializerName)
+
+ try {
+ await initializersApi.updateSettings(initializerName, request)
+ setStatusMessage({ intent: 'success', text: `Saved settings for ${initializerName}.` })
+ refreshSettings(false)
+ } catch (error) {
+ setStatusMessage({ intent: 'error', text: toApiError(error).detail })
+ } finally {
+ setSavingInitializerName(null)
+ }
+ }
+
+ const handleApply = async (
+ initializerName: string,
+ parameters?: Record | null,
+ ): Promise => {
+ setApplyingInitializerName(initializerName)
+
+ try {
+ // An explicit {} (rather than null/undefined) tells the backend "apply with no
+ // parameters," distinct from omitting parameters entirely, which would fall back
+ // to any saved override parameters instead.
+ await initializersApi.applyNow(initializerName, { parameters: parameters ?? {} })
+ setStatusMessage({ intent: 'success', text: `Applied ${initializerName}.` })
+ } catch (error) {
+ setStatusMessage({ intent: 'error', text: toApiError(error).detail })
+ } finally {
+ setApplyingInitializerName(null)
+ }
+ }
+
+ const handleReset = async (initializerName: string): Promise => {
+ setResettingInitializerName(initializerName)
+
+ try {
+ await initializersApi.clearSettings(initializerName)
+ setStatusMessage({ intent: 'success', text: `Cleared saved settings for ${initializerName}.` })
+ refreshSettings(false)
+ } catch (error) {
+ setStatusMessage({ intent: 'error', text: toApiError(error).detail })
+ } finally {
+ setResettingInitializerName(null)
+ }
+ }
+
+ return (
+
+
+
+
+ Review the effective initializer order, save overrides to the database, or apply a single initializer now.
+
+
+
+ }
+ onClick={() => refreshSettings()}
+ disabled={loading}
+ >
+ Refresh
+
+
+
+
+ {statusMessage && (
+
+ {statusMessage.text}
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : items.length === 0 ? (
+ No initializer settings are available.
+ ) : (
+
+ )}
+
+ )
+}
+
diff --git a/frontend/src/components/Config/InitializerList.styles.ts b/frontend/src/components/Config/InitializerList.styles.ts
new file mode 100644
index 0000000000..3a42ba34a8
--- /dev/null
+++ b/frontend/src/components/Config/InitializerList.styles.ts
@@ -0,0 +1,63 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+export const useInitializerListStyles = makeStyles({
+ list: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalL,
+ width: '100%',
+ },
+ card: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingVerticalL,
+ border: `1px solid ${tokens.colorNeutralStroke2}`,
+ borderRadius: tokens.borderRadiusLarge,
+ },
+ cardHeader: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalM,
+ },
+ titleGroup: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ },
+ parameterList: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXXS,
+ marginBottom: tokens.spacingVerticalS,
+ },
+ parameterHint: {
+ color: tokens.colorNeutralForeground3,
+ },
+ parametersEditor: {
+ fontFamily: 'Consolas, "Courier New", monospace',
+ minHeight: '10rem',
+ width: '100%',
+ },
+ actionsRow: {
+ display: 'flex',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalS,
+ },
+ sourceBadge: {
+ textTransform: 'capitalize',
+ },
+ errorText: {
+ color: tokens.colorPaletteRedForeground1,
+ marginTop: tokens.spacingVerticalXS,
+ },
+ envVarText: {
+ color: tokens.colorNeutralForeground3,
+ display: 'block',
+ marginTop: tokens.spacingVerticalXXS,
+ },
+})
+
diff --git a/frontend/src/components/Config/InitializerList.test.tsx b/frontend/src/components/Config/InitializerList.test.tsx
new file mode 100644
index 0000000000..3fe66cc559
--- /dev/null
+++ b/frontend/src/components/Config/InitializerList.test.tsx
@@ -0,0 +1,182 @@
+import { render, screen, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+
+import type { EffectiveInitializerSetting } from '@/types'
+
+import InitializerList from './InitializerList'
+
+const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+ {children}
+)
+
+const SAMPLE_ITEMS: EffectiveInitializerSetting[] = [
+ {
+ initializer_name: 'target',
+ initializer_type: 'TargetInitializer',
+ description: 'Registers targets.',
+ required_env_vars: ['AZURE_OPENAI_ENDPOINT'],
+ supported_parameters: [
+ {
+ name: 'tags',
+ type_name: 'list[str]',
+ required: false,
+ default: ['default'],
+ choices: null,
+ is_list: true,
+ description: 'Target tags.',
+ },
+ ],
+ enabled: true,
+ parameters: { tags: ['default'] },
+ order_index: 0,
+ saved_order_index: 2,
+ source: 'baseline+override',
+ },
+ {
+ initializer_name: 'scorer',
+ initializer_type: 'ScorerInitializer',
+ description: 'Registers scorers.',
+ required_env_vars: [],
+ supported_parameters: [],
+ enabled: true,
+ parameters: null,
+ order_index: 1,
+ saved_order_index: null,
+ source: 'baseline',
+ },
+]
+
+describe('InitializerList', () => {
+ const defaultProps = {
+ items: SAMPLE_ITEMS,
+ onSave: jest.fn().mockResolvedValue(undefined),
+ onApply: jest.fn().mockResolvedValue(undefined),
+ onReset: jest.fn().mockResolvedValue(undefined),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should render initializer rows and metadata', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('list', { name: 'Initializer settings' })).toBeInTheDocument()
+ expect(screen.getByText('target')).toBeInTheDocument()
+ expect(screen.getByText('Customized')).toBeInTheDocument()
+ })
+
+ it('should show the description as hover text on the initializer name', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
+
+ await user.hover(screen.getByText('target'))
+
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Registers targets.')
+ })
+
+ it('should call onSave with parsed settings', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ const row = screen.getByTestId('initializer-row-target')
+ await user.clear(within(row).getByRole('textbox', { name: 'Parameters JSON' }))
+ await user.click(within(row).getByRole('textbox', { name: 'Parameters JSON' }))
+ await user.paste('{"tags":["extra"]}')
+ await user.click(within(row).getByRole('button', { name: 'Save' }))
+
+ expect(defaultProps.onSave).toHaveBeenCalledWith('target', {
+ enabled: true,
+ parameters: { tags: ['extra'] },
+ order_index: 2,
+ })
+ })
+
+ it('should disable Save until parameters change, while keeping Apply now enabled', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ const row = screen.getByTestId('initializer-row-target')
+ expect(within(row).getByRole('button', { name: 'Save' })).toBeDisabled()
+ expect(within(row).getByRole('button', { name: 'Apply now' })).toBeEnabled()
+
+ await user.clear(within(row).getByRole('textbox', { name: 'Parameters JSON' }))
+ await user.click(within(row).getByRole('textbox', { name: 'Parameters JSON' }))
+ await user.paste('{"tags":["extra"]}')
+
+ expect(within(row).getByRole('button', { name: 'Save' })).toBeEnabled()
+ expect(within(row).getByRole('button', { name: 'Apply now' })).toBeEnabled()
+ })
+
+ it('should call onApply with parsed parameters', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ const row = screen.getByTestId('initializer-row-scorer')
+ await user.clear(within(row).getByRole('textbox', { name: 'Parameters JSON' }))
+ await user.click(within(row).getByRole('textbox', { name: 'Parameters JSON' }))
+ await user.paste('{"mode":"strict"}')
+ await user.click(within(row).getByRole('button', { name: 'Apply now' }))
+
+ expect(defaultProps.onApply).toHaveBeenCalledWith('scorer', { mode: 'strict' })
+ })
+
+ it('should show a validation error for invalid JSON', async () => {
+ const user = userEvent.setup()
+
+ render(
+
+
+ ,
+ )
+
+ const row = screen.getByTestId('initializer-row-target')
+ await user.clear(within(row).getByRole('textbox', { name: 'Parameters JSON' }))
+ await user.click(within(row).getByRole('textbox', { name: 'Parameters JSON' }))
+ await user.paste('{"tags":')
+ await user.click(within(row).getByRole('button', { name: 'Save' }))
+
+ expect(await within(row).findByRole('alert')).toHaveTextContent('Unexpected end of JSON input')
+ expect(defaultProps.onSave).not.toHaveBeenCalled()
+ })
+
+ it('should only show reset action for saved overrides', () => {
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByRole('button', { name: 'Reset saved' })).toBeInTheDocument()
+ const baselineRow = screen.getByTestId('initializer-row-scorer')
+ expect(within(baselineRow).queryByRole('button', { name: 'Reset saved' })).not.toBeInTheDocument()
+ })
+})
+
diff --git a/frontend/src/components/Config/InitializerList.tsx b/frontend/src/components/Config/InitializerList.tsx
new file mode 100644
index 0000000000..833303cc61
--- /dev/null
+++ b/frontend/src/components/Config/InitializerList.tsx
@@ -0,0 +1,271 @@
+import { useState } from 'react'
+import { Badge, Button, Field, Text, Textarea, Tooltip } from '@fluentui/react-components'
+
+import type {
+ EffectiveInitializerSetting,
+ UpdateInitializerSettingRequest,
+} from '@/types'
+
+import { useInitializerListStyles } from './InitializerList.styles'
+
+interface RowDraft {
+ enabled: boolean
+ savedOrderIndex: number | null
+ parametersText: string
+ initialParametersText: string
+ error: string | null
+}
+
+interface InitializerListProps {
+ items: EffectiveInitializerSetting[]
+ savingInitializerName?: string | null
+ applyingInitializerName?: string | null
+ resettingInitializerName?: string | null
+ onSave: (initializerName: string, request: UpdateInitializerSettingRequest) => Promise
+ onApply: (initializerName: string, parameters?: Record | null) => Promise
+ onReset: (initializerName: string) => Promise
+}
+
+function serializeParameters(parameters?: Record | null): string {
+ return JSON.stringify(parameters ?? {}, null, 2)
+}
+
+function parseParametersText(text: string): Record | null {
+ const trimmed = text.trim()
+ if (!trimmed) {
+ return null
+ }
+
+ const parsed: unknown = JSON.parse(trimmed)
+ if (parsed === null) {
+ return null
+ }
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error('Parameters must be a JSON object.')
+ }
+ return parsed as Record
+}
+
+function formatSupportedParameterSummary(initializer: EffectiveInitializerSetting): string[] {
+ if (initializer.supported_parameters.length === 0) {
+ return ['No declared parameters.']
+ }
+
+ return initializer.supported_parameters.map((parameter) => {
+ const requiredLabel = parameter.required ? 'required' : 'optional'
+ return `${parameter.name} (${parameter.type_name}, ${requiredLabel})`
+ })
+}
+
+const SOURCE_DETAILS: Record<
+ EffectiveInitializerSetting['source'],
+ { label: string; tooltip: string }
+> = {
+ baseline: {
+ label: 'From config file',
+ tooltip:
+ "This initializer comes from your deployment's config file (.pyrit_conf) and has not been changed here.",
+ },
+ override: {
+ label: 'Added in GUI',
+ tooltip:
+ "This initializer is not in your deployment's config file. It was added and is saved only here in the GUI.",
+ },
+ 'baseline+override': {
+ label: 'Customized',
+ tooltip:
+ "This initializer is defined in your deployment's config file, but its settings have been changed and saved here.",
+ },
+}
+
+export default function InitializerList({
+ items,
+ savingInitializerName = null,
+ applyingInitializerName = null,
+ resettingInitializerName = null,
+ onSave,
+ onApply,
+ onReset,
+}: InitializerListProps) {
+ const styles = useInitializerListStyles()
+ const [seenItems, setSeenItems] = useState(null)
+ const [drafts, setDrafts] = useState>({})
+
+ if (items !== seenItems) {
+ const nextDrafts = items.reduce>((accumulator, item) => {
+ const initialParametersText = serializeParameters(item.parameters)
+ accumulator[item.initializer_name] = {
+ enabled: item.enabled,
+ savedOrderIndex: item.saved_order_index ?? null,
+ parametersText: initialParametersText,
+ initialParametersText,
+ error: null,
+ }
+ return accumulator
+ }, {})
+ setSeenItems(items)
+ setDrafts(nextDrafts)
+ }
+
+ const updateDraft = (initializerName: string, patch: Partial): void => {
+ setDrafts((currentDrafts) => ({
+ ...currentDrafts,
+ [initializerName]: {
+ ...currentDrafts[initializerName],
+ ...patch,
+ },
+ }))
+ }
+
+ const parseDraft = (initializerName: string): UpdateInitializerSettingRequest | null => {
+ const draft = drafts[initializerName]
+ if (!draft) {
+ return null
+ }
+
+ try {
+ return {
+ enabled: draft.enabled,
+ parameters: parseParametersText(draft.parametersText),
+ order_index: draft.savedOrderIndex,
+ }
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Invalid initializer settings.'
+ updateDraft(initializerName, { error: message })
+ return null
+ }
+ }
+
+ const handleSave = async (initializerName: string): Promise => {
+ const parsedDraft = parseDraft(initializerName)
+ if (!parsedDraft) {
+ return
+ }
+
+ updateDraft(initializerName, { error: null })
+ await onSave(initializerName, parsedDraft)
+ }
+
+ const handleApply = async (initializerName: string): Promise => {
+ const parsedDraft = parseDraft(initializerName)
+ if (!parsedDraft) {
+ return
+ }
+
+ updateDraft(initializerName, { error: null })
+ await onApply(initializerName, parsedDraft.parameters)
+ }
+
+ const handleReset = async (initializerName: string): Promise => {
+ updateDraft(initializerName, { error: null })
+ await onReset(initializerName)
+ }
+
+ return (
+
+ {items.map((item) => {
+ const draft = drafts[item.initializer_name]
+ if (!draft) {
+ return null
+ }
+
+ const isSaving = savingInitializerName === item.initializer_name
+ const isApplying = applyingInitializerName === item.initializer_name
+ const isResetting = resettingInitializerName === item.initializer_name
+ const isBusy = isSaving || isApplying || isResetting
+ const isUnchanged = draft.parametersText === draft.initialParametersText
+
+ return (
+
+
+
+
+
+ {item.initializer_name}
+
+
+ {item.required_env_vars.length > 0 && (
+
+ Required env vars: {item.required_env_vars.join(', ')}
+
+ )}
+
+
+
+ {SOURCE_DETAILS[item.source].label}
+
+
+
+
+
+ {formatSupportedParameterSummary(item).map((summary) => (
+
+ {summary}
+
+ ))}
+
+
+
+ {draft.error && (
+
+ {draft.error}
+
+ )}
+
+
+ {isUnchanged && !isBusy ? (
+
+
+
+ ) : (
+
+ )}
+
+ {item.source !== 'baseline' && (
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
+
diff --git a/frontend/src/components/Config/InitializerSettingsDialog.styles.ts b/frontend/src/components/Config/InitializerSettingsDialog.styles.ts
new file mode 100644
index 0000000000..8b16a2d604
--- /dev/null
+++ b/frontend/src/components/Config/InitializerSettingsDialog.styles.ts
@@ -0,0 +1,15 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+export const useInitializerSettingsDialogStyles = makeStyles({
+ dialogSurface: {
+ width: '100%',
+ minWidth: 0,
+ maxWidth: '50rem',
+ '@media (max-width: 600px)': {
+ maxWidth: `calc(100vw - ${tokens.spacingHorizontalXXL} - ${tokens.spacingHorizontalXXL})`,
+ },
+ },
+ dialogContent: {
+ minWidth: 0,
+ },
+})
diff --git a/frontend/src/components/Config/InitializerSettingsDialog.tsx b/frontend/src/components/Config/InitializerSettingsDialog.tsx
new file mode 100644
index 0000000000..ddc6bc62f5
--- /dev/null
+++ b/frontend/src/components/Config/InitializerSettingsDialog.tsx
@@ -0,0 +1,39 @@
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogBody,
+ DialogContent,
+ DialogSurface,
+ DialogTitle,
+} from '@fluentui/react-components'
+
+import InitializerConfig from './InitializerConfig'
+import { useInitializerSettingsDialogStyles } from './InitializerSettingsDialog.styles'
+
+interface InitializerSettingsDialogProps {
+ open: boolean
+ onClose: () => void
+}
+
+export default function InitializerSettingsDialog({ open, onClose }: InitializerSettingsDialogProps) {
+ const styles = useInitializerSettingsDialogStyles()
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/Config/TargetConfig.test.tsx b/frontend/src/components/Config/TargetConfig.test.tsx
index 67753e89b9..07e97841b9 100644
--- a/frontend/src/components/Config/TargetConfig.test.tsx
+++ b/frontend/src/components/Config/TargetConfig.test.tsx
@@ -42,6 +42,30 @@ jest.mock("./CreateTargetDialog", () => {
};
});
+jest.mock("./InitializerSettingsDialog", () => {
+ const MockInitializerSettingsDialog = ({
+ open,
+ onClose,
+ }: {
+ open: boolean;
+ onClose: () => void;
+ }) => {
+ if (!open) return null;
+ return (
+
+
+
+ );
+ };
+ MockInitializerSettingsDialog.displayName = "MockInitializerSettingsDialog";
+ return {
+ __esModule: true,
+ default: MockInitializerSettingsDialog,
+ };
+});
+
const mockedTargetsApi = targetsApi as jest.Mocked;
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({
@@ -403,4 +427,37 @@ describe("TargetConfig", () => {
await userEvent.click(screen.getByTestId("dialog-close"));
expect(screen.queryByTestId("create-dialog")).not.toBeInTheDocument();
});
+
+ it("should open the initializer settings dialog when the button is clicked", async () => {
+ mockedTargetsApi.listTargets.mockResolvedValue({
+ items: sampleTargets,
+ pagination: { limit: 200, has_more: false },
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Initializer Settings")).toBeInTheDocument();
+ });
+
+ expect(
+ screen.queryByTestId("initializer-settings-dialog")
+ ).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByText("Initializer Settings"));
+
+ expect(
+ screen.getByTestId("initializer-settings-dialog")
+ ).toBeInTheDocument();
+
+ await userEvent.click(screen.getByTestId("initializer-dialog-close"));
+
+ expect(
+ screen.queryByTestId("initializer-settings-dialog")
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/frontend/src/components/Config/TargetConfig.tsx b/frontend/src/components/Config/TargetConfig.tsx
index feb067511a..99595b571f 100644
--- a/frontend/src/components/Config/TargetConfig.tsx
+++ b/frontend/src/components/Config/TargetConfig.tsx
@@ -6,11 +6,12 @@ import {
Link,
Spinner,
} from '@fluentui/react-components'
-import { AddRegular, ArrowSyncRegular } from '@fluentui/react-icons'
+import { AddRegular, ArrowSyncRegular, SettingsRegular } from '@fluentui/react-icons'
import { targetsApi } from '../../services/api'
import { toApiError } from '../../services/errors'
import type { TargetInstance } from '../../types'
import CreateTargetDialog from './CreateTargetDialog'
+import InitializerSettingsDialog from './InitializerSettingsDialog'
import TargetTable from './TargetTable'
import { useTargetConfigStyles } from './TargetConfig.styles'
@@ -25,6 +26,7 @@ export default function TargetConfig({ activeTarget, onSetActiveTarget }: Target
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [dialogOpen, setDialogOpen] = useState(false)
+ const [initializerDialogOpen, setInitializerDialogOpen] = useState(false)
// Counter used to re-trigger the fetch effect from event handlers (Refresh,
// dialog close) without invoking setState synchronously in the effect body.
const [refetchCount, setRefetchCount] = useState(0)
@@ -91,6 +93,15 @@ export default function TargetConfig({ activeTarget, onSetActiveTarget }: Target
>
Refresh
+ }
+ onClick={() => setInitializerDialogOpen(true)}
+ data-testid="open-initializer-settings"
+ >
+ Initializer Settings
+
+
+ setInitializerDialogOpen(false)}
+ />
)
}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 5dfb44d8f0..75bd4a9d5f 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -3,6 +3,8 @@ import { InteractionRequiredAuthError, type PublicClientApplication } from '@azu
import { toApiError } from './errors'
import { getApiScopes } from '../auth/msalConfig'
import type {
+ ApplyInitializerRequest,
+ ApplyInitializerResponse,
TargetInstance,
TargetListResponse,
TargetCatalogResponse,
@@ -10,6 +12,8 @@ import type {
ConverterInstance,
ConverterListResponse,
CreateTargetRequest,
+ InitializerSettingsResponse,
+ SavedInitializerSetting,
CreateAttackRequest,
CreateAttackResponse,
AttackSummary,
@@ -21,6 +25,7 @@ import type {
CreateConversationRequest,
CreateConversationResponse,
ChangeMainConversationResponse,
+ UpdateInitializerSettingRequest,
} from '../types'
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
@@ -197,6 +202,39 @@ export const convertersApi = {
},
}
+export const initializersApi = {
+ getSettings: async (): Promise => {
+ const response = await apiClient.get('/initializers/settings')
+ return response.data
+ },
+
+ updateSettings: async (
+ initializerName: string,
+ request: UpdateInitializerSettingRequest,
+ ): Promise => {
+ const response = await apiClient.put(
+ `/initializers/${encodeURIComponent(initializerName)}/settings`,
+ request,
+ )
+ return response.data
+ },
+
+ clearSettings: async (initializerName: string): Promise => {
+ await apiClient.delete(`/initializers/${encodeURIComponent(initializerName)}/settings`)
+ },
+
+ applyNow: async (
+ initializerName: string,
+ request?: ApplyInitializerRequest,
+ ): Promise => {
+ const response = await apiClient.post(
+ `/initializers/${encodeURIComponent(initializerName)}/apply`,
+ request ?? {},
+ )
+ return response.data
+ },
+}
+
export const attacksApi = {
createAttack: async (request: CreateAttackRequest): Promise => {
const response = await apiClient.post('/attacks', request)
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index a3da01bd0a..0d06f9ac32 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -108,6 +108,53 @@ export interface CreateTargetRequest {
auth_mode?: 'api_key' | 'identity'
}
+// --- Initializers ---
+
+export interface RegisteredInitializer {
+ initializer_name: string
+ initializer_type: string
+ description: string
+ required_env_vars: string[]
+ supported_parameters: Parameter[]
+}
+
+export type InitializerSource = 'baseline' | 'override' | 'baseline+override'
+
+export interface EffectiveInitializerSetting extends RegisteredInitializer {
+ enabled: boolean
+ parameters?: Record | null
+ order_index: number
+ saved_order_index?: number | null
+ source: InitializerSource
+}
+
+export interface InitializerSettingsResponse {
+ items: EffectiveInitializerSetting[]
+}
+
+export interface UpdateInitializerSettingRequest {
+ enabled: boolean
+ parameters?: Record | null
+ order_index?: number | null
+}
+
+export interface SavedInitializerSetting {
+ initializer_name: string
+ enabled: boolean
+ parameters?: Record | null
+ order_index?: number | null
+}
+
+export interface ApplyInitializerRequest {
+ parameters?: Record | null
+}
+
+export interface ApplyInitializerResponse {
+ initializer_name: string
+ status: 'applied'
+ applied_parameters?: Record | null
+}
+
// --- Converters ---
export interface ConverterIdentifier {
diff --git a/pyrit/backend/models/initializers.py b/pyrit/backend/models/initializers.py
index 836fa7095e..f484e097c0 100644
--- a/pyrit/backend/models/initializers.py
+++ b/pyrit/backend/models/initializers.py
@@ -9,15 +9,23 @@
Initializer parameters are described by the shared ``pyrit.models.Parameter``.
"""
+from typing import Any, Literal
+
from pydantic import BaseModel, Field
from pyrit.backend.models.common import PaginationInfo
-from pyrit.models import REGISTRY_NAME_PATTERN
+from pyrit.models import REGISTRY_NAME_PATTERN, InitializerSetting
from pyrit.models.catalog.initializer import RegisteredInitializer
__all__ = [
+ "ApplyInitializerRequest",
+ "ApplyInitializerResponse",
+ "EffectiveInitializerSetting",
+ "InitializerSettingResponse",
+ "ListEffectiveInitializerSettingsResponse",
"ListRegisteredInitializersResponse",
"RegisterInitializerRequest",
+ "UpdateInitializerSettingRequest",
]
@@ -37,3 +45,71 @@ class RegisterInitializerRequest(BaseModel):
description="Registry name for the initializer (e.g., 'my_custom')",
)
script_content: str = Field(..., description="Python source code containing a PyRITInitializer subclass")
+
+
+class EffectiveInitializerSetting(RegisteredInitializer):
+ """Merged initializer settings plus registry metadata."""
+
+ enabled: bool = Field(..., description="Whether the initializer is enabled in the effective list.")
+ parameters: dict[str, Any] | None = Field(
+ default=None,
+ description="Effective parameters that will be used for this initializer.",
+ )
+ order_index: int = Field(..., ge=0, description="Effective zero-based order position.")
+ saved_order_index: int | None = Field(
+ default=None,
+ ge=0,
+ description="Saved override order, if one exists.",
+ )
+ source: Literal["baseline", "override", "baseline+override"] = Field(
+ ...,
+ description="Whether this effective row comes from the config baseline, a saved override, or both.",
+ )
+
+
+class ListEffectiveInitializerSettingsResponse(BaseModel):
+ """Response for listing merged initializer settings."""
+
+ items: list[EffectiveInitializerSetting] = Field(
+ ...,
+ description="Merged baseline and saved initializer settings.",
+ )
+
+
+class UpdateInitializerSettingRequest(BaseModel):
+ """Request body for saving one initializer override."""
+
+ enabled: bool = Field(default=True, description="Whether the initializer should remain enabled.")
+ parameters: dict[str, Any] | None = Field(
+ default=None,
+ description="Parameter overrides to persist for this initializer.",
+ )
+ order_index: int | None = Field(
+ default=None,
+ ge=0,
+ description="Optional zero-based order override for this initializer.",
+ )
+
+
+class InitializerSettingResponse(InitializerSetting):
+ """Saved initializer override row."""
+
+
+class ApplyInitializerRequest(BaseModel):
+ """Optional request body for applying an initializer immediately."""
+
+ parameters: dict[str, Any] | None = Field(
+ default=None,
+ description="Optional one-time parameters for this apply-now request.",
+ )
+
+
+class ApplyInitializerResponse(BaseModel):
+ """Response for a successful apply-now initializer run."""
+
+ initializer_name: str = Field(..., description="Initializer registry name that was applied.")
+ status: Literal["applied"] = Field(default="applied", description="Result status.")
+ applied_parameters: dict[str, Any] | None = Field(
+ default=None,
+ description="Parameters used for this apply-now execution.",
+ )
diff --git a/pyrit/backend/routes/initializers.py b/pyrit/backend/routes/initializers.py
index 9d2e4cbb1d..9b65f4cbce 100644
--- a/pyrit/backend/routes/initializers.py
+++ b/pyrit/backend/routes/initializers.py
@@ -13,19 +13,48 @@
DELETE /api/initializers/{name} — unregister an initializer
"""
+import asyncio
+import os
+from collections.abc import Sequence
+from pathlib import Path
+
from fastapi import APIRouter, HTTPException, Query, Request, status
from pyrit.backend.models.common import ProblemDetail
from pyrit.backend.models.initializers import (
+ ApplyInitializerRequest,
+ ApplyInitializerResponse,
+ InitializerSettingResponse,
+ ListEffectiveInitializerSettingsResponse,
ListRegisteredInitializersResponse,
RegisterInitializerRequest,
+ UpdateInitializerSettingRequest,
)
from pyrit.backend.services.initializer_service import get_initializer_service
from pyrit.models.catalog.initializer import RegisteredInitializer
+from pyrit.setup.configuration_loader import ConfigurationLoader, InitializerConfig
router = APIRouter(prefix="/initializers", tags=["initializers"])
+def _load_baseline_initializer_configs_sync() -> Sequence[InitializerConfig]:
+ """
+ Load the baseline initializer list from the active configuration.
+
+ Uses the same ``PYRIT_CONFIG_FILE`` resolution as the backend's startup
+ lifespan (see ``pyrit.backend.main.lifespan``) so the baseline shown here
+ always matches what the running backend was actually initialized with,
+ rather than re-reading ``~/.pyrit/.pyrit_conf`` unconditionally.
+
+ Returns:
+ Sequence[InitializerConfig]: The normalized initializer configs from the active config.
+ """
+ config_file_env = os.getenv("PYRIT_CONFIG_FILE")
+ config_file = Path(config_file_env) if config_file_env else None
+ config = ConfigurationLoader.load_with_overrides(config_file=config_file)
+ return list(config.initializer_configs)
+
+
def _check_custom_initializers_allowed(request: Request) -> None:
"""
Check that allow_custom_initializers is enabled on the server.
@@ -68,6 +97,116 @@ async def list_initializers( # pyrit-async-suffix-exempt
return await service.list_initializers_async(limit=limit, cursor=cursor)
+@router.get(
+ "/settings",
+ response_model=ListEffectiveInitializerSettingsResponse,
+)
+async def get_initializer_settings( # pyrit-async-suffix-exempt
+) -> ListEffectiveInitializerSettingsResponse:
+ """
+ Get the merged effective initializer list.
+
+ Returns:
+ ListEffectiveInitializerSettingsResponse: Merged baseline and saved settings.
+ """
+ service = get_initializer_service()
+ baseline_initializers = await asyncio.to_thread(_load_baseline_initializer_configs_sync)
+ return await service.list_effective_initializer_settings_async(baseline_initializers=baseline_initializers)
+
+
+@router.put(
+ "/{initializer_name}/settings",
+ response_model=InitializerSettingResponse,
+ responses={
+ 400: {"model": ProblemDetail, "description": "Invalid initializer settings"},
+ 404: {"model": ProblemDetail, "description": "Initializer not found"},
+ },
+)
+async def save_initializer_settings( # pyrit-async-suffix-exempt
+ initializer_name: str,
+ body: UpdateInitializerSettingRequest,
+) -> InitializerSettingResponse:
+ """
+ Validate and save one initializer override row.
+
+ Args:
+ initializer_name: Registry name of the initializer.
+ body: The override payload to persist.
+
+ Returns:
+ InitializerSettingResponse: The saved override row.
+ """
+ service = get_initializer_service()
+ try:
+ return await service.save_initializer_setting_async(
+ initializer_name=initializer_name,
+ enabled=body.enabled,
+ parameters=body.parameters,
+ order_index=body.order_index,
+ )
+ except KeyError:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Initializer '{initializer_name}' not found",
+ ) from None
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
+
+
+@router.delete(
+ "/{initializer_name}/settings",
+ status_code=status.HTTP_204_NO_CONTENT,
+)
+async def delete_initializer_settings( # pyrit-async-suffix-exempt
+ initializer_name: str,
+) -> None:
+ """
+ Delete one saved initializer override row.
+
+ Args:
+ initializer_name: Registry name of the initializer whose saved settings should be cleared.
+ """
+ service = get_initializer_service()
+ await service.delete_initializer_setting_async(initializer_name=initializer_name)
+
+
+@router.post(
+ "/{initializer_name}/apply",
+ response_model=ApplyInitializerResponse,
+ responses={
+ 400: {"model": ProblemDetail, "description": "Initializer apply failed"},
+ 404: {"model": ProblemDetail, "description": "Initializer not found"},
+ },
+)
+async def apply_initializer( # pyrit-async-suffix-exempt
+ initializer_name: str,
+ body: ApplyInitializerRequest | None = None,
+) -> ApplyInitializerResponse:
+ """
+ Apply one initializer immediately without saving it.
+
+ Args:
+ initializer_name: Registry name of the initializer to apply.
+ body: Optional one-time parameters for this execution.
+
+ Returns:
+ ApplyInitializerResponse: Success metadata for the apply-now operation.
+ """
+ service = get_initializer_service()
+ try:
+ return await service.apply_initializer_async(
+ initializer_name=initializer_name,
+ parameters=body.parameters if body else None,
+ )
+ except KeyError:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=f"Initializer '{initializer_name}' not found",
+ ) from None
+ except ValueError as exc:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
+
+
@router.get(
"/{initializer_name}",
response_model=RegisteredInitializer,
diff --git a/pyrit/backend/services/initializer_service.py b/pyrit/backend/services/initializer_service.py
index e8476a076e..561f4335f7 100644
--- a/pyrit/backend/services/initializer_service.py
+++ b/pyrit/backend/services/initializer_service.py
@@ -2,36 +2,51 @@
# Licensed under the MIT license.
"""
-Initializer service for listing, registering, and removing initializers.
+Initializer service for catalog, settings, and apply-now operations.
-Provides access to the InitializerRegistry, exposing initializer
-metadata through the REST API.
+Provides access to the ``InitializerRegistry`` plus persisted initializer
+override rows stored in Central Memory.
"""
import logging
+from collections.abc import Sequence
from functools import lru_cache
+from typing import Any
from pyrit.backend.models.common import PaginationInfo
from pyrit.backend.models.initializers import (
+ ApplyInitializerResponse,
+ EffectiveInitializerSetting,
+ ListEffectiveInitializerSettingsResponse,
ListRegisteredInitializersResponse,
)
-from pyrit.models.catalog.initializer import (
- RegisteredInitializer,
-)
+from pyrit.memory import CentralMemory
+from pyrit.models import InitializerSetting
+from pyrit.models.catalog.initializer import RegisteredInitializer
from pyrit.registry import InitializerMetadata, InitializerRegistry
+from pyrit.setup.configuration_loader import InitializerConfig
logger = logging.getLogger(__name__)
+# Built-in initializers whose effects are only observed by the scanner (a separate
+# consumer that shares this backend process) and have no visible effect in the GUI
+# itself: no scenario-run, dataset-browsing, or live-scoring UI exists here today.
+# Excluded from the GUI-facing effective settings list so the Initializers page only
+# shows controls that actually do something for a GUI-only user.
+_GUI_HIDDEN_INITIALIZER_NAMES = frozenset(
+ {"scorer", "technique", "load_default_datasets", "preload_scenario_metadata"}
+)
+
def _metadata_to_registered_initializer(metadata: InitializerMetadata) -> RegisteredInitializer:
"""
- Convert an InitializerMetadata dataclass to a RegisteredInitializer Pydantic model.
+ Convert initializer metadata into a response model.
Args:
metadata: The registry metadata for an initializer.
Returns:
- RegisteredInitializer Pydantic model.
+ RegisteredInitializer: The response model representation.
"""
return RegisteredInitializer(
initializer_name=metadata.registry_name,
@@ -42,16 +57,37 @@ def _metadata_to_registered_initializer(metadata: InitializerMetadata) -> Regist
)
+def _missing_registered_initializer(initializer_name: str) -> RegisteredInitializer:
+ """
+ Build placeholder metadata for a saved override whose class is no longer registered.
+
+ Args:
+ initializer_name: The missing initializer's registry name.
+
+ Returns:
+ RegisteredInitializer: Placeholder metadata for display.
+ """
+ return RegisteredInitializer(
+ initializer_name=initializer_name,
+ initializer_type="UnknownInitializer",
+ description="Initializer is no longer registered.",
+ required_env_vars=[],
+ supported_parameters=[],
+ )
+
+
class InitializerService:
"""
- Service for listing, registering, and removing initializers.
+ Service for listing, registering, configuring, and applying initializers.
- Uses InitializerRegistry as the source of truth for initializer metadata.
+ Uses ``InitializerRegistry`` for metadata/building and Central Memory for
+ persisted override rows.
"""
def __init__(self) -> None:
"""Initialize the initializer service."""
self._registry = InitializerRegistry.get_registry_singleton()
+ self._memory = CentralMemory.get_memory_instance()
async def list_initializers_async(
self,
@@ -67,7 +103,7 @@ async def list_initializers_async(
cursor: Pagination cursor (initializer_name to start after).
Returns:
- ListRegisteredInitializersResponse with paginated initializer summaries.
+ ListRegisteredInitializersResponse: Paginated initializer summaries.
"""
all_metadata = self._registry.get_all_registered_class_metadata()
all_summaries = [_metadata_to_registered_initializer(m) for m in all_metadata]
@@ -85,16 +121,159 @@ async def get_initializer_async(self, *, initializer_name: str) -> RegisteredIni
Get a single initializer by registry name.
Args:
- initializer_name: The registry key of the initializer (e.g., 'target').
+ initializer_name: The registry key of the initializer.
Returns:
- RegisteredInitializer if found, None otherwise.
+ RegisteredInitializer | None: The matching initializer, if found.
"""
- all_metadata = self._registry.get_all_registered_class_metadata()
- for metadata in all_metadata:
- if metadata.registry_name == initializer_name:
- return _metadata_to_registered_initializer(metadata)
- return None
+ metadata = self._get_metadata_by_name().get(initializer_name)
+ return _metadata_to_registered_initializer(metadata) if metadata else None
+
+ async def list_effective_initializer_settings_async(
+ self,
+ *,
+ baseline_initializers: Sequence[InitializerConfig],
+ ) -> ListEffectiveInitializerSettingsResponse:
+ """
+ Merge baseline initializer config with saved override rows.
+
+ Args:
+ baseline_initializers: The initializer list resolved from the config baseline.
+
+ Returns:
+ ListEffectiveInitializerSettingsResponse: The merged, ordered effective list.
+ """
+ metadata_by_name = self._get_metadata_by_name()
+ saved_overrides = {setting.initializer_name: setting for setting in self._memory.get_initializer_settings()}
+ ordered_items: list[tuple[int, int, int, EffectiveInitializerSetting]] = []
+ seen_names: set[str] = set()
+
+ for baseline_position, config in enumerate(baseline_initializers):
+ if config.name in _GUI_HIDDEN_INITIALIZER_NAMES:
+ continue
+
+ override = saved_overrides.get(config.name)
+ effective_parameters = override.parameters if override and override.parameters is not None else config.args
+ source = "baseline+override" if override else "baseline"
+ effective_order = (
+ override.order_index if override and override.order_index is not None else baseline_position
+ )
+ registered_initializer = self._get_registered_initializer_for_name(
+ initializer_name=config.name,
+ metadata_by_name=metadata_by_name,
+ )
+
+ ordered_items.append(
+ self._build_effective_item_sort_entry(
+ registered_initializer=registered_initializer,
+ enabled=override.enabled if override else True,
+ parameters=effective_parameters,
+ order_index=effective_order,
+ saved_order_index=override.order_index if override else None,
+ source=source,
+ insertion_order=baseline_position,
+ )
+ )
+ seen_names.add(config.name)
+
+ append_index = 0
+ for initializer_name, override in saved_overrides.items():
+ if initializer_name in seen_names or initializer_name in _GUI_HIDDEN_INITIALIZER_NAMES:
+ continue
+
+ effective_order = (
+ override.order_index if override.order_index is not None else len(baseline_initializers) + append_index
+ )
+ registered_initializer = self._get_registered_initializer_for_name(
+ initializer_name=initializer_name,
+ metadata_by_name=metadata_by_name,
+ )
+
+ ordered_items.append(
+ self._build_effective_item_sort_entry(
+ registered_initializer=registered_initializer,
+ enabled=override.enabled,
+ parameters=override.parameters,
+ order_index=effective_order,
+ saved_order_index=override.order_index,
+ source="override",
+ insertion_order=len(baseline_initializers) + append_index,
+ )
+ )
+ append_index += 1
+
+ ordered_items.sort(key=lambda item: (item[0], item[1], item[2], item[3].initializer_name))
+ return ListEffectiveInitializerSettingsResponse(items=[item[3] for item in ordered_items])
+
+ async def save_initializer_setting_async(
+ self,
+ *,
+ initializer_name: str,
+ enabled: bool,
+ parameters: dict[str, Any] | None,
+ order_index: int | None,
+ ) -> InitializerSetting:
+ """
+ Validate and persist a single initializer override row.
+
+ Args:
+ initializer_name: The initializer registry name.
+ enabled: Whether the initializer should remain enabled.
+ parameters: Optional parameter overrides to persist.
+ order_index: Optional zero-based order override.
+
+ Returns:
+ InitializerSetting: The saved override row.
+ """
+ self._validate_initializer_parameters(initializer_name=initializer_name, parameters=parameters)
+ setting = InitializerSetting(
+ initializer_name=initializer_name,
+ enabled=enabled,
+ parameters=parameters,
+ order_index=order_index,
+ )
+ self._memory.add_initializer_setting(setting=setting)
+ return setting
+
+ async def delete_initializer_setting_async(self, *, initializer_name: str) -> None:
+ """
+ Delete one saved initializer override row.
+
+ Args:
+ initializer_name: The initializer registry name to clear.
+ """
+ self._memory.delete_initializer_setting(initializer_name=initializer_name)
+
+ async def apply_initializer_async(
+ self,
+ *,
+ initializer_name: str,
+ parameters: dict[str, Any] | None = None,
+ ) -> ApplyInitializerResponse:
+ """
+ Build, validate, and run one initializer immediately.
+
+ Args:
+ initializer_name: The initializer registry name to execute.
+ parameters: Optional one-time parameters. When omitted, any saved override
+ parameters are used instead.
+
+ Returns:
+ ApplyInitializerResponse: Success metadata for the apply-now execution.
+ """
+ resolved_parameters = parameters if parameters is not None else self._get_saved_parameters(initializer_name)
+ initializer = self._registry.create_and_configure(
+ initializer_name,
+ initializer_params=resolved_parameters or None,
+ )
+ initializer.validate()
+ await initializer.initialize_async()
+
+ return ApplyInitializerResponse(
+ initializer_name=initializer_name,
+ status="applied",
+ applied_parameters=resolved_parameters,
+ )
async def register_initializer_async(
self,
@@ -110,10 +289,7 @@ async def register_initializer_async(
script_content: Python source code containing a PyRITInitializer subclass.
Returns:
- The newly registered initializer summary.
-
- Raises:
- ValueError: If the script is invalid or contains no initializer class.
+ RegisteredInitializer: The newly registered initializer summary.
"""
self._registry.register_from_content(name=name, script_content=script_content)
@@ -126,13 +302,81 @@ async def unregister_initializer_async(self, *, initializer_name: str) -> None:
"""
Remove a custom initializer from the registry.
- Built-in initializers cannot be removed.
-
Args:
initializer_name: The registry name to remove.
"""
self._registry.unregister_and_cleanup(initializer_name)
- logger.info(f"Unregistered initializer: {initializer_name}")
+ logger.info("Unregistered initializer: %s", initializer_name)
+
+ def _validate_initializer_parameters(
+ self,
+ *,
+ initializer_name: str,
+ parameters: dict[str, Any] | None,
+ ) -> None:
+ """
+ Ensure the initializer exists and its parameters are valid.
+
+ Args:
+ initializer_name: The initializer registry name.
+ parameters: Optional initializer parameters to validate.
+ """
+ self._registry.create_and_configure(initializer_name, initializer_params=parameters or None)
+
+ def _get_saved_parameters(self, initializer_name: str) -> dict[str, Any] | None:
+ """
+ Look up saved parameters for one initializer.
+
+ Args:
+ initializer_name: The initializer registry name.
+
+ Returns:
+ dict[str, Any] | None: Saved parameters, if present.
+ """
+ for setting in self._memory.get_initializer_settings():
+ if setting.initializer_name == initializer_name:
+ return setting.parameters
+ return None
+
+ def _get_metadata_by_name(self) -> dict[str, InitializerMetadata]:
+ return {metadata.registry_name: metadata for metadata in self._registry.get_all_registered_class_metadata()}
+
+ def _get_registered_initializer_for_name(
+ self,
+ *,
+ initializer_name: str,
+ metadata_by_name: dict[str, InitializerMetadata],
+ ) -> RegisteredInitializer:
+ metadata = metadata_by_name.get(initializer_name)
+ if metadata:
+ return _metadata_to_registered_initializer(metadata)
+ return _missing_registered_initializer(initializer_name)
+
+ @staticmethod
+ def _build_effective_item_sort_entry(
+ *,
+ registered_initializer: RegisteredInitializer,
+ enabled: bool,
+ parameters: dict[str, Any] | None,
+ order_index: int,
+ saved_order_index: int | None,
+ source: str,
+ insertion_order: int,
+ ) -> tuple[int, int, int, EffectiveInitializerSetting]:
+ explicit_priority = 0 if saved_order_index is not None else 1
+ return (
+ order_index,
+ explicit_priority,
+ insertion_order,
+ EffectiveInitializerSetting(
+ **registered_initializer.model_dump(),
+ enabled=enabled,
+ parameters=parameters,
+ order_index=order_index,
+ saved_order_index=saved_order_index,
+ source=source,
+ ),
+ )
@staticmethod
def _paginate(
@@ -150,13 +394,13 @@ def _paginate(
limit: Maximum items per page.
Returns:
- Tuple of (paginated items, has_more flag).
+ tuple[list[RegisteredInitializer], bool]: Paginated items and has-more flag.
"""
start_idx = 0
if cursor:
- for i, item in enumerate(items):
+ for index, item in enumerate(items):
if item.initializer_name == cursor:
- start_idx = i + 1
+ start_idx = index + 1
break
page = items[start_idx : start_idx + limit]
@@ -170,6 +414,6 @@ def get_initializer_service() -> InitializerService:
Get the global initializer service instance.
Returns:
- The singleton InitializerService instance.
+ InitializerService: The singleton initializer service instance.
"""
return InitializerService()
diff --git a/pyrit/memory/alembic/versions/4c9a6e1f2b7d_add_initializer_settings_table.py b/pyrit/memory/alembic/versions/4c9a6e1f2b7d_add_initializer_settings_table.py
new file mode 100644
index 0000000000..e4ad05308c
--- /dev/null
+++ b/pyrit/memory/alembic/versions/4c9a6e1f2b7d_add_initializer_settings_table.py
@@ -0,0 +1,38 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Add persisted initializer settings table.
+
+Revision ID: 4c9a6e1f2b7d
+Revises: 3f6e8a0c2d4b
+Create Date: 2026-07-24 12:30:00.000000
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "4c9a6e1f2b7d"
+down_revision: str | None = "3f6e8a0c2d4b"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Apply this schema upgrade."""
+ op.create_table(
+ "InitializerSettings",
+ sa.Column("initializer_name", sa.String(length=64), nullable=False),
+ sa.Column("enabled", sa.Boolean(), nullable=False),
+ sa.Column("parameters", sa.JSON(), nullable=True),
+ sa.Column("order_index", sa.INTEGER(), nullable=True),
+ sa.PrimaryKeyConstraint("initializer_name"),
+ )
+
+
+def downgrade() -> None:
+ """Revert this schema upgrade."""
+ op.drop_table("InitializerSettings")
diff --git a/pyrit/memory/memory_interface.py b/pyrit/memory/memory_interface.py
index 602f13b6bf..01f42005e9 100644
--- a/pyrit/memory/memory_interface.py
+++ b/pyrit/memory/memory_interface.py
@@ -32,6 +32,7 @@
ConversationEntry,
ConverterIdentifierEntry,
EmbeddingDataEntry,
+ InitializerSettingEntry,
PromptConverterIdentifierEntry,
PromptMemoryEntry,
ScenarioIdentifierEntry,
@@ -61,6 +62,7 @@
ConverterIdentifier,
IdentifierFilter,
IdentifierType,
+ InitializerSetting,
Message,
MessagePiece,
ScenarioIdentifier,
@@ -323,6 +325,49 @@ def get_all_embeddings(self) -> Sequence[EmbeddingDataEntry]:
result: Sequence[EmbeddingDataEntry] = self._query_entries(EmbeddingDataEntry)
return result
+ def add_initializer_setting(self, *, setting: InitializerSetting) -> None:
+ """
+ Insert or replace a saved initializer setting.
+
+ Args:
+ setting: The initializer setting to persist.
+ """
+ self._update_entry(InitializerSettingEntry.from_domain_model(setting))
+
+ def get_initializer_settings(self) -> Sequence[InitializerSetting]:
+ """
+ Load all saved initializer settings.
+
+ Returns:
+ Sequence[InitializerSetting]: The persisted settings ordered by initializer name.
+ """
+ entries = self._query_entries(
+ InitializerSettingEntry,
+ order_by=InitializerSettingEntry.initializer_name.asc(),
+ )
+ return [entry.to_domain_model() for entry in entries]
+
+ def delete_initializer_setting(self, *, initializer_name: str) -> None:
+ """
+ Delete a saved initializer setting when it exists.
+
+ Args:
+ initializer_name: The initializer registry name to delete.
+
+ Raises:
+ SQLAlchemyError: If the delete operation fails.
+ """
+ with closing(self.get_session()) as session:
+ try:
+ session.query(InitializerSettingEntry).filter(
+ InitializerSettingEntry.initializer_name == initializer_name
+ ).delete(synchronize_session=False)
+ session.commit()
+ except SQLAlchemyError as e:
+ session.rollback()
+ logger.exception(f"Error deleting initializer setting '{initializer_name}': {e}")
+ raise
+
@abc.abstractmethod
def _init_storage_io(self) -> None:
"""
diff --git a/pyrit/memory/memory_models.py b/pyrit/memory/memory_models.py
index 24e8fb875c..49c3c04c01 100644
--- a/pyrit/memory/memory_models.py
+++ b/pyrit/memory/memory_models.py
@@ -52,6 +52,7 @@
ConversationType,
ConverterIdentifier,
EvaluationIdentifier,
+ InitializerSetting,
MessagePiece,
PromptDataType,
ScenarioEvaluationIdentifier,
@@ -419,6 +420,50 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
)
+class InitializerSettingEntry(DomainBackedEntry[InitializerSetting]):
+ """Persistence row for an ``InitializerSetting`` override."""
+
+ __tablename__ = "InitializerSettings"
+ __table_args__ = {"extend_existing": True}
+
+ initializer_name: Mapped[str] = mapped_column(String(64), primary_key=True)
+ enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+ parameters: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
+ order_index: Mapped[int | None] = mapped_column(INTEGER, nullable=True)
+
+ @classmethod
+ def from_domain_model(cls, domain_model: InitializerSetting) -> Self:
+ """
+ Build an unsaved initializer-setting row from its domain model.
+
+ Args:
+ domain_model (InitializerSetting): The domain model this entry persists.
+
+ Returns:
+ Self: A new, unsaved row.
+ """
+ return cls(
+ initializer_name=domain_model.initializer_name,
+ enabled=domain_model.enabled,
+ parameters=domain_model.parameters,
+ order_index=domain_model.order_index,
+ )
+
+ def to_domain_model(self) -> InitializerSetting:
+ """
+ Convert this row back into its domain model.
+
+ Returns:
+ InitializerSetting: The reconstructed initializer setting.
+ """
+ return InitializerSetting(
+ initializer_name=self.initializer_name,
+ enabled=self.enabled,
+ parameters=self.parameters,
+ order_index=self.order_index,
+ )
+
+
T = TypeVar("T", bound=ComponentIdentifier)
diff --git a/pyrit/models/__init__.py b/pyrit/models/__init__.py
index 4c9075d58a..a828f7aeeb 100644
--- a/pyrit/models/__init__.py
+++ b/pyrit/models/__init__.py
@@ -49,6 +49,7 @@
snake_case_to_class_name,
validate_registry_name,
)
+from pyrit.models.initializer_setting import InitializerSetting
from pyrit.models.literals import (
MEDIA_PATH_DATA_TYPES,
ChatMessageRole,
@@ -164,6 +165,7 @@
"Identifiable",
"IdentifierFilter",
"IdentifierType",
+ "InitializerSetting",
"JSONValue",
"COMMON_JSON_SCHEMAS",
"JsonResponseConfig",
diff --git a/pyrit/models/initializer_setting.py b/pyrit/models/initializer_setting.py
new file mode 100644
index 0000000000..8e78e8fb81
--- /dev/null
+++ b/pyrit/models/initializer_setting.py
@@ -0,0 +1,32 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Persisted initializer override settings."""
+
+from typing import Any
+
+from pydantic import BaseModel, Field, field_validator
+
+from pyrit.models.identifiers import validate_registry_name
+
+
+class InitializerSetting(BaseModel):
+ """Saved override for a registered initializer."""
+
+ initializer_name: str = Field(..., description="Initializer registry name.")
+ enabled: bool = Field(default=True, description="Whether the initializer is enabled.")
+ parameters: dict[str, Any] | None = Field(
+ default=None,
+ description="JSON-serializable parameter overrides for this initializer.",
+ )
+ order_index: int | None = Field(
+ default=None,
+ ge=0,
+ description="Optional zero-based startup-order override.",
+ )
+
+ @field_validator("initializer_name")
+ @classmethod
+ def _validate_initializer_name(cls, value: str) -> str:
+ validate_registry_name(value)
+ return value
diff --git a/pyrit/registry/components/initializer_registry.py b/pyrit/registry/components/initializer_registry.py
index c7b74994ee..27fee84a99 100644
--- a/pyrit/registry/components/initializer_registry.py
+++ b/pyrit/registry/components/initializer_registry.py
@@ -203,7 +203,7 @@ def _build_metadata(self, name: str, cls: type[PyRITInitializer]) -> Initializer
Returns:
InitializerMetadata describing the initializer class.
"""
- description = RegistryMetadata.description_from_docstring(cls, fallback="No description available")
+ description = RegistryMetadata.summary_from_docstring(cls) or "No description available"
try:
instance = cls()
diff --git a/pyrit/setup/configuration_loader.py b/pyrit/setup/configuration_loader.py
index 4ed9355deb..50c654dbc3 100644
--- a/pyrit/setup/configuration_loader.py
+++ b/pyrit/setup/configuration_loader.py
@@ -216,6 +216,11 @@ def server_config(self) -> ServerConfig | None:
"""The normalized ``server:`` block, or ``None`` when not configured."""
return self._server_config
+ @property
+ def initializer_configs(self) -> Sequence[InitializerConfig]:
+ """The normalized, ordered list of ``initializers:`` entries from this configuration."""
+ return self._initializer_configs
+
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ConfigurationLoader":
"""
diff --git a/tests/unit/backend/test_initializer_service.py b/tests/unit/backend/test_initializer_service.py
index e0e83e4176..4778ae4ea7 100644
--- a/tests/unit/backend/test_initializer_service.py
+++ b/tests/unit/backend/test_initializer_service.py
@@ -14,12 +14,15 @@
from pyrit.backend.main import app
from pyrit.backend.models.common import PaginationInfo
from pyrit.backend.models.initializers import (
+ ApplyInitializerResponse,
+ ListEffectiveInitializerSettingsResponse,
ListRegisteredInitializersResponse,
RegisteredInitializer,
)
from pyrit.backend.services.initializer_service import InitializerService, get_initializer_service
-from pyrit.models import Parameter
+from pyrit.models import InitializerSetting, Parameter
from pyrit.registry import InitializerMetadata
+from pyrit.setup.configuration_loader import InitializerConfig
@pytest.fixture
@@ -189,6 +192,207 @@ async def test_get_initializer_returns_none_for_missing(self) -> None:
assert result is None
+class TestInitializerServiceSettings:
+ """Tests for merged initializer settings behavior."""
+
+ async def test_list_effective_initializer_settings_merges_baseline_and_overrides(self) -> None:
+ metadata = [
+ _make_initializer_metadata(registry_name="target", class_name="TargetInitializer"),
+ _make_initializer_metadata(registry_name="widget", class_name="WidgetInitializer"),
+ _make_initializer_metadata(registry_name="custom", class_name="CustomInitializer"),
+ ]
+ baseline_initializers = [
+ InitializerConfig(name="target", args={"tags": ["baseline"]}),
+ InitializerConfig(name="widget", args={"mode": "baseline"}),
+ ]
+ saved_overrides = [
+ InitializerSetting(initializer_name="target", enabled=False, order_index=3),
+ InitializerSetting(
+ initializer_name="custom",
+ enabled=True,
+ parameters={"tags": ["override"]},
+ order_index=0,
+ ),
+ ]
+
+ with patch.object(InitializerService, "__init__", lambda self: None):
+ service = InitializerService()
+ service._registry = MagicMock()
+ service._registry.get_all_registered_class_metadata.return_value = metadata
+ service._memory = MagicMock()
+ service._memory.get_initializer_settings.return_value = saved_overrides
+
+ result = await service.list_effective_initializer_settings_async(
+ baseline_initializers=baseline_initializers
+ )
+
+ assert [item.initializer_name for item in result.items] == ["custom", "widget", "target"]
+ assert [item.source for item in result.items] == ["override", "baseline", "baseline+override"]
+ assert result.items[0].parameters == {"tags": ["override"]}
+ assert result.items[1].parameters == {"mode": "baseline"}
+ assert result.items[2].enabled is False
+ assert result.items[2].saved_order_index == 3
+
+ async def test_list_effective_initializer_settings_hides_scanner_only_initializers(self) -> None:
+ """Scorer/technique/dataset/scenario-metadata initializers have no GUI-visible
+ effect (no scenario-run, dataset, or live-scoring UI exists here), so the
+ GUI-facing effective settings list excludes them even when they are configured
+ in the baseline or have a saved override."""
+ metadata = [
+ _make_initializer_metadata(registry_name="target", class_name="TargetInitializer"),
+ _make_initializer_metadata(registry_name="scorer", class_name="ScorerInitializer"),
+ _make_initializer_metadata(registry_name="technique", class_name="TechniqueInitializer"),
+ _make_initializer_metadata(registry_name="load_default_datasets", class_name="LoadDefaultDatasets"),
+ _make_initializer_metadata(
+ registry_name="preload_scenario_metadata", class_name="PreloadScenarioMetadata"
+ ),
+ ]
+ baseline_initializers = [
+ InitializerConfig(name="technique"),
+ InitializerConfig(name="target"),
+ InitializerConfig(name="scorer"),
+ InitializerConfig(name="load_default_datasets"),
+ ]
+ saved_overrides = [
+ InitializerSetting(initializer_name="scorer", parameters={"mode": "strict"}),
+ InitializerSetting(initializer_name="preload_scenario_metadata", enabled=False),
+ ]
+
+ with patch.object(InitializerService, "__init__", lambda self: None):
+ service = InitializerService()
+ service._registry = MagicMock()
+ service._registry.get_all_registered_class_metadata.return_value = metadata
+ service._memory = MagicMock()
+ service._memory.get_initializer_settings.return_value = saved_overrides
+
+ result = await service.list_effective_initializer_settings_async(
+ baseline_initializers=baseline_initializers
+ )
+
+ assert [item.initializer_name for item in result.items] == ["target"]
+
+ async def test_list_effective_initializer_settings_handles_disabled_override_without_order(self) -> None:
+ baseline_initializers = [InitializerConfig(name="target", args={"tags": ["baseline"]})]
+
+ with patch.object(InitializerService, "__init__", lambda self: None):
+ service = InitializerService()
+ service._registry = MagicMock()
+ service._registry.get_all_registered_class_metadata.return_value = [
+ _make_initializer_metadata(registry_name="target")
+ ]
+ service._memory = MagicMock()
+ service._memory.get_initializer_settings.return_value = [
+ InitializerSetting(initializer_name="target", enabled=False)
+ ]
+
+ result = await service.list_effective_initializer_settings_async(
+ baseline_initializers=baseline_initializers
+ )
+
+ assert result.items[0].enabled is False
+ assert result.items[0].order_index == 0
+ assert result.items[0].source == "baseline+override"
+
+ async def test_save_initializer_setting_validates_and_persists(self) -> None:
+ with patch.object(InitializerService, "__init__", lambda self: None):
+ service = InitializerService()
+ service._registry = MagicMock()
+ service._memory = MagicMock()
+
+ result = await service.save_initializer_setting_async(
+ initializer_name="target",
+ enabled=False,
+ parameters={"tags": ["saved"]},
+ order_index=2,
+ )
+
+ service._registry.create_and_configure.assert_called_once_with(
+ "target",
+ initializer_params={"tags": ["saved"]},
+ )
+ service._memory.add_initializer_setting.assert_called_once()
+ assert result == InitializerSetting(
+ initializer_name="target",
+ enabled=False,
+ parameters={"tags": ["saved"]},
+ order_index=2,
+ )
+
+ async def test_delete_initializer_setting_calls_memory(self) -> None:
+ with patch.object(InitializerService, "__init__", lambda self: None):
+ service = InitializerService()
+ service._memory = MagicMock()
+ service._registry = MagicMock()
+
+ await service.delete_initializer_setting_async(initializer_name="target")
+
+ service._memory.delete_initializer_setting.assert_called_once_with(initializer_name="target")
+
+ async def test_apply_initializer_uses_explicit_parameters(self) -> None:
+ initializer = MagicMock()
+ initializer.validate = MagicMock()
+ initializer.initialize_async = AsyncMock(return_value=None)
+
+ with patch.object(InitializerService, "__init__", lambda self: None):
+ service = InitializerService()
+ service._registry = MagicMock()
+ service._registry.create_and_configure.return_value = initializer
+ service._memory = MagicMock()
+ service._memory.get_initializer_settings.return_value = [
+ InitializerSetting(initializer_name="target", parameters={"tags": ["saved"]})
+ ]
+
+ result = await service.apply_initializer_async(
+ initializer_name="target",
+ parameters={"tags": ["explicit"]},
+ )
+
+ service._registry.create_and_configure.assert_called_once_with(
+ "target",
+ initializer_params={"tags": ["explicit"]},
+ )
+ initializer.validate.assert_called_once()
+ initializer.initialize_async.assert_awaited_once()
+ assert result == ApplyInitializerResponse(
+ initializer_name="target",
+ status="applied",
+ applied_parameters={"tags": ["explicit"]},
+ )
+
+ async def test_apply_initializer_uses_saved_parameters_when_body_is_empty(self) -> None:
+ initializer = MagicMock()
+ initializer.validate = MagicMock()
+ initializer.initialize_async = AsyncMock(return_value=None)
+
+ with patch.object(InitializerService, "__init__", lambda self: None):
+ service = InitializerService()
+ service._registry = MagicMock()
+ service._registry.create_and_configure.return_value = initializer
+ service._memory = MagicMock()
+ service._memory.get_initializer_settings.return_value = [
+ InitializerSetting(initializer_name="target", parameters={"tags": ["saved"]})
+ ]
+
+ result = await service.apply_initializer_async(initializer_name="target")
+
+ service._registry.create_and_configure.assert_called_once_with(
+ "target",
+ initializer_params={"tags": ["saved"]},
+ )
+ assert result.applied_parameters == {"tags": ["saved"]}
+
+ async def test_apply_initializer_propagates_validation_errors(self) -> None:
+ with patch.object(InitializerService, "__init__", lambda self: None):
+ service = InitializerService()
+ service._registry = MagicMock()
+ service._registry.create_and_configure.side_effect = ValueError("Unknown parameter")
+ service._memory = MagicMock()
+ service._memory.get_initializer_settings.return_value = []
+
+ with pytest.raises(ValueError, match="Unknown parameter"):
+ await service.apply_initializer_async(initializer_name="target")
+
+
# ============================================================================
# Route Tests
# ============================================================================
@@ -291,6 +495,104 @@ def test_get_initializer_returns_404_when_not_found(self, client: TestClient) ->
assert response.status_code == status.HTTP_404_NOT_FOUND
+ def test_get_initializer_settings_returns_200(self, client: TestClient) -> None:
+ with (
+ patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service,
+ patch(
+ "pyrit.backend.routes.initializers.asyncio.to_thread",
+ new=AsyncMock(return_value=[]),
+ ),
+ ):
+ mock_service = MagicMock()
+ mock_service.list_effective_initializer_settings_async = AsyncMock(
+ return_value=ListEffectiveInitializerSettingsResponse(items=[])
+ )
+ mock_get_service.return_value = mock_service
+
+ response = client.get("/api/initializers/settings")
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["items"] == []
+
+ def test_put_initializer_settings_returns_saved_row(self, client: TestClient) -> None:
+ saved_setting = InitializerSetting(
+ initializer_name="target",
+ enabled=False,
+ parameters={"tags": ["saved"]},
+ order_index=2,
+ )
+
+ with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service:
+ mock_service = MagicMock()
+ mock_service.save_initializer_setting_async = AsyncMock(return_value=saved_setting)
+ mock_get_service.return_value = mock_service
+
+ response = client.put(
+ "/api/initializers/target/settings",
+ json={"enabled": False, "parameters": {"tags": ["saved"]}, "order_index": 2},
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["initializer_name"] == "target"
+ mock_service.save_initializer_setting_async.assert_called_once_with(
+ initializer_name="target",
+ enabled=False,
+ parameters={"tags": ["saved"]},
+ order_index=2,
+ )
+
+ def test_put_initializer_settings_returns_404_for_missing_initializer(self, client: TestClient) -> None:
+ with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service:
+ mock_service = MagicMock()
+ mock_service.save_initializer_setting_async = AsyncMock(side_effect=KeyError("missing"))
+ mock_get_service.return_value = mock_service
+
+ response = client.put("/api/initializers/unknown/settings", json={"enabled": True})
+
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+ def test_delete_initializer_settings_returns_204(self, client: TestClient) -> None:
+ with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service:
+ mock_service = MagicMock()
+ mock_service.delete_initializer_setting_async = AsyncMock(return_value=None)
+ mock_get_service.return_value = mock_service
+
+ response = client.delete("/api/initializers/target/settings")
+
+ assert response.status_code == status.HTTP_204_NO_CONTENT
+ mock_service.delete_initializer_setting_async.assert_called_once_with(initializer_name="target")
+
+ def test_post_apply_initializer_returns_200(self, client: TestClient) -> None:
+ apply_result = ApplyInitializerResponse(
+ initializer_name="target",
+ status="applied",
+ applied_parameters={"tags": ["saved"]},
+ )
+
+ with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service:
+ mock_service = MagicMock()
+ mock_service.apply_initializer_async = AsyncMock(return_value=apply_result)
+ mock_get_service.return_value = mock_service
+
+ response = client.post("/api/initializers/target/apply", json={"parameters": {"tags": ["saved"]}})
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["status"] == "applied"
+ mock_service.apply_initializer_async.assert_called_once_with(
+ initializer_name="target",
+ parameters={"tags": ["saved"]},
+ )
+
+ def test_post_apply_initializer_returns_400_for_invalid_parameters(self, client: TestClient) -> None:
+ with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service:
+ mock_service = MagicMock()
+ mock_service.apply_initializer_async = AsyncMock(side_effect=ValueError("bad params"))
+ mock_get_service.return_value = mock_service
+
+ response = client.post("/api/initializers/target/apply", json={"parameters": {"bad": True}})
+
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
+
# ============================================================================
# Service Register/Unregister Tests
diff --git a/tests/unit/memory/test_initializer_setting_memory.py b/tests/unit/memory/test_initializer_setting_memory.py
new file mode 100644
index 0000000000..342abd604f
--- /dev/null
+++ b/tests/unit/memory/test_initializer_setting_memory.py
@@ -0,0 +1,48 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import pytest
+
+from pyrit.memory.memory_models import InitializerSettingEntry
+from pyrit.models import InitializerSetting
+
+
+@pytest.mark.usefixtures("patch_central_database")
+class TestInitializerSettingMemory:
+ def test_initializer_setting_entry_round_trips_real_domain_model(self, sqlite_instance) -> None:
+ setting = InitializerSetting(
+ initializer_name="target",
+ enabled=False,
+ parameters={"tags": ["default"]},
+ order_index=3,
+ )
+
+ sqlite_instance.add_initializer_setting(setting=setting)
+
+ entries = sqlite_instance._query_entries(InitializerSettingEntry)
+
+ assert len(entries) == 1
+ assert entries[0].to_domain_model() == setting
+ assert sqlite_instance.get_initializer_settings() == [setting]
+
+ def test_add_initializer_setting_uses_upsert_semantics(self, sqlite_instance) -> None:
+ sqlite_instance.add_initializer_setting(
+ setting=InitializerSetting(initializer_name="target", enabled=True, order_index=1)
+ )
+
+ sqlite_instance.add_initializer_setting(
+ setting=InitializerSetting(initializer_name="target", enabled=False, order_index=4)
+ )
+
+ assert sqlite_instance.get_initializer_settings() == [
+ InitializerSetting(initializer_name="target", enabled=False, order_index=4)
+ ]
+
+ def test_delete_initializer_setting_is_idempotent(self, sqlite_instance) -> None:
+ sqlite_instance.delete_initializer_setting(initializer_name="target")
+
+ sqlite_instance.add_initializer_setting(setting=InitializerSetting(initializer_name="target", enabled=True))
+ sqlite_instance.delete_initializer_setting(initializer_name="target")
+ sqlite_instance.delete_initializer_setting(initializer_name="target")
+
+ assert sqlite_instance.get_initializer_settings() == []
diff --git a/tests/unit/models/test_initializer_setting.py b/tests/unit/models/test_initializer_setting.py
new file mode 100644
index 0000000000..2cd4c209c2
--- /dev/null
+++ b/tests/unit/models/test_initializer_setting.py
@@ -0,0 +1,20 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import pytest
+from pydantic import ValidationError
+
+from pyrit.models import InitializerSetting
+
+
+def test_initializer_setting_defaults() -> None:
+ setting = InitializerSetting(initializer_name="target")
+
+ assert setting.enabled is True
+ assert setting.parameters is None
+ assert setting.order_index is None
+
+
+def test_initializer_setting_rejects_invalid_registry_name() -> None:
+ with pytest.raises(ValidationError, match="Invalid registry name"):
+ InitializerSetting(initializer_name="Not Valid")