Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3471807
Add runtime technique registry and GUI
richlundeen Oct 7, 2026
bb9daa0
Simplify technique creation to registry references
richlundeen Oct 7, 2026
2a3a7c5
Keep technique REST validation out of the shared resolver
richlundeen Oct 7, 2026
21c1b44
Remove unused technique UI and cache code
richlundeen Oct 7, 2026
9f82038
Prefer scenario-local technique factories in catalogs
richlundeen Oct 7, 2026
8a49f0c
Keep scenario selection cache in the technique registry
richlundeen Oct 8, 2026
8e69296
Show technique factory identifiers without null display settings
richlundeen Oct 8, 2026
8dce2b5
Show supplied technique factory creation calls
richlundeen Oct 8, 2026
52e5be2
Simplify technique registry forms and match target styling
richlundeen Oct 8, 2026
25d61a0
Paginate technique registry lists
richlundeen Oct 8, 2026
9c65629
Fix technique selector admission and form capabilities
richlundeen Oct 8, 2026
d7bdcf2
Merge current main into techniques registry UI
richlundeen Oct 8, 2026
4a2c39e
Merge remote-tracking branch 'origin/main' into richlundeen-technique…
richlundeen Oct 8, 2026
a070ad1
Fix lazy technique export tests for registry-owned caching
richlundeen Oct 9, 2026
2166956
Merge origin main into techniques registry UI
richlundeen Oct 10, 2026
33c9d7f
Fix runtime technique admission and selection snapshots
richlundeen Oct 10, 2026
ebebb3e
Merge origin main into techniques registry UI
richlundeen Oct 10, 2026
77926ca
Fix enum list resolution and deferred constructor defaults
richlundeen Oct 10, 2026
1c7142c
Fix null validation for constrained registry inputs
richlundeen Oct 10, 2026
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
4 changes: 2 additions & 2 deletions doc/code/registry/0_registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,8 @@ attack = AttackRegistry.get_registry_singleton().create_instance(
Simple scalar inputs use the shared resolver. Pass live Python configuration
objects, such as `AttackAdversarialConfig`, `AttackConverterConfig`, and
`AttackScoringConfig`, for nested components. Advanced Python values, such as a
prompt normalizer or a parameter class, pass through unchanged. Nested JSON
attack recipes are not supported.
prompt normalizer or a parameter class, pass through unchanged. The shared
resolver also constructs explicitly declared structured configuration variants.

An attack class implements the conversation algorithm. An attack technique
factory selects and configures that class, converters, scorers, and seeds.
Expand Down
17 changes: 16 additions & 1 deletion doc/gui/0_gui.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# PyRIT GUI (CoPyRIT)

CoPyRIT is a web-based graphical interface for PyRIT built with React and Fluent UI. It provides an interactive way to run attacks, configure targets and converters, and view results — all from a browser.
CoPyRIT is a web-based graphical interface for PyRIT built with React and Fluent UI. It provides an interactive way to run attacks, configure targets, converters, and techniques, and view results — all from a browser.

## Getting Started

Expand Down Expand Up @@ -469,6 +469,21 @@ In active runs and saved scenario results, **Atomic attack groups** defaults to

Until you expand or collapse the section, its default follows the current group count as progress loads. Once you choose, the section keeps your choice during progress updates for the same run, even if the count crosses 20. Opening a different run resets to that run's count-based default.

### Technique Registry

Open **Registry > Techniques**, or go to `/registry/techniques`. Targets and
Converters keep their existing registry URLs.

Search or filter registered techniques, then select **Details** to inspect one.
Select **New technique** to configure an existing attack with basic settings,
ordered converters, and optional adversarial prompts. The objective target is
selected when you run a scenario. Seeds and conversation settings need a Python
initializer. Creation does not run an attack.

New techniques are available in compatible scenarios and are lost on restart or
reinitialization. The pane does not edit or delete techniques.
See the [backend README](../../pyrit/backend/README.md#techniques) for the API.

### Target Configuration

The Configuration view manages the targets available for attacks.
Expand Down
146 changes: 145 additions & 1 deletion frontend/e2e/registry.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,149 @@
import { expect, test, type Page } from "./_fixtures";
import { mockVersion } from "./_compatibility";
import { compatibilityHeaders, mockVersion } from "./_compatibility";

test("creates and selects a runtime technique with the real backend @seeded", async ({ page, request }) => {
test.setTimeout(120_000);
const headers = compatibilityHeaders();
for (const name of ["objective_scorer_chat", "adversarial_chat", "registry_local_objective"]) {
const existing = await request.get(`/api/targets/${name}`, { headers });
if (existing.status() === 404) {
const created = await request.post("/api/targets", { headers, data: name === "registry_local_objective"
? { name, type: "TextTarget", params: {} }
: { name, type: "OpenAIChatTarget", auth_mode: "api_key", params: {
endpoint: "http://127.0.0.1:9/v1", model_name: "local-test", api_key: "local-test-only",
} } });
expect(created.ok(), await created.text()).toBe(true);
} else {
expect(existing.ok(), await existing.text()).toBe(true);
}
}
const warm = await request.get("/api/scenarios/catalog/airt.rapid_response", { headers });
expect(warm.ok(), await warm.text()).toBe(true);
const before = await warm.json();
const name = `browser_${Date.now()}`;
expect(before.all_techniques).not.toContain(name);
const converterName = `${name}_b64`;
const converter = await request.post("/api/converters", {
headers, data: { name: converterName, type: "Base64Converter", params: {} },
});
expect(converter.ok(), await converter.text()).toBe(true);
let replacementGeneration: string | undefined;
await page.route("**/api/runtime", async (route) => {
const response = await route.fetch();
const readiness = await response.json();
await route.fulfill({ json: { ...readiness, generation: replacementGeneration ?? readiness.generation } });
});

const promptRequests: string[] = [];
const detailRequests: string[] = [];
page.on("request", (outgoing) => {
if (outgoing.method() === "POST" && /\/api\/(?:message-sends|attacks\/[^/]+\/messages|scenarios\/runs)(?:\/|\?|$)/.test(outgoing.url())) {
promptRequests.push(outgoing.url());
}
if (outgoing.method() === "GET" && new URL(outgoing.url()).pathname === `/api/techniques/${name}`) {
detailRequests.push(outgoing.url());
}
});
await page.goto("/registry/techniques");
await expect(page.getByRole("tab", { name: "Techniques" })).toHaveAttribute("aria-selected", "true");
await expect(page.getByText("Named configurations of existing attack techniques", { exact: true })).toBeVisible();
await expect(page.getByText(/Runtime only|No attack runs when you add a technique/)).toHaveCount(0);
await expect(page.getByRole("table", { name: "Registered techniques" })).toBeVisible();
const builtinCalls = {
crescendo_simulated: "AttackTechniqueFactory.with_simulated_conversation(\n" +
" name='crescendo_simulated',\n" +
" description='Escalates gradually over a simulated conversation toward the objective.',\n" +
" technique_tags=['single_turn'],\n)",
tap: "AttackTechniqueFactory(\n" +
" name='tap',\n" +
" attack_class=TreeOfAttacksWithPruningAttack,\n" +
" description='Explores a tree of adversarial prompts, pruning weak branches to refine the attack.',\n" +
" technique_tags=['multi_turn'],\n)",
};
for (const [builtinName, statement] of Object.entries(builtinCalls)) {
await page.getByRole("button", { name: `Details for ${builtinName}` }).click();
await expect(page.getByLabel("Technique creation call", { exact: true })).toHaveText(statement);
await expect(page.getByRole("heading", { name: "Configuration" })).toHaveCount(0);
await page.getByRole("button", { name: "Close", exact: true }).click();
}
await page.getByRole("button", { name: "New technique" }).click();
await page.getByRole("textbox", { name: "Registry name" }).fill(name);
await page.getByRole("textbox", { name: "Description" }).fill("Local browser technique");
await page.getByRole("textbox", { name: "Tags" }).fill("browser_test");
await page.getByRole("combobox", { name: "Attack type", exact: true }).selectOption("PromptSendingAttack");
await expect(page.getByRole("dialog")).not.toContainText(/Implementation of|This form shows|Runtime only|prompt_normalizer/);
await page.getByLabel("max_attempts_on_failure").fill("0");
await page.getByRole("combobox", { name: "Request converters", exact: true }).selectOption(converterName);
await page.getByRole("button", { name: "Add to Request converters", exact: true }).click();
await page.getByRole("button", { name: "Add to Request converters", exact: true }).click();
const create = page.waitForResponse((response) => response.url().endsWith("/api/techniques") && response.request().method() === "POST");
await page.getByRole("button", { name: "Add technique" }).click();
const created = await create;
expect(created.status()).toBe(201);
const createdTechnique = await created.json();
const body = created.request().postDataJSON();
expect(body).toMatchObject({
name, type: "PromptSendingAttack", params: { max_attempts_on_failure: 0 },
request_converters: [converterName, converterName], response_converters: [],
});
expect(body).not.toHaveProperty("factory_options");
expect(body).not.toHaveProperty("seed_technique");
await expect(page.getByRole("dialog")).not.toBeVisible();
await page.getByRole("textbox", { name: "Search techniques" }).fill(name);
await expect(page.getByRole("cell", { name, exact: true })).toBeVisible();
await page.getByRole("button", { name: `Details for ${name}` }).click();
const creationCall = page.getByLabel("Technique creation call", { exact: true });
await expect(creationCall).toHaveText(createdTechnique.creation_statement);
await expect(creationCall).toContainText("AttackTechniqueFactory(");
await expect(creationCall).toContainText("'max_attempts_on_failure': 0");
expect(createdTechnique).not.toHaveProperty("evaluation_identifier");
expect(createdTechnique).not.toHaveProperty("configuration");
await expect(page.getByRole("heading", { name: "Configuration" })).toHaveCount(0);
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByRole("button", { name: `Details for ${name}` })).toBeFocused();
expect(detailRequests).toEqual([]);

let finishCreate: (() => void) | undefined;
const pendingCreate = new Promise<void>((resolve) => { finishCreate = resolve; });
let markCreateStarted: (() => void) | undefined;
const createStarted = new Promise<void>((resolve) => { markCreateStarted = resolve; });
const original = await request.get(`/api/techniques/${name}`, { headers });
const originalTechnique = await original.json();
await page.route("**/api/techniques", async (route) => {
if (route.request().method() !== "POST") {
await route.continue();
return;
}
markCreateStarted?.();
await pendingCreate;
await route.fulfill({ status: 201, json: { ...originalTechnique, name: "old_runtime_response" } });
});
await page.getByRole("button", { name: "New technique" }).click();
await page.getByRole("textbox", { name: "Registry name" }).fill("old_runtime_response");
await page.getByRole("combobox", { name: "Attack type", exact: true }).selectOption("PromptSendingAttack");
await page.getByRole("button", { name: "Add technique" }).click();
await createStarted;
replacementGeneration = "registry-replacement-test";
await expect(page.getByRole("dialog")).not.toBeVisible();
await expect(page.getByRole("button", { name: "New technique" })).toBeFocused();
await expect(page.getByRole("table", { name: "Registered techniques" })).toBeVisible();
const staleResponse = page.waitForResponse((response) => response.url().endsWith("/api/techniques") && response.request().method() === "POST");
finishCreate?.();
await staleResponse;
await expect(page.getByRole("cell", { name: "old_runtime_response", exact: true })).toHaveCount(0);

const current = await request.get("/api/scenarios/catalog/airt.rapid_response", { headers });
expect(current.ok(), await current.text()).toBe(true);
const after = await current.json();
expect(after.all_techniques).toContain(name);
expect(after.default_techniques).toEqual(before.default_techniques);
await page.goto("/scanner/airt.rapid_response");
const checkbox = page.getByRole("checkbox", { name, exact: true });
await expect(checkbox).toBeVisible();
await checkbox.check();
await expect(checkbox).toBeChecked();
expect(promptRequests).toEqual([]);
});

interface RegisteredConverter {
converter_id: string;
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import AttackNotFound from './components/Chat/AttackNotFound'
import Home from './components/Home/Home'
import TargetConfig from './components/Config/TargetConfig'
import ConverterRegistry from './components/Registry/ConverterRegistry'
import TechniqueRegistry from './components/Registry/TechniqueRegistry'
import RegistryLayout from './components/Registry/RegistryLayout'
import Configuration from './components/Configuration/Configuration'
import AttackHistory from './components/History/AttackHistory'
Expand Down Expand Up @@ -730,6 +731,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) {
}
/>
<Route path="converters" element={<ConverterRegistry />} />
<Route path="techniques" element={<TechniqueRegistry />} />
</Route>
<Route path="/targets" element={<Navigate to="/registry/targets" replace />} />
<Route path="/scanner" element={<ScenarioCatalog />} />
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/Parameters/ReferenceField.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { makeStyles, tokens } from '@fluentui/react-components'

import { mobileTouchTargetHeight } from '@/styles/touchTargets'

export const useReferenceFieldStyles = makeStyles({
root: { display: 'flex', flexDirection: 'column', gap: tokens.spacingVerticalS },
row: { display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: tokens.spacingHorizontalS },
action: { ...mobileTouchTargetHeight },
})
83 changes: 83 additions & 0 deletions frontend/src/components/Parameters/ReferenceField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useId, useState } from 'react'

