Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
dc88eb7
Support adding custom initializers in CopyRIT
Aug 25, 2026
c94c046
Merge remote-tracking branch 'upstream/main' into custom-initializers
Aug 25, 2026
a81a162
FIX add missing final newlines
Aug 25, 2026
ed16aae
package-lock
Aug 25, 2026
262ce8b
reuse dialog
Aug 25, 2026
d75e825
respect custom initializer flag on startup
Aug 25, 2026
0d43a56
GUI config
Aug 26, 2026
7c69104
add restart and simplify cache
Aug 26, 2026
edf9983
update deployment
Aug 26, 2026
cd3a27c
precommit
Aug 26, 2026
a71ed77
dont change old initializer paths
Aug 27, 2026
b11b653
simplify
Aug 27, 2026
ae2e585
simplify backend configuration lifecycle
Aug 27, 2026
6a665e5
ruff
Aug 27, 2026
4541a97
Support remote config and AKV environment refs
Aug 27, 2026
94dc897
Fix initialization script config example
Aug 27, 2026
936ae55
Add coverage for configuration editing
Aug 27, 2026
62e0594
feat: restrict backend configuration routes to admins
Aug 27, 2026
c9e14e6
fix: address configuration review feedback
Aug 27, 2026
1fb2d22
fix: rename target navigation route
Aug 27, 2026
94e725f
fix: address remaining configuration review feedback
Aug 28, 2026
65411cf
refactor: rename frontend configuration feature
Aug 28, 2026
c15beab
address feedback
Aug 28, 2026
4e86073
Merge remote-tracking branch 'origin/main' into custom-initializers
Aug 28, 2026
d65bfde
Merge remote-tracking branch 'upstream/main' into custom-initializers
Aug 28, 2026
c231226
Merge remote-tracking branch 'upstream/main' into custom-initializers
Aug 28, 2026
0b99752
fix: keep external config environment sources authoritative
Aug 28, 2026
7accd1d
FIX frontend configuration checks
Aug 28, 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
8 changes: 7 additions & 1 deletion .pyrit_conf_example
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ operation: op_trash_panda

# Initialization Scripts
# ----------------------
# List of paths to custom Python scripts containing PyRITInitializer subclasses.
# List of local paths to Python scripts containing PyRITInitializer subclasses.
# Paths can be absolute or relative to the current working directory.
#
# Behavior:
Expand Down Expand Up @@ -123,6 +123,12 @@ max_concurrent_scenario_runs: 3
# Default: false
allow_custom_initializers: false

# Optional storage for custom initializer Python scripts. This may be a local
# directory or an Azure Blob container URI with an optional blob prefix.
# Container URIs may include a SAS; otherwise DefaultAzureCredential is used. Defaults to
# ~/.pyrit/custom_initializers.
# custom_initializers_source: https://account.blob.core.windows.net/container/custom_initializers

# Local Backend Server
# --------------------
# Client settings used by pyrit_scan when connecting to or launching a backend.
Expand Down
12 changes: 11 additions & 1 deletion doc/getting_started/pyrit_conf.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,19 @@ initializers:

Full preload can take several minutes and may require network access, provider credentials, or acceptance of gated dataset licenses. When preloading during local backend startup, increase `server.startup_timeout` if the configured timeout is not long enough.

### `custom_initializers_source`

Stores custom initializer Python files in a local directory or Azure Blob container. An Azure URI may include a blob-name prefix, which behaves like a folder:

```yaml
custom_initializers_source: https://account.blob.core.windows.net/pyrit-storage/custom-initializers
```

With this configuration, PyRIT reads and writes scripts directly under the `custom-initializers/` prefix in the `pyrit-storage` container. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/custom_initializers`.

### `initialization_scripts`

Paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory.
Local paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory.

| Value | Behavior |
| ----------------- | ---------------------------------- |
Expand Down
14 changes: 14 additions & 0 deletions doc/gui/0_gui.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ pyrit_backend

Then open `http://localhost:8000` in your browser.

Authentication-disabled local servers deny administrator operations by default. To enable configuration and initializer
administration for a trusted local development server, set `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`. Never use this
setting on a network-accessible deployment.

### Docker

