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
69 changes: 66 additions & 3 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ To deploy an isolated instance for an external team, see [Deploy a New Instance]

## Views

CoPyRIT has three main views, accessible from the left sidebar: **Chat**, **Attack History**, and **Target Configuration**. The **Theme** menu is available at the bottom of the sidebar.
Use the left sidebar to switch between views, including **Chat**, **Attack History**, **Operations**, and **Target Configuration**. The **Theme** menu is available at the bottom of the sidebar.

### Themes

Expand Down Expand Up @@ -369,11 +369,11 @@ Export stays available for read-only historical conversations, and is disabled w

#### Labels

The labels bar above the page content is available across the GUI, including scanner setup, Home, Chat, and History. It shows the active labels for future attacks and scans, not the attribution of a historical run you are viewing. Click the labels icon to open **Default Labels** and add, edit, or remove custom labels. The required `operator` and `operation` controls remain in the bar, outside this popover, and cannot be removed. A signed-in operator is read-only.
The labels bar above the page content is available across the GUI, including scanner setup, Home, Chat, and History. It shows the active labels for future attacks and scans, not the attribution of a historical run you are viewing. Click the labels icon to open **Default Labels** and add, edit, or remove custom labels. **Operator:** is an always-visible text input; **Operation:** is an always-visible searchable dropdown outside this popover. Operator is required; an operation is optional and removable. A signed-in operator is read-only.

In Chat, the active target, Markdown toggle, export menu, conversations panel toggle, and **New Attack** button share the right side of this bar. They wrap below the labels on narrow screens.

Clicking the `operation` label opens a picker listing the operations already recorded in memory, so you can choose one without typing it from memory. Typing a name that doesn't exist yet offers to create it. Very long lists show the first 200 and say how many are left, so type to narrow them. On narrow screens, use the labels icon to view or edit labels that do not fit inline.
Open the **Operation** dropdown to choose a saved operation. **New operation…** stays first, including while searching, and opens the creation dialog. Typing alone never selects an unsaved name. Very long lists show up to 200 saved choices and say how many match, so type to narrow them. The compact metadata controls wrap on narrow screens; custom labels that do not fit remain available through the labels icon.

Your choices persist in this browser across navigation and refreshes. Backend configuration supplies defaults for labels you have not chosen, and the signed-in account alias takes precedence over the default or remembered operator during initialization. Scanner launches receive the active labels from this bar.

Expand Down Expand Up @@ -459,6 +459,69 @@ 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.

### Operations and Findings

An operation groups related red-teaming work. Inside it you record findings:
human assessments, whether or not an attack produced them.

Open **Operations** in the sidebar and choose **New operation**. Names are unique,
ignoring case and surrounding spaces; if the name is taken, the dialog offers the
existing operation. The **Operation** dropdown in the labels bar applies the
selected operation to new attacks and scanner runs, and **New operation…** at the
top of that list creates one without leaving the page.

Operations can't be renamed or deleted from the GUI.

#### Findings

Open an operation and choose **New finding**.

| Field | Required | Values |
| --- | --- | --- |
| Title | Yes | Free text |
| Severity | Yes | Critical, Important, Moderate, Low, Informational, or Other (your own text). Defaults to Moderate. |
| Harm-type | No | A PyRIT harm category, Other (your own text), or Not set |
| Description | No | Free text |

Findings are sorted by severity in the order above, newest first within each
level, 20 per page. **Edit** changes any field; the operation and creation time
stay fixed. **Delete** asks for confirmation and can't be undone.

**View execution history** opens History filtered to the operation's exact name,
and the filter stays when you switch between the Attacks and Scanner tabs. Runs
labeled with a different capitalization or spacing of the name don't match.

#### Conversation evidence

To attach a saved conversation to a finding, open it in Chat and choose **Link to
finding**, the link icon next to Export. The picker lists findings from the
operation the attack was labeled with. Search by title, select one, and choose
**Attach**. If the attack's operation label doesn't exactly match a saved
operation, the button is unavailable. The current toolbar selection doesn't
change this.

**New finding** in the picker opens the same form and attaches the conversation
once the finding is saved. If the finding saves but the attachment fails, the
finding is kept and the viewer offers **Retry attachment**, which won't create a
second finding. Retry works only from the original conversation.

