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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,33 @@ 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.

### Initializer Configuration

The Configuration view also includes an **Initializers** settings dialog for reviewing and tuning the initializers that configure PyRIT's default converters, scorers, and targets.

#### Effective Initializer List

The dialog shows an *effective* list that merges two sources:

1. **Baseline** — the `initializers:` entries resolved from your active configuration file (`~/.pyrit/.pyrit_conf`, or the file named by `PYRIT_CONFIG_FILE`). This is the same baseline the backend was started with.
2. **Saved overrides** — per-initializer settings you edit in the GUI, which are persisted to the memory database (Central Memory).

For each initializer, the dialog reports where its effective values come from via a `source` field:

| `source` | Meaning |
| --- | --- |
| `baseline` | Value comes only from your config file; no GUI override saved |
| `baseline+override` | Initializer is in your config file **and** has a saved GUI override |
| `override` | Initializer is not in your config file but has a saved GUI override |

Scanner-only initializers (`scorer`, `technique`, `load_default_datasets`, `preload_scenario_metadata`) are hidden from this list because they have no visible effect in the GUI today.

#### How Overrides Resolve Against `.pyrit_conf`

When a saved GUI override and the `.pyrit_conf` baseline both specify a value for the same initializer, **the saved override wins**. Overrides can change an initializer's parameters, whether it is `enabled`, and its `order_index`. When an override leaves a field unset, the effective value falls back to the baseline from `.pyrit_conf`. This follows the same precedence philosophy as the rest of PyRIT's configuration — the more specific, user-set layer takes precedence over the config-file baseline, with no error on conflict.

> **Note:** Saved overrides do **not** change how the backend initializes at startup. When the backend process boots, it still runs the initializers from `.pyrit_conf` only — the saved override rows are read for the GUI's effective list and for on-demand actions, not replayed during startup initialization. To make an initializer's saved (or one-time) settings take effect in the running process, use the dialog's **Apply** action, which builds, validates, and runs that single initializer immediately. Editing `.pyrit_conf`, by contrast, takes effect on the next backend restart.

---

## Connection Health
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -386,7 +386,7 @@ function App() {
<Route
path="/config"
element={
<TargetConfig
<ConfigPage
activeTarget={activeTarget}
onSetActiveTarget={handleSetActiveTarget}
/>
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/components/Config/ConfigPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => <div data-testid="target-config-panel" />
MockTargetConfig.displayName = 'MockTargetConfig'
return {
__esModule: true,
default: MockTargetConfig,
}
})

const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<FluentProvider theme={webLightTheme}>{children}</FluentProvider>
)

describe('ConfigPage', () => {
it('renders the target configuration panel', () => {
render(
<TestWrapper>
<ConfigPage activeTarget={null} onSetActiveTarget={jest.fn()} />
</TestWrapper>,
)

expect(screen.getByTestId('target-config-panel')).toBeInTheDocument()
})
})

12 changes: 12 additions & 0 deletions frontend/src/components/Config/ConfigPage.tsx
Original file line number Diff line number Diff line change
@@ -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 <TargetConfig activeTarget={activeTarget} onSetActiveTarget={onSetActiveTarget} />
}

38 changes: 38 additions & 0 deletions frontend/src/components/Config/InitializerConfig.styles.ts
Original file line number Diff line number Diff line change
@@ -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%',
},
})
191 changes: 191 additions & 0 deletions frontend/src/components/Config/InitializerConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
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: { parameters?: Record<string, unknown> | null }) => Promise<void>
onApply: (initializerName: string, parameters?: Record<string, unknown> | null) => Promise<void>
onReset: (initializerName: string) => Promise<void>
}) => (
<div data-testid="initializer-table">
<span data-testid="initializer-count">{items.length}</span>
<button onClick={() => void onSave('target', { parameters: null })}>Save target</button>
<button onClick={() => void onApply('target', { tags: ['extra'] })}>Apply target</button>
<button onClick={() => void onReset('target')}>Reset target</button>
</div>
)
MockInitializerList.displayName = 'MockInitializerList'
return {
__esModule: true,
default: MockInitializerList,
}
})

const mockedInitializersApi = initializersApi as jest.Mocked<typeof initializersApi>

const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<FluentProvider theme={webLightTheme}>{children}</FluentProvider>
)

const SAMPLE_RESPONSE = {
items: [
{
initializer_name: 'target',
initializer_type: 'TargetInitializer',
description: 'Registers targets.',
required_env_vars: [],
supported_parameters: [],
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',
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(
<TestWrapper>
<InitializerConfig />
</TestWrapper>,
)

expect(screen.getByText('Loading initializer settings...')).toBeInTheDocument()
})

it('should render fetched initializer settings', async () => {
render(
<TestWrapper>
<InitializerConfig />
</TestWrapper>,
)

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(
<TestWrapper>
<InitializerConfig />
</TestWrapper>,
)

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(
<TestWrapper>
<InitializerConfig />
</TestWrapper>,
)

await waitFor(() => {
expect(screen.getByTestId('initializer-table')).toBeInTheDocument()
})

await user.click(screen.getByRole('button', { name: 'Save target' }))

await waitFor(() => {
expect(mockedInitializersApi.updateSettings).toHaveBeenCalledWith('target', { parameters: null })
expect(screen.getByText('Saved settings for target.')).toBeInTheDocument()
})
})

it('should apply an initializer and show success feedback', async () => {
const user = userEvent.setup()

render(
<TestWrapper>
<InitializerConfig />
</TestWrapper>,
)

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(
<TestWrapper>
<InitializerConfig />
</TestWrapper>,
)

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()
})
})
})

Loading
Loading