CoPyRIT is also available as a Docker container. See the [Docker setup](https://github.com/microsoft/PyRIT/blob/main/docker/) for details.
Expand Down Expand Up @@ -173,6 +177,16 @@ 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.

### Configuration Editor

The **Configuration** page provides administrator-only editing for the files and scripts used to configure PyRIT. It has three tabs:

- **PyRIT Configuration** edits the active `.pyrit_conf` YAML file. The source may be a local file or an Azure Blob URI. Saving validates the configuration before replacing it.
- **Environment & Secrets** lists the configured local dotenv files and Azure Key Vault bootstrap secrets. Content is loaded only after selecting a source. Saves validate the dotenv document and reject the update if the source changed since it was loaded.
- **Custom Initializers** registers or removes Python initializer scripts. This tab requires `allow_custom_initializers: true`; scripts are stored in the configured local directory or Azure Blob container and must define a concrete `PyRITInitializer` subclass.

Use **Reload** to discard local edits and fetch the latest source content. Saved configuration and environment changes take effect after restarting PyRIT. Custom initializer scripts execute under the backend service identity, so only trusted administrators should manage them.

### 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.
Expand Down
66 changes: 39 additions & 27 deletions docker/start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,15 @@ fi
echo "Checking PyRIT installation..."
python -c "import pyrit; print(f'Running PyRIT version: {pyrit.__version__}')"

# Write .env from the Container App's `env-file` secret. deploy_instance.py
# supplies it inline. The optional Key Vault-backed Bicep path requires both
# Key Vault Secrets User and an explicit vault network path because ACA is not
# currently listed in Key Vault's trusted-services firewall bypass.
# Write .env when deploy_instance.py supplies inline content. Otherwise the
# generated PyRIT config uses the Key Vault URL from PYRIT_ENV_AKV_REF so the
# backend can read and update that environment source through managed identity.
if [ -n "$PYRIT_ENV_CONTENTS" ]; then
mkdir -p ~/.pyrit
echo "$PYRIT_ENV_CONTENTS" > ~/.pyrit/.env
echo "Wrote .env file from PYRIT_ENV_CONTENTS ($(wc -l < ~/.pyrit/.env) lines)"
else
echo "No PYRIT_ENV_CONTENTS set — using system environment variables only"
echo "No inline PYRIT_ENV_CONTENTS set — using configured environment sources"
fi

# Start the appropriate service based on PYRIT_MODE
Expand All @@ -57,28 +56,41 @@ if [ "$PYRIT_MODE" = "jupyter" ]; then
exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --notebook-dir=/app/notebooks
elif [ "$PYRIT_MODE" = "gui" ]; then
echo "Starting PyRIT GUI on port 8000..."
# The thin backend only takes --host/--port/--config-file/--log-level.
# Translate AZURE_SQL_SERVER and PYRIT_INITIALIZER into a runtime config file
# so the FastAPI lifespan (ConfigurationLoader) picks them up on startup.
RUNTIME_CONFIG=/tmp/pyrit_runtime.yaml
{
if [ -n "$AZURE_SQL_SERVER" ]; then
echo "Using Azure SQL database (server: $AZURE_SQL_SERVER)" >&2
echo "memory_db_type: AzureSQL"
else
echo "Using SQLite database (AZURE_SQL_SERVER not set)" >&2
echo "memory_db_type: SQLite"
if [ -n "${PYRIT_CONFIG_FILE:-}" ]; then
CONFIG_FILE="$PYRIT_CONFIG_FILE"
echo "Using external PyRIT configuration"
if [ -n "${PYRIT_ENV_AKV_REF:-}" ]; then
echo "WARNING: Ignoring PYRIT_ENV_AKV_REF because the external PyRIT configuration controls environment sources" >&2
fi
if [ -n "$PYRIT_INITIALIZER" ]; then
echo "Using initializer: $PYRIT_INITIALIZER" >&2
echo "initializers:"
# Split comma-separated initializer names into a YAML list.
IFS=',' read -ra INIT_NAMES <<<"$PYRIT_INITIALIZER"
for name in "${INIT_NAMES[@]}"; do
echo " - $(echo "$name" | xargs)"
done
fi
} >"$RUNTIME_CONFIG"
else
# Translate deployment settings into a runtime config file so the FastAPI
# lifespan (ConfigurationLoader) picks them up on startup.
RUNTIME_CONFIG=/tmp/pyrit_runtime.yaml
{
if [ -n "$AZURE_SQL_SERVER" ]; then
echo "Using Azure SQL database (server: $AZURE_SQL_SERVER)" >&2
echo "memory_db_type: AzureSQL"
else
echo "Using SQLite database (AZURE_SQL_SERVER not set)" >&2
echo "memory_db_type: SQLite"
fi
if [ -n "$PYRIT_INITIALIZER" ]; then
echo "Using initializer: $PYRIT_INITIALIZER" >&2
echo "initializers:"
# Split comma-separated initializer names into a YAML list.
IFS=',' read -ra INIT_NAMES <<<"$PYRIT_INITIALIZER"
for name in "${INIT_NAMES[@]}"; do
echo " - $(echo "$name" | xargs)"
done
fi
if [ -n "$PYRIT_ENV_AKV_REF" ]; then
echo "Using Azure Key Vault environment reference" >&2
echo "env_akv_ref:"
echo " - $PYRIT_ENV_AKV_REF"
fi
} >"$RUNTIME_CONFIG"
CONFIG_FILE="$RUNTIME_CONFIG"
Comment thread
behnam-o marked this conversation as resolved.
fi

# Pick the launcher module. PR #1753 moved the launcher from
# ``pyrit.cli.pyrit_backend`` to ``pyrit.backend.pyrit_backend``. The PyPI
Expand All @@ -99,7 +111,7 @@ elif [ "$PYRIT_MODE" = "gui" ]; then
exec python -m "$BACKEND_MODULE" \
--host 0.0.0.0 \
--port 8000 \
--config-file "$RUNTIME_CONFIG"
--config-file "$CONFIG_FILE"
else
echo "ERROR: Invalid PYRIT_MODE '$PYRIT_MODE'. Must be 'jupyter' or 'gui'"
exit 1
Expand Down
22 changes: 11 additions & 11 deletions frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ test.describe("Accessibility", () => {
});
});