Evidence points to the live conversation, not a copy, so later messages show up
too. Attaching the same conversation again reports **Already attached**.

On the operation page, **Evidence (n)** under a finding lists its conversations.
**Open conversation** reopens one in Chat. If a conversation no longer exists,
its entry stays, shows its ID and attachment time, and reads **Evidence
unavailable**. **Remove link** removes only the link, never the conversation.
Deleting a finding removes its links the same way.

#### Upgrading an existing database

One migration adds the operation, finding, and evidence tables and leaves
existing data alone. The backend applies it automatically when it starts, so
restart a running backend after upgrading. If you start memory with
`skip_schema_migration=True`, run the migration yourself. Downgrading refuses to
drop the tables while any operation exists.

### Target Configuration

The Configuration view manages the targets available for attacks.
Expand Down
112 changes: 64 additions & 48 deletions frontend/e2e/labels-operation-picker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ async function setupMocks(
versionDelayMs?: number;
defaultLabels?: Record<string, string>;
operatorLabels?: string[];
savedOperations?: string[];
} = {},
): Promise<void> {
let versionRequests = 0;
Expand Down Expand Up @@ -63,6 +64,15 @@ async function setupMocks(
},
}));
}
if (path === "/operations") {
return route.fulfill(json({
items: (options.savedOperations ?? operationLabels).map((name, index) => ({
id: `saved-operation-${index}`,
name,
created_at: "2026-01-01T00:00:00Z",
})),
}));
}
if (path === "/attacks") {
return route.fulfill(json({ items: [], total: 0, limit: 5, offset: 0 }));
}
Expand All @@ -81,7 +91,7 @@ function json(body: unknown) {
/** Opens the picker from the labels bar and returns the rendered listbox. */
async function openOperationPicker(page: Page) {
await page.goto("/");
const chip = page.getByTestId("label-operation");
const chip = page.getByTestId("edit-label-operation");
await expect(chip).toBeVisible();
await chip.click();

Expand All @@ -100,14 +110,14 @@ test.describe("operation picker placement", () => {
await expect(bar.getByText("New run labels", { exact: true })).toHaveCount(0);
await expect(bar.getByText("Used for new attacks and scans.", { exact: false })).toHaveCount(0);

await bar.getByRole("button", { name: /^Edit operation, currently / }).click();
await bar.getByRole("combobox", { name: "Operation" }).click();
await page.getByRole("option", { name: "op_beta", exact: true }).click();
await expect(bar.getByRole("button", { name: "Edit operation, currently op_beta" })).toBeVisible();
await expect(bar.getByRole("combobox", { name: "Operation" })).toBeVisible();

await bar.getByRole("button", { name: /^Edit operator, currently / }).click();
await page.getByRole("textbox", { name: "Value for operator label" }).fill("alice");
await page.getByRole("textbox", { name: "Value for operator label" }).press("Enter");
await expect(bar.getByRole("button", { name: "Edit operator, currently alice" })).toBeVisible();
await bar.getByRole("textbox", { name: "Operator" }).click();
await page.getByRole("textbox", { name: "Operator" }).fill("alice");
await page.getByRole("textbox", { name: "Operator" }).press("Enter");
await expect(bar.getByRole("textbox", { name: "Operator" })).toBeVisible();

await bar.getByTestId("labels-icon-btn").click();
const popover = page.getByRole("group").filter({
Expand Down Expand Up @@ -156,7 +166,7 @@ test.describe("operation picker placement", () => {
await page.getByRole("region", { name: "Default Labels" }).evaluate(
(bar: HTMLElement) => { bar.style.marginTop = "240px"; },
);
await page.getByTestId("label-operation").click();
await page.getByTestId("edit-label-operation").click();

const listbox = page.getByRole("listbox");
await expect(listbox).toBeVisible();
Expand Down Expand Up @@ -246,7 +256,9 @@ test.describe("operation picker placement", () => {
JSON.stringify({ operator: "roakey", operation: "op_chosen_elsewhere" }),
);
});
await setupMocks(page, ["op_alpha", "op_beta"]);
await setupMocks(page, ["op_alpha", "op_beta"], {
savedOperations: ["op_alpha", "op_beta", "op_chosen_elsewhere"],
});
await openOperationPicker(page);

await expect(
Expand Down Expand Up @@ -280,11 +292,9 @@ test.describe("operation picker placement", () => {
expect(Date.now() - started).toBeLessThan(3000);
});

test("keeps the operation in use reachable past the end of a long list", async ({
test("keeps a legacy operation removable without offering it as a saved choice", async ({
page,
}) => {
// The value in use goes to the front of the list. Cap the wrong end and it
// is the first thing to disappear — whether or not the request returned it.
await page.setViewportSize({ width: 1280, height: 800 });
await page.addInitScript(() => {
window.localStorage.setItem(
Expand All @@ -299,11 +309,13 @@ test.describe("operation picker placement", () => {
name: "op_chosen_elsewhere",
exact: true,
});
await expect(inUse).toBeVisible();
await inUse.click();
await expect(page.getByTestId("label-operation")).toContainText(
await expect(inUse).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(page.getByTestId("edit-label-operation")).toHaveValue(
"op_chosen_elsewhere",
);
await page.getByRole("button", { name: "Remove operation label" }).click();
await expect(page.getByRole("combobox", { name: "Operation" })).toBeVisible();
});

test("keeps an operation the saved list already holds past the cap", async ({
Expand Down Expand Up @@ -335,11 +347,11 @@ test.describe("operation picker persistence", () => {
await openOperationPicker(page);

await page.getByRole("option", { name: "op_beta", exact: true }).click();
await expect(page.getByTestId("label-operation")).toContainText("op_beta");
await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta");

await page.reload();

await expect(page.getByTestId("label-operation")).toContainText("op_beta");
await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta");
});

test("keeps an operation picked while the app was still starting up", async ({
Expand All @@ -362,13 +374,13 @@ test.describe("operation picker persistence", () => {
await page
.getByRole("option", { name: "op_picked_early", exact: true })
.click();
await expect(page.getByTestId("label-operation")).toContainText(
await expect(page.getByTestId("edit-label-operation")).toHaveValue(
"op_picked_early",
);

// Let the slow response land; it must not undo the choice.
await page.waitForTimeout(5000);
await expect(page.getByTestId("label-operation")).toContainText(
await expect(page.getByTestId("edit-label-operation")).toHaveValue(
"op_picked_early",
);
});
Expand All @@ -379,7 +391,7 @@ test.describe("operation picker persistence", () => {
// Nothing is stored, and the backend supplies its own `operation` default
// that lands after the bar is already usable. The only thing standing
// between the pick and that late response is that the value on screen is
// no longer the built-in placeholder.
// no longer the untouched default.
await page.setViewportSize({ width: 1280, height: 800 });
await setupMocks(page, ["op_alpha", "op_picked_early"], {
versionDelayMs: 4000,
Expand All @@ -390,12 +402,12 @@ test.describe("operation picker persistence", () => {
await page
.getByRole("option", { name: "op_picked_early", exact: true })
.click();
await expect(page.getByTestId("label-operation")).toContainText(
await expect(page.getByTestId("edit-label-operation")).toHaveValue(
"op_picked_early",
);

await page.waitForTimeout(5000);
await expect(page.getByTestId("label-operation")).toContainText(
await expect(page.getByTestId("edit-label-operation")).toHaveValue(
"op_picked_early",
);
// What is on screen is also what a refresh would restore.
Expand All @@ -416,7 +428,7 @@ test.describe("operation picker persistence", () => {
await openOperationPicker(page);

await page.getByRole("option", { name: "op_beta", exact: true }).click();
await expect(page.getByTestId("label-operation")).toContainText("op_beta");
await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta");

// A later visit, once the deployment configures an operator.
await page.unrouteAll({ behavior: "ignoreErrors" });
Expand All @@ -425,10 +437,10 @@ test.describe("operation picker persistence", () => {
});
await page.reload();

await expect(page.getByTestId("label-operator")).toContainText(
await expect(page.getByTestId("edit-label-operator")).toHaveValue(
"configured_user",
);
await expect(page.getByTestId("label-operation")).toContainText("op_beta");
await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta");
});

test("lets the backend change a label it supplied, after you pick", async ({
Expand All @@ -443,7 +455,7 @@ test.describe("operation picker persistence", () => {
await openOperationPicker(page);

await page.getByRole("option", { name: "op_beta", exact: true }).click();
await expect(page.getByTestId("label-operator")).toContainText(
await expect(page.getByTestId("edit-label-operator")).toHaveValue(
"configured_day1",
);

Expand All @@ -453,10 +465,10 @@ test.describe("operation picker persistence", () => {
});
await page.reload();

await expect(page.getByTestId("label-operator")).toContainText(
await expect(page.getByTestId("edit-label-operator")).toHaveValue(
"configured_day2",
);
await expect(page.getByTestId("label-operation")).toContainText("op_beta");
await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_beta");
});
});

Expand All @@ -471,7 +483,7 @@ test.describe("switching between labels", () => {
await setupMocks(page, ["op_alpha", "op_beta"]);
await openOperationPicker(page);

await page.getByTestId("label-operator").click();
await page.getByTestId("edit-label-operator").click();

const operatorEditor = page.getByTestId("edit-label-operator");
await expect(operatorEditor).toBeVisible();
Expand All @@ -488,16 +500,16 @@ test.describe("switching between labels", () => {
await setupMocks(page, ["op_alpha", "op_beta"]);
await page.goto("/");

await page.getByTestId("label-operator").click();
await page.getByTestId("edit-label-operator").click();
await page.getByTestId("edit-label-operator").fill("alice");
await page.getByTestId("label-operation").click();
await page.getByTestId("edit-label-operation").click();

await expect(page.getByRole("listbox")).toBeVisible();
await page.waitForTimeout(500);
await expect(page.getByRole("listbox")).toBeVisible();
// The operator edit still went in; only its clean-up was skipped.
await page.keyboard.press("Escape");
await expect(page.getByTestId("label-operator")).toContainText("alice");
await expect(page.getByTestId("edit-label-operator")).toHaveValue("alice");
});
});

Expand All @@ -511,32 +523,36 @@ test.describe("finishing an edit another way", () => {
await setupMocks(page, ["op_alpha"], { operatorLabels: ["roakey", "alice"] });
await page.goto("/");

await page.getByTestId("label-operator").click();
await page.getByTestId("edit-label-operator").click();
await page.getByTestId("edit-label-operator").fill("al");
await page.getByText("alice", { exact: true }).click();

await page.waitForTimeout(500);
await expect(page.getByTestId("label-operator")).toContainText("alice");
await expect(page.getByTestId("edit-label-operator")).toHaveValue("alice");
});

test("starts an edit when the chip is clicked beside the edit control", async ({
test("keeps compact metadata inputs visible and creation first on mobile", async ({
page,
}) => {
// The pill's padding sits outside the control that opens the editor, and
// only a real layout says where that padding actually is.
await page.setViewportSize({ width: 1280, height: 800 });
await page.setViewportSize({ width: 360, height: 800 });
await setupMocks(page, ["op_alpha"]);
await page.goto("/");

const chip = page.getByTestId("label-operator");
await expect(chip).toBeVisible();
const badge = chip.locator("xpath=..");
const box = await badge.boundingBox();
if (!box) throw new Error("chip has no layout");

// Two pixels in from the pill's left edge is padding, not the control.
await page.mouse.click(box.x + 2, box.y + box.height / 2);

await expect(page.getByTestId("edit-label-operator")).toBeVisible();
const operator = page.getByRole("textbox", { name: "Operator" });
const operation = page.getByRole("combobox", { name: "Operation" });
await expect(operator).toBeVisible();
await expect(operation).toBeVisible();
expect(await operator.evaluate(input => input.parentElement?.getBoundingClientRect().width)).toBeLessThanOrEqual(100);
expect(await operation.evaluate(input => input.parentElement?.getBoundingClientRect().width)).toBeLessThanOrEqual(140);
await operation.click();
await expect(page.getByRole("option").first()).toHaveText("New operation…");
await operation.fill("missing");
await expect(page.getByRole("option").first()).toHaveText("New operation…");
await page.getByRole("option", { name: "New operation…" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("textbox", { name: "Name" })).toBeFocused();
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(operation).toBeFocused();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(360);
});
});
Loading
Loading