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
11 changes: 11 additions & 0 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const config: Config = {
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
},
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
testTimeout: 15000,
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts",
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -36,6 +37,7 @@ const VIEW_PATHS: Record<ViewName, string> = {
chat: '/chat',
history: '/history',
config: '/config',
initializers: '/initializers',
}

/** Resolves the active view from a URL path, defaulting to home for unknown paths. */
Expand Down Expand Up @@ -392,6 +394,7 @@ function App() {
/>
}
/>
<Route path="/initializers" element={<Initializers />} />
<Route
path="/history"
element={
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Config/TargetConfig.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,4 +403,5 @@ describe("TargetConfig", () => {
await userEvent.click(screen.getByTestId("dialog-close"));
expect(screen.queryByTestId("create-dialog")).not.toBeInTheDocument();
});

});
Original file line number Diff line number Diff line change
@@ -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,
},
})
196 changes: 196 additions & 0 deletions frontend/src/components/Initializers/AdditionalInitializers.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<FluentProvider theme={webLightTheme}>{children}</FluentProvider>
)

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(
<TestWrapper>
<AdditionalInitializers {...defaultProps} />
</TestWrapper>,
)

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(
<TestWrapper>
<AdditionalInitializers {...defaultProps} />
</TestWrapper>,
)

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(
<TestWrapper>
<AdditionalInitializers {...defaultProps} />
</TestWrapper>,
)

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(
<TestWrapper>
<AdditionalInitializers {...defaultProps} />
</TestWrapper>,
)

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(
<TestWrapper>
<AdditionalInitializers {...defaultProps} />
</TestWrapper>,
)

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(
<TestWrapper>
<AdditionalInitializers {...defaultProps} />
</TestWrapper>,
)

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(
<TestWrapper>
<AdditionalInitializers {...defaultProps} />
</TestWrapper>,
)

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