// Navigate to config, set active, return to chat so input is enabled
await page.getByTitle("Configuration").click();
// Navigate to targets, set active, return to chat so input is enabled
await page.getByTitle("Targets").click();
await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 });
const setActiveBtn = page.getByRole("button", { name: /set active/i });
await expect(setActiveBtn).toBeVisible({ timeout: 5000 });
Expand All @@ -146,8 +146,8 @@ test.describe("Accessibility", () => {
const chatBtn = page.getByTitle("Chat");
await expect(chatBtn).toBeVisible();

// Configuration button
const configBtn = page.getByTitle("Configuration");
// Targets button
const configBtn = page.getByTitle("Targets");
await expect(configBtn).toBeVisible();

// Theme toggle button (now a menu trigger with "Theme: <mode>" title)
Expand Down Expand Up @@ -219,8 +219,8 @@ test.describe("Accessibility", () => {
});
});

// Navigate to config, set active, return to chat so input is enabled
await page.getByTitle("Configuration").click();
// Navigate to targets, set active, return to chat so input is enabled
await page.getByTitle("Targets").click();
await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 });
const setActiveBtn = page.getByRole("button", { name: /set active/i });
await expect(setActiveBtn).toBeVisible({ timeout: 5000 });
Expand All @@ -239,7 +239,7 @@ test.describe("Accessibility", () => {
await expect(input).toBeFocused();
});

test("should have accessible target table in config view", async ({ page }) => {
test("should have accessible target table in targets view", async ({ page }) => {
// Mock targets API for consistent test
await page.route(/\/api\/targets/, async (route) => {
await route.fulfill({
Expand All @@ -265,8 +265,8 @@ test.describe("Accessibility", () => {
});
});

// Navigate to config
await page.getByTitle("Configuration").click();
// Navigate to targets
await page.getByTitle("Targets").click();
await expect(page.getByText("Target Configuration")).toBeVisible();

// Table should exist
Expand All @@ -289,7 +289,7 @@ test.describe("Accessibility", () => {

const views = [
{ button: "Attack History", heading: "Attack History" },
{ button: "Configuration", heading: "Target Configuration" },
{ button: "Targets", heading: "Target Configuration" },
{ button: "Chat", heading: "Chat" },
];

Expand Down Expand Up @@ -334,7 +334,7 @@ test.describe("Accessibility", () => {
});
});

await page.getByRole("button", { name: "Configuration" }).click();
await page.getByRole("button", { name: "Targets" }).click();
await expect(
page.getByRole("heading", { level: 1, name: "Target Configuration" })
).toBeVisible();
Expand Down
10 changes: 5 additions & 5 deletions frontend/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,10 @@ async function mockBackendAPIs(page: Page) {
});
}

/** Navigate to config, set the mock target as active, then return to chat. */
/** Navigate to targets, set the mock target as active, then return to chat. */
async function activateMockTarget(page: Page) {
// Click Configuration button in sidebar
await page.getByTitle("Configuration").click();
// Click Targets button in sidebar
await page.getByTitle("Targets").click();
await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 });

// Set the mock target active
Expand Down Expand Up @@ -853,7 +853,7 @@ test.describe("Target type scenarios", () => {
});

await page.goto("/");
await page.getByTitle("Configuration").click();
await page.getByTitle("Targets").click();
await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 });

await expect(page.locator("table").getByText("OpenAIChatTarget")).toBeVisible();
Expand All @@ -878,7 +878,7 @@ test.describe("Target type scenarios", () => {
});

await page.goto("/");
await page.getByTitle("Configuration").click();
await page.getByTitle("Targets").click();
await expect(page.getByText("dall-e-3")).toBeVisible({ timeout: 10000 });

// Activate the DALL-E target (second row)
Expand Down
34 changes: 17 additions & 17 deletions frontend/e2e/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,10 @@ async function expectWithin(
);
}