import { Button, Field, Select, Text } from '@fluentui/react-components'

import type { RegistryReferenceOption } from '@/types'

import { useReferenceFieldStyles } from './ReferenceField.styles'

interface ReferenceFieldProps {
label: string
options: RegistryReferenceOption[]
value: string | string[]
multiple?: boolean
disabled?: boolean
hint?: string
onChange: (value: string | string[]) => void
}

/** Ordered registry references. Repeated entries are intentional. */
export default function ReferenceField({
label, options, value, multiple = false, disabled = false, hint, onChange,
}: ReferenceFieldProps) {
const styles = useReferenceFieldStyles()
const [pending, setPending] = useState('')
const [entryIds, setEntryIds] = useState<string[]>([])
const id = useId()
const [nextId, setNextId] = useState(0)
const selected = Array.isArray(value) ? value : []

const move = (index: number, delta: number): void => {
const next = [...selected]
const ids = selected.map((_, position) => entryIds[position] ?? `${id}-initial-${position}`)
;[next[index], next[index + delta]] = [next[index + delta], next[index]]
;[ids[index], ids[index + delta]] = [ids[index + delta], ids[index]]
setEntryIds(ids)
onChange(next)
}

return (
<div className={styles.root}>
<Field label={label} hint={hint}>
<Select
value={multiple ? pending : typeof value === 'string' ? value : ''}
disabled={disabled}
onChange={(_, data) => multiple ? setPending(data.value) : onChange(data.value)}
>
<option value="">{multiple ? 'Select an instance to add' : 'Use default / not set'}</option>
{options.map((option) => (
<option key={option.name} value={option.name}>{option.name} ({option.type})</option>
))}
</Select>
</Field>
{multiple && (
<>
<Button
className={styles.action}
disabled={disabled || !pending}
onClick={() => {
setEntryIds([...selected.map((_, index) => entryIds[index] ?? `${id}-initial-${index}`), `${id}-${nextId}`])
setNextId(nextId + 1)
onChange([...selected, pending])
}}
>Add to {label}</Button>
{selected.map((name, index) => (
<div className={styles.row} key={entryIds[index] ?? `${id}-initial-${index}`}>
<Text>{index + 1}. {name}</Text>
<Button className={styles.action} aria-label={`Move ${label} ${index + 1} up`}
disabled={disabled || index === 0} onClick={() => move(index, -1)}>Up</Button>
<Button className={styles.action} aria-label={`Move ${label} ${index + 1} down`}
disabled={disabled || index === selected.length - 1} onClick={() => move(index, 1)}>Down</Button>
<Button className={styles.action} aria-label={`Remove ${label} ${index + 1}`} disabled={disabled}
onClick={() => {
setEntryIds(selected.map((_, position) => entryIds[position] ?? `${id}-initial-${position}`)
.filter((_, position) => position !== index))
onChange(selected.filter((_, position) => position !== index))
}}>Remove</Button>
</div>
))}
</>
)}
</div>
)
}
19 changes: 19 additions & 0 deletions frontend/src/components/Registry/CreateTechniqueDialog.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { makeStyles, tokens } from '@fluentui/react-components'

import { mobileTouchTargetHeight, NARROW_VIEWPORT_QUERY } from '@/styles/touchTargets'

export const useCreateTechniqueDialogStyles = makeStyles({
surface: {
width: '100%', minWidth: 0, maxWidth: '37.5rem', maxHeight: '90vh',
[NARROW_VIEWPORT_QUERY]: {
maxWidth: `calc(100vw - ${tokens.spacingHorizontalXXL} - ${tokens.spacingHorizontalXXL})`,
},
},
content: { minWidth: 0, overflowY: 'auto', maxHeight: '65vh' },
form: {
display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0,
maxWidth: '100%', gap: tokens.spacingVerticalL,
},
select: { width: '100%', minWidth: 0, '& select': { width: '100%', minWidth: 0 } },
action: { ...mobileTouchTargetHeight },
})
Loading
Loading