diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index e26f39c078..404546a6b4 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -153,6 +153,17 @@ For `AzureMLChatTarget`, additional fields are available: **Max New Tokens**, ** Targets can also be auto-populated by adding the `target` initializer to your `~/.pyrit/.pyrit_conf` file. This reads endpoints from your `.env` and `.env.local` files. See [.pyrit_conf_example](https://github.com/microsoft/PyRIT/blob/main/.pyrit_conf_example) for details. +### Initializers + +The **Initializers** page (in the left navigation) lets you review and extend how PyRIT sets itself up at startup — for example, the `target` initializer's `tags` and `auto_group` settings. + +The page has two sections: + +- **Baseline initializers** are read-only. They come from your active configuration file (`~/.pyrit/.pyrit_conf`) and run first, in order. +- **Additional initializers** are added in the GUI and saved to the memory database. They run after the baseline, in the order shown. You can add more than one initializer of the same type — each is its own invocation. + +Use **Apply now** to re-run a single initializer immediately against the running backend — handy for picking up an environment or setting change without a restart. Saved additional initializers and `.pyrit_conf` edits otherwise take effect the next time the backend starts. + --- ## Connection Health diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts index 6cdf3bd10b..b4b6073c7d 100644 --- a/frontend/jest.config.ts +++ b/frontend/jest.config.ts @@ -10,6 +10,7 @@ const config: Config = { "\\.(css|less|scss|sass)$": "identity-obj-proxy", }, setupFilesAfterEnv: ["/src/setupTests.ts"], + testTimeout: 15000, collectCoverageFrom: [ "src/**/*.{ts,tsx}", "!src/**/*.d.ts", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae9a572dee..a099e5fd38 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ 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 Initializers from './components/Initializers/Initializers' import AttackHistory from './components/History/AttackHistory' import FeedbackDialog from './components/Feedback/FeedbackDialog' import type { HistoryFilters } from './components/History/historyFilters' @@ -36,6 +37,7 @@ const VIEW_PATHS: Record = { chat: '/chat', history: '/history', config: '/config', + initializers: '/initializers', } /** Resolves the active view from a URL path, defaulting to home for unknown paths. */ @@ -392,6 +394,7 @@ function App() { /> } /> + } /> { await userEvent.click(screen.getByTestId("dialog-close")); expect(screen.queryByTestId("create-dialog")).not.toBeInTheDocument(); }); + }); diff --git a/frontend/src/components/Initializers/AdditionalInitializers.styles.ts b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts new file mode 100644 index 0000000000..c710946ac0 --- /dev/null +++ b/frontend/src/components/Initializers/AdditionalInitializers.styles.ts @@ -0,0 +1,74 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useAdditionalInitializersStyles = 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, + backgroundColor: tokens.colorNeutralBackground1, + }, + 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%', + }, + parametersBlock: { + margin: 0, + marginTop: tokens.spacingVerticalXS, + padding: tokens.spacingVerticalM, + borderRadius: tokens.borderRadiusMedium, + backgroundColor: tokens.colorNeutralBackground3, + overflowX: 'auto', + fontFamily: 'Consolas, "Courier New", monospace', + }, + dialogContent: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, + actionsRow: { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + errorText: { + color: tokens.colorPaletteRedForeground1, + marginTop: tokens.spacingVerticalXS, + }, + envVarText: { + color: tokens.colorNeutralForeground3, + display: 'block', + marginTop: tokens.spacingVerticalXXS, + }, +}) diff --git a/frontend/src/components/Initializers/AdditionalInitializers.test.tsx b/frontend/src/components/Initializers/AdditionalInitializers.test.tsx new file mode 100644 index 0000000000..5ecee9d2df --- /dev/null +++ b/frontend/src/components/Initializers/AdditionalInitializers.test.tsx @@ -0,0 +1,196 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FluentProvider, webLightTheme } from '@fluentui/react-components' + +import type { AdditionalInitializerSetting, RegisteredInitializer } from '@/types' + +import AdditionalInitializers from './AdditionalInitializers' + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +) + +const targetInitializer: RegisteredInitializer = { + 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: null, + choices: null, + is_list: true, + description: 'Target tags.', + }, + ], +} + +const scorerInitializer: RegisteredInitializer = { + initializer_name: 'scorer', + initializer_type: 'ScorerInitializer', + description: 'Registers scorers.', + required_env_vars: [], + supported_parameters: [ + { + name: 'mode', + type_name: 'str', + required: false, + default: null, + choices: null, + is_list: false, + description: 'Scorer mode.', + }, + ], +} + +const sampleItems: AdditionalInitializerSetting[] = [ + { + id: 'additional-1', + initializer_name: 'target', + parameters: { tags: ['default'] }, + order_index: 2, + }, + { + id: 'additional-2', + initializer_name: 'scorer', + parameters: null, + order_index: null, + }, +] + +describe('AdditionalInitializers', () => { + const defaultProps = { + items: sampleItems, + registeredInitializers: [targetInitializer, scorerInitializer], + creating: false, + onAdd: jest.fn().mockResolvedValue(true), + onSave: jest.fn().mockResolvedValue(undefined), + onApply: jest.fn().mockResolvedValue(undefined), + onRemove: jest.fn().mockResolvedValue(undefined), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should render additional initializer rows and metadata', () => { + render( + + + , + ) + + expect(screen.getByRole('list', { name: 'Additional initializers' })).toBeInTheDocument() + expect(screen.getByTestId('initializer-row-additional-1')).toHaveTextContent('target') + expect(screen.getByText('Required env vars: AZURE_OPENAI_ENDPOINT')).toBeInTheDocument() + expect(screen.getByText('tags (list[str], optional)')).toBeInTheDocument() + }) + + it('should show the saved parameters read-only without an inline editor', () => { + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-additional-1') + expect(within(row).getByText(/"tags"/)).toBeInTheDocument() + expect(within(row).queryByRole('textbox', { name: 'Parameters JSON' })).not.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(within(screen.getByTestId('initializer-row-additional-1')).getByText('target')) + + expect(await screen.findByRole('tooltip')).toHaveTextContent('Registers targets.') + }) + + it('should call onSave from the edit dialog, preserving the existing order_index', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-additional-1') + fireEvent.click(within(row).getByRole('button', { name: 'Edit' })) + + const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) + await within(dialog).findByText('Edit target initializer') + const editor = within(dialog).getByRole('textbox', { name: 'Parameters JSON', hidden: true }) + fireEvent.change(editor, { target: { value: '{"tags":["extra"]}' } }) + await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) + + expect(defaultProps.onSave).toHaveBeenCalledWith('additional-1', { + parameters: { tags: ['extra'] }, + order_index: 2, + }) + }) + + it('should call onApply with the saved parameters', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-additional-1') + await user.click(within(row).getByRole('button', { name: 'Apply now' })) + + expect(defaultProps.onApply).toHaveBeenCalledWith('additional-1', 'target', { tags: ['default'] }) + }) + + it('should call onRemove with the additional initializer id', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + await user.click(within(screen.getByTestId('initializer-row-additional-1')).getByRole('button', { name: 'Remove' })) + + expect(defaultProps.onRemove).toHaveBeenCalledWith('additional-1') + }) + + it('should show a validation error for invalid JSON in the edit dialog', async () => { + const user = userEvent.setup() + + render( + + + , + ) + + const row = screen.getByTestId('initializer-row-additional-1') + fireEvent.click(within(row).getByRole('button', { name: 'Edit' })) + + const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 }) + await within(dialog).findByText('Edit target initializer') + const editor = within(dialog).getByRole('textbox', { name: 'Parameters JSON', hidden: true }) + fireEvent.change(editor, { target: { value: '{"tags":' } }) + await user.click(await within(dialog).findByRole('button', { name: 'Save', hidden: true })) + + expect(await within(dialog).findByRole('alert', { hidden: true })).toHaveTextContent( + 'Unexpected end of JSON input', + ) + expect(defaultProps.onSave).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/Initializers/AdditionalInitializers.tsx b/frontend/src/components/Initializers/AdditionalInitializers.tsx new file mode 100644 index 0000000000..67b5805f01 --- /dev/null +++ b/frontend/src/components/Initializers/AdditionalInitializers.tsx @@ -0,0 +1,219 @@ +import { useState } from 'react' + +import { Button, Select, Text, Tooltip } from '@fluentui/react-components' +import { AddRegular } from '@fluentui/react-icons' + +import type { + AdditionalInitializerSetting, + RegisteredInitializer, + UpdateAdditionalInitializerRequest, +} from '@/types' + +import { useAdditionalInitializersStyles } from './AdditionalInitializers.styles' +import { formatInitializerParameters, formatSupportedParameterSummary } from './initializerFormatting' +import { resolveRegisteredInitializer } from './initializerLookup' +import InitializerParametersDialog from './InitializerParametersDialog' +import { useInitializersStyles } from './Initializers.styles' + +interface AdditionalInitializersProps { + items: AdditionalInitializerSetting[] + registeredInitializers: RegisteredInitializer[] + creating: boolean + savingInitializerId?: string | null + applyingInitializerId?: string | null + deletingInitializerId?: string | null + onAdd: (initializerName: string, parameters: Record | null) => Promise + onSave: (id: string, request: UpdateAdditionalInitializerRequest) => Promise + onApply: (id: string, initializerName: string, parameters?: Record | null) => Promise + onRemove: (id: string) => Promise +} + +interface AdditionalInitializerCardProps { + item: AdditionalInitializerSetting + initializer: RegisteredInitializer + isSaving: boolean + isApplying: boolean + isDeleting: boolean + onSave: (id: string, request: UpdateAdditionalInitializerRequest) => Promise + onApply: (id: string, initializerName: string, parameters?: Record | null) => Promise + onRemove: (id: string) => Promise +} + +function AdditionalInitializerCard({ + item, + initializer, + isSaving, + isApplying, + isDeleting, + onSave, + onApply, + onRemove, +}: AdditionalInitializerCardProps) { + const styles = useAdditionalInitializersStyles() + const [editOpen, setEditOpen] = useState(false) + const isBusy = isSaving || isApplying || isDeleting + + const handleEditSubmit = async (parameters: Record | null): Promise => { + await onSave(item.id, { parameters, order_index: item.order_index ?? null }) + setEditOpen(false) + } + + return ( +
+
+
+ + {item.initializer_name} + + {initializer.required_env_vars.length > 0 && ( + + Required env vars: {initializer.required_env_vars.join(', ')} + + )} +
+
+ +
+ {formatSupportedParameterSummary(initializer).map((summary: string) => ( + + {summary} + + ))} +
+ +
+ Parameters +
{formatInitializerParameters(item.parameters)}
+
+ +
+ + + +
+ + {editOpen && ( + + )} +
+ ) +} + +export default function AdditionalInitializers({ + items, + registeredInitializers, + creating, + savingInitializerId = null, + applyingInitializerId = null, + deletingInitializerId = null, + onAdd, + onSave, + onApply, + onRemove, +}: AdditionalInitializersProps) { + const pageStyles = useInitializersStyles() + const listStyles = useAdditionalInitializersStyles() + const [selectedInitializerName, setSelectedInitializerName] = useState('') + const [addDialogOpen, setAddDialogOpen] = useState(false) + const initializerName = selectedInitializerName || registeredInitializers[0]?.initializer_name || '' + const selectedInitializer = registeredInitializers.find( + (initializer) => initializer.initializer_name === initializerName, + ) ?? null + + const handleAdd = async (parameters: Record | null): Promise => { + if (!initializerName) { + return + } + const added = await onAdd(initializerName, parameters) + if (added) { + setAddDialogOpen(false) + } + } + + return ( +
+
+ + Additional initializers + + + Add and edit initializer invocations that run after the baseline. + +
+ +
+ + +
+ + {items.length === 0 ? ( + No additional initializers are configured. + ) : ( +
+ {items.map((item: AdditionalInitializerSetting) => ( + + ))} +
+ )} + + {addDialogOpen && ( + + )} +
+ ) +} diff --git a/frontend/src/components/Initializers/AvailableInitializersDialog.tsx b/frontend/src/components/Initializers/AvailableInitializersDialog.tsx new file mode 100644 index 0000000000..82255129a9 --- /dev/null +++ b/frontend/src/components/Initializers/AvailableInitializersDialog.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react' + +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + DialogTrigger, + Text, +} from '@fluentui/react-components' +import { AppsListRegular } from '@fluentui/react-icons' + +import type { RegisteredInitializer } from '@/types' + +import { formatSupportedParameterSummary } from './initializerFormatting' +import { useInitializersStyles } from './Initializers.styles' + +interface AvailableInitializersDialogProps { + registeredInitializers: RegisteredInitializer[] + disabled?: boolean +} + +export default function AvailableInitializersDialog({ + registeredInitializers, + disabled = false, +}: AvailableInitializersDialogProps) { + const styles = useInitializersStyles() + const [open, setOpen] = useState(false) + + return ( + setOpen(data.open)}> + + + + + + Available initializers + + + Every initializer registered with PyRIT. This is a read-only reference of what exists and the + parameters each one accepts. + + {registeredInitializers.length === 0 ? ( + No registered initializers were found. + ) : ( +
+ {registeredInitializers.map((initializer: RegisteredInitializer) => ( +
+
+ {initializer.initializer_name} + {initializer.description || 'No description available.'} + + Required env vars: {initializer.required_env_vars.length > 0 + ? initializer.required_env_vars.join(', ') + : 'None'} + +
+
+ Parameters +
+ {formatSupportedParameterSummary(initializer).map((summary: string) => ( + + {summary} + + ))} +
+
+
+ ))} +
+ )} +
+ + + + + +
+
+
+ ) +} diff --git a/frontend/src/components/Initializers/BaselineInitializers.tsx b/frontend/src/components/Initializers/BaselineInitializers.tsx new file mode 100644 index 0000000000..46910182ff --- /dev/null +++ b/frontend/src/components/Initializers/BaselineInitializers.tsx @@ -0,0 +1,66 @@ +import { Text } from '@fluentui/react-components' + +import type { BaselineInitializerSetting, RegisteredInitializer } from '@/types' + +import { formatInitializerParameters } from './initializerFormatting' +import { resolveRegisteredInitializer } from './initializerLookup' +import { useInitializersStyles } from './Initializers.styles' + +interface BaselineInitializersProps { + items: BaselineInitializerSetting[] + registeredInitializers: RegisteredInitializer[] +} + +export default function BaselineInitializers({ + items, + registeredInitializers, +}: BaselineInitializersProps) { + const styles = useInitializersStyles() + + return ( +
+
+ + Baseline initializers + + + Read-only initializers from the .pyrit_conf baseline. + +
+ {items.length === 0 ? ( + No baseline initializers are configured. + ) : ( +
+ {items.map((item: BaselineInitializerSetting) => { + const initializer = resolveRegisteredInitializer(item.initializer_name, registeredInitializers) + return ( +
+
+
+ {item.initializer_name} + {initializer.description || 'No description available.'} + + Required env vars: {initializer.required_env_vars.length > 0 + ? initializer.required_env_vars.join(', ') + : 'None'} + + Order: {item.order_index} +
+
+
+ Parameters +
{formatInitializerParameters(item.parameters)}
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/components/Initializers/InitializerParametersDialog.tsx b/frontend/src/components/Initializers/InitializerParametersDialog.tsx new file mode 100644 index 0000000000..6dde3b9d27 --- /dev/null +++ b/frontend/src/components/Initializers/InitializerParametersDialog.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react' +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + Field, + Text, + Textarea, +} from '@fluentui/react-components' + +import type { RegisteredInitializer } from '@/types' + +import { useAdditionalInitializersStyles } from './AdditionalInitializers.styles' +import { formatSupportedParameterSummary } from './initializerFormatting' + +interface InitializerParametersDialogProps { + open: boolean + mode: 'add' | 'edit' + initializer: RegisteredInitializer | null + initialParameters?: Record | null + submitting?: boolean + onSubmit: (parameters: Record | null) => void | Promise + onOpenChange: (open: boolean) => void +} + +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 +} + +export default function InitializerParametersDialog({ + open, + mode, + initializer, + initialParameters = null, + submitting = false, + onSubmit, + onOpenChange, +}: InitializerParametersDialogProps) { + const styles = useAdditionalInitializersStyles() + const [parametersText, setParametersText] = useState(() => serializeParameters(initialParameters)) + const [error, setError] = useState(null) + + const handleSubmit = async (): Promise => { + let parameters: Record | null + try { + parameters = parseParametersText(parametersText) + } catch (parseError) { + setError(parseError instanceof Error ? parseError.message : 'Invalid initializer settings.') + return + } + + setError(null) + await onSubmit(parameters) + } + + const initializerName = initializer?.initializer_name ?? '' + const title = mode === 'add' ? `Add ${initializerName} initializer` : `Edit ${initializerName} initializer` + const submitLabel = mode === 'add' ? 'Add' : 'Save' + + return ( + onOpenChange(data.open)}> + + + {title} + + {initializer && ( + <> + {initializer.description || 'No description available.'} + {initializer.required_env_vars.length > 0 && ( + + Required env vars: {initializer.required_env_vars.join(', ')} + + )} +
+ {formatSupportedParameterSummary(initializer).map((summary: string) => ( + + {summary} + + ))} +
+ + )} + +