/** Navigate to the config view. */
async function goToConfig(page: Page) {
/** Navigate to the targets view. */
async function goToTargets(page: Page) {
await page.goto("/");
await page.getByTitle("Configuration").click();
await page.getByTitle("Targets").click();
await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 });
}

Expand Down Expand Up @@ -185,7 +185,7 @@ test.describe("Target Configuration Page", () => {
await route.fulfill(mockTargetsList(SAMPLE_TARGETS));
});

await goToConfig(page);
await goToTargets(page);

// Table should appear with both targets
await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 });
Expand All @@ -199,7 +199,7 @@ test.describe("Target Configuration Page", () => {
await route.fulfill(mockTargetsList([]));
});

await goToConfig(page);
await goToTargets(page);

await expect(page.getByText("No Targets Configured")).toBeVisible();
await expect(page.getByRole("button", { name: /create first target/i })).toBeVisible();
Expand All @@ -210,7 +210,7 @@ test.describe("Target Configuration Page", () => {
await route.fulfill({ status: 500, body: "Internal Server Error" });
});

await goToConfig(page);
await goToTargets(page);

await expect(page.getByText(/error/i)).toBeVisible({ timeout: 10000 });
});
Expand All @@ -220,7 +220,7 @@ test.describe("Target Configuration Page", () => {
await route.fulfill(mockTargetsList(SAMPLE_TARGETS));
});

await goToConfig(page);
await goToTargets(page);
await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 });

// Both rows should have a "Set Active" button initially
Expand All @@ -237,7 +237,7 @@ test.describe("Target Configuration Page", () => {
await route.fulfill(mockTargetsList([]));
});

await goToConfig(page);
await goToTargets(page);

// Click the "New Target" button in the header
await page.getByRole("button", { name: /new target/i }).click();
Expand All @@ -257,7 +257,7 @@ test.describe("Target Configuration Page", () => {
await route.fulfill(mockTargetsList(items));
});

await goToConfig(page);
await goToTargets(page);
// First load shows one target
await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 });
await expect(page.getByText("dall-e-3")).not.toBeVisible();
Expand Down Expand Up @@ -294,7 +294,7 @@ test.describe("Create Target Dialog", () => {
await route.fulfill(mockTargetsList([]));
});

await goToConfig(page);
await goToTargets(page);
await page.getByRole("button", { name: /new target/i }).click();

const dialog = page.getByRole("dialog");
Expand Down Expand Up @@ -356,7 +356,7 @@ test.describe("Create Target Dialog", () => {
}
});

await goToConfig(page);
await goToTargets(page);

// Click "New Target" button
await page.getByRole("button", { name: /new target/i }).click();
Expand Down Expand Up @@ -393,7 +393,7 @@ test.describe("Create Target Dialog", () => {
await route.fulfill(mockTargetsList([]));
});

await goToConfig(page);
await goToTargets(page);

// Open dialog
await page.getByRole("button", { name: /new target/i }).click();
Expand Down Expand Up @@ -432,7 +432,7 @@ test.describe("Responsive Target Configuration", () => {
height: viewport.height,
});
await routeResponsiveTargetData(page, LONG_NAME_TARGETS);
await goToConfig(page);
await goToTargets(page);
await expect(page.getByText("gpt-4o-responsive").first()).toBeVisible();

const config = page.getByTestId("target-config");
Expand Down Expand Up @@ -460,7 +460,7 @@ test.describe("Responsive Target Configuration", () => {
height: viewport.height,
});
await routeResponsiveTargetData(page, LONG_NAME_TARGETS);
await goToConfig(page);
await goToTargets(page);
await expect(page.getByText("gpt-4o-responsive").first()).toBeVisible();

await page.getByRole("button", { name: /new target/i }).click();
Expand Down Expand Up @@ -523,7 +523,7 @@ test.describe("Target Config ↔ Chat Navigation", () => {
await route.fulfill(mockTargetsList(SAMPLE_TARGETS));
});

await goToConfig(page);
await goToTargets(page);
await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 });

// Set first target active
Expand Down Expand Up @@ -551,8 +551,8 @@ test.describe("Target Config ↔ Chat Navigation", () => {
await page.getByTitle("Chat").click();
await expect(page.getByTestId("no-target-banner")).toBeVisible();

// Go to config, set a target
await page.getByTitle("Configuration").click();
// Go to targets, set a target
await page.getByTitle("Targets").click();
await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 });
await page.getByRole("button", { name: /set active/i }).first().click();

Expand Down
Loading