Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
9f409ab
feat(setup): add plug-in loading to initialize_pyrit_async
Jul 7, 2026
c7a7528
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 8, 2026
e7d63cf
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 8, 2026
eca5928
Potential fix for pull request finding
ValbuenaVC Jul 8, 2026
0b11b6c
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 8, 2026
8781038
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 8, 2026
e6159e5
test(setup): verify plug-in scenarios are discovered end-to-end
Jul 8, 2026
f8e761d
fix(setup): restore overwritten registry entries on plug-in rollback
Jul 8, 2026
ed3fc0d
refactor(setup): extract plug-in wheels with safe_extract_zip
Jul 8, 2026
fb822e0
perf(setup): run blocking plug-in extraction off the event loop
Jul 8, 2026
95de68c
feat(setup): add granular PluginLoadError subclasses
Jul 8, 2026
c108578
fix(setup): use a unique temp dir for plug-in wheel extraction
Jul 8, 2026
b551008
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 9, 2026
c028f8b
refactor(setup): rename plugin_fail_open to plugin_accept_load_failures
Jul 9, 2026
67f8a85
Merge commit 'b55100804be2f4fae26d619cbd5b072e104a94ce' into vvalbuen…
Jul 9, 2026
067ffba
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 9, 2026
2ae6c55
feat(setup): configure plug-ins in .pyrit_conf with multi-plug-in sup…
Jul 9, 2026
b21f856
Merge commit '067ffba6461c6e89590a3d369a3d5bcc408508a3' into vvalbuen…
Jul 9, 2026
21ebf36
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 9, 2026
466679a
Merge commit '21ebf36815e93891ef55221912fee2345bd311c9' into vvalbuen…
Jul 9, 2026
88bd76a
feat(registry): auto-register external plug-in Scenario subclasses
Jul 9, 2026
b177f49
feat(setup): tolerate plug-in version drift and re-extract stale wheels
Jul 9, 2026
1538e6e
test(setup): cover plug-in auto-registration, discovery, instantiate,…
Jul 9, 2026
bbd3da8
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 9, 2026
9b440cd
refactor(exceptions): centralize plug-in load errors
Jul 13, 2026
87f1156
feat(exceptions): define source plug-in failure stages
Jul 13, 2026
97e5976
feat(setup): normalize source and wheel plug-in specs
Jul 13, 2026
d092a10
refactor(setup): activate plug-ins through privileged initializer
Jul 13, 2026
38f62fa
refactor(setup): isolate wheel plug-in preparation
Jul 14, 2026
6010b12
feat(setup): prepare source-file plug-ins
Jul 14, 2026
c5d0507
feat(setup): prepare source-package plug-ins
Jul 14, 2026
8ead0c5
feat(setup): discover source plug-in scenarios
Jul 14, 2026
9f0c0c8
feat(setup): discover plug-in attack techniques
Jul 14, 2026
153b2c0
feat(setup): register plug-in components transactionally
Jul 14, 2026
f94cea0
feat(scenario): expose applicable plug-in techniques
Jul 14, 2026
11e40cb
test(setup): exercise source plug-ins through scanner
Jul 14, 2026
ddf27ac
docs(setup): explain private scenario plug-in usage
Jul 14, 2026
34daddf
refactor(setup): validate plug-in scenarios during activation
Jul 14, 2026
d8377e7
test(setup): make mock plug-in scenario self-contained
Jul 14, 2026
7bcb8d3
Merge origin/main into plug-in mechanism
Jul 14, 2026
f7ea23d
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 14, 2026
26267c7
refactor(scenario): expose all registered RapidResponse techniques
Jul 14, 2026
3fbdd04
refactor(registry): drop plug-in contributed and external-subclass APIs
Jul 14, 2026
a9764ac
refactor(setup): point plug-ins at a PyRITInitializer, drop discovery…
Jul 14, 2026
d288506
docs: retarget plug-in config and docs to the source-initializer model
Jul 14, 2026
19bdbf9
test(setup): rewrite plug-in tests around the initializer path
Jul 14, 2026
7bc560b
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 14, 2026
5b3e996
docs: address review comments; drop version and internal-repo references
Jul 14, 2026
a7800f3
Merge branch 'main' into vvalbuena-microsoft-plugin-mechanism
ValbuenaVC Jul 14, 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
33 changes: 33 additions & 0 deletions .pyrit_conf_example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ memory_db_type: sqlite
# - load_default_datasets: Loads datasets into memory so scenarios can run
# - preload_scenario_metadata: Preloads scenario metadata into the registry
#
# Note: plug-ins are configured below via the dedicated `plugins:` key, NOT as an entry
# in this `initializers:` list. Plug-ins load as a guaranteed-first phase (before these
# initializers), so there is no ordering to get right here.
#
# Each initializer can be specified as:
# - A simple string (name only)
# - A dictionary with 'name' and optional 'args' for parameters
Expand All @@ -53,6 +57,35 @@ initializers:
- name: technique
- name: load_default_datasets

# Plug-ins
# --------
# A plug-in points PyRIT at a private ``PyRITInitializer`` that lives outside the public
# tree (for example, a team's internal red-teaming package). ConfigurationLoader runs it
# as a guaranteed-first, privileged initializer -- before the initializers above -- so the
# scenarios, attack techniques, datasets, and targets it registers exist before anything
# else consumes the registries. Do NOT list it under `initializers:`.
#
# The plug-in's initializer owns all registration; PyRIT discovers nothing on its own.
# A plug-in is one entry with three fields:
# - name: an operator label (used in logs/errors)
# - source: the folder that contains your package, placed on sys.path so
# `import <your_package>` resolves
# - initializer: dotted 'module.Class' path to a PyRITInitializer subclass
# Relative source paths resolve against this configuration file.
#
# WARNING: loading a plug-in executes third-party code in the PyRIT process. Whoever can
# write this file (or the source) can run code on the host. Treat it as sensitive.
#
# Example:
# plugins:
# - name: my_redteam
# source: /repos/my-redteam
# initializer: my_redteam.setup.MyInitializer
#
# Help and troubleshooting:
# - doc/getting_started/plugins.md
# - doc/getting_started/troubleshooting/plugins.md

# Default Scenario
# ----------------
# Optional default scenario to run when invoking `pyrit_scan` without a
Expand Down
1 change: 1 addition & 0 deletions doc/getting_started/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ Once you're set up:

- 🔌 [Targets](../code/targets/0_prompt_targets.md) — Connect to different AI systems
- 📦 [Scenarios](../code/scenarios/0_scenarios.ipynb) — Run standardized evaluation scenarios
- 🧩 [Private Scenario Plug-Ins](./plugins.md) — Add private scenarios and attack techniques to stock PyRIT
- 🖥️ [Scanner](../scanner/0_scanner.md) — Use `pyrit_scan` for automated assessments
130 changes: 130 additions & 0 deletions doc/getting_started/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Private Scenarios and Attack Techniques

PyRIT plug-ins let an operator activate private scenarios and attack techniques from a
**stock PyRIT installation** — without forking PyRIT or passing per-run CLI flags.

## Plug-in or `--initialization-scripts`?

A plug-in is a thin layer over the existing initializer path:

- `--initialization-scripts ./my_init.py` runs a custom `PyRITInitializer` from a loose
file. It works for a self-contained script but is per-run and breaks for a packaged
initializer that imports from its own package.
- A `plugins:` entry in `.pyrit_conf` names a private initializer once, runs it
**first** (before other initializers and before catalog warming), and anchors the
package root on `sys.path` so a packaged initializer with intra-package imports loads
correctly.

Use a plug-in when a private, packaged initializer (for example a team's internal
red-teaming package) needs to behave like a built-in inside the standard backend, GUI,
and `pyrit_scan` catalog.

## What a plug-in is

A plug-in is a config entry that points at a concrete `PyRITInitializer` reachable by a
dotted path from a source root:

```yaml
# .pyrit_conf
plugins:
- name: my_redteam
source: /repos/my-redteam
initializer: my_redteam.setup.MyInitializer
```
- `name` — an operator label used in logs and errors.
- `source` — the folder placed on `sys.path` so your package can be imported (see below).
- `initializer` — a dotted `module.Class` path to a concrete `PyRITInitializer`.

### What `source` should point at (and why it matters)

`source` should be the folder that **contains** your package — the directory you would be
sitting in for `import my_redteam` to succeed at a Python prompt. PyRIT adds that folder
to Python's import search path (`sys.path`) before importing your initializer.

You need this because a real private package doesn't live next to PyRIT; its modules
import from *each other* (for example `from my_redteam.datasets import load`). If Python
can't find the package root, those imports fail and the plug-in won't load. In plain
terms: point `source` at the folder above your package, not at the package folder itself
and not at a single file buried inside it. If you point at the wrong place, loading fails
closed with an import error naming what could not be found.

`ConfigurationLoader` runs the plug-in as a privileged initializer, always **first** —
before your other initializers and before anything reads the scenario/technique catalog.
You do not (and cannot) add it to `initializers:`: it isn't a registered initializer name,
so listing it there fails with an "initializer not found" error. The framework constructs
and runs it for you.

## The initializer owns registration

PyRIT discovers nothing on its own. The plug-in's initializer registers everything it
wants discoverable, at whatever level of abstraction fits:

- **Attack techniques** —
`AttackTechniqueRegistry.get_registry_singleton().register_from_factories([...])`;
selectable via `--techniques`.
- **Scenarios** —
`ScenarioRegistry.get_registry_singleton().register_class(MyScenario, name="my_redteam.violence")`;
runnable via `pyrit_scan my_redteam.violence`.
- **Datasets** — register providers and load them into memory so private seeds stay in
the operator's database and are never published.
- **Default targets** — `set_default_value(...)`.

### Deciding what to keep private

"Private" is rarely all-or-nothing. If you build a custom scenario or technique, you have
to decide whether to contribute it publicly, keep it in your own tracked repo, or keep it
fully private — and often only *part* of it is sensitive. A plug-in lets you keep exactly
the sensitive layer private while everything else stays public and works out of the box:

- sometimes only the **dataset** (the prompts/objectives) is sensitive;
- sometimes it's a niche **technique**;
- sometimes an entire **scenario** should not be exposed at all.

Because the initializer registers each level independently, you can publish the generic
parts and register only the sensitive parts from your private package.

### Example initializer

```python
from pyrit.executor.attack import PromptSendingAttack
from pyrit.registry import AttackTechniqueRegistry, ScenarioRegistry
from pyrit.scenario.core import AttackTechniqueFactory
from pyrit.setup.pyrit_initializer import PyRITInitializer
from my_redteam.scenarios import Violence
class MyInitializer(PyRITInitializer):
"""Register a private technique and a private scenario."""
async def initialize_async(self) -> None:
AttackTechniqueRegistry.get_registry_singleton().register_from_factories(
[AttackTechniqueFactory(name="operation_foobar", attack_class=PromptSendingAttack)]
)
ScenarioRegistry.get_registry_singleton().register_class(Violence, name="my_redteam.violence")
```

## Usage

```powershell
# A private technique through a public scenario
pyrit_scan airt.rapid_response --target openai_chat --techniques operation_foobar
# A private scenario
pyrit_scan my_redteam.violence --target openai_chat
```

## Behavior and limits

- The plug-in initializer runs **first**, so lazy catalog/metadata consumers see a
complete registry.
- Loading executes third-party Python with backend permissions; whoever can write the
config or the source can run code on the host. Treat the config as sensitive.
- Dependencies must already be installed in the backend environment.
- The plug-in path is **fail-closed** and supports **one** plug-in. A failed load aborts
initialization — fix the config or source and restart.
- Plug-ins activate only at process/backend startup. Restart after changing the config
or the source; there is no hot reload.

See [Plug-In Troubleshooting](./troubleshooting/plugins.md) for common failures.
31 changes: 29 additions & 2 deletions doc/getting_started/pyrit_conf.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ flowchart LR
### Using .env.local for Overrides

You can use `~/.pyrit/.env.local` to override values in `~/.pyrit/.env` without modifying the base file. This is useful for:

- Testing different targets
- Using personal credentials instead of shared ones
- Switching between configurations quickly
Expand Down Expand Up @@ -151,6 +152,28 @@ initialization_scripts:
- ./local_initializer.py
```

### `plugins`

One private plug-in to activate before the configured initializers. A plug-in points at
a private ``PyRITInitializer`` reached by a dotted path from a source root; the
initializer registers whatever private scenarios, attack techniques, datasets, and
targets it wants discoverable:

```yaml
plugins:
- name: my_redteam
source: /repos/my-redteam
initializer: my_redteam.setup.MyInitializer
```

`ConfigurationLoader` automatically injects this as a privileged initializer that runs
first; you cannot add it to `initializers:` yourself (it is not a registered initializer
name). It is fail-closed, supports one plug-in, and requires a process/backend restart
after changes.

See [Private Scenarios and Attack Techniques](./plugins.md) and
[Plug-In Troubleshooting](./troubleshooting/plugins.md).

### `env_files`

Environment file paths to load during initialization. Later files override values from earlier files.
Expand Down Expand Up @@ -196,9 +219,13 @@ The 3-layer model above determines **which config values are selected**. Once re
1. Environment files are loaded
2. Default values are reset
3. Memory database is configured (from `memory_db_type`)
4. Initializers are executed in listed order
4. The privileged plug-in initializer runs when `plugins:` is configured
5. User-configured initializers are executed in listed order

Because initializers run last, they can modify anything set up in earlier steps — including environment variables and the memory instance. In practice, built-in initializers like `target` and `scorer` only call `set_default_value` and `set_global_variable` and do not touch memory or environment variables. However, a custom initializer could override those if needed. When this happens, the initializer's changes take effect because it runs after the other settings have been applied.
Because initializers run after environment and memory setup, they can use those
prerequisites. The plug-in initializer is framework-controlled and always precedes
the listed initializers so scenario/technique registries are complete before catalog
consumers run.

## Usage

Expand Down
2 changes: 2 additions & 0 deletions doc/getting_started/troubleshooting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ Common issues and advanced setup guides.

- **[Azure SQL Database Setup](./azure_sql_db.md)** — Setting up Azure SQL as your memory backend, including Entra ID authentication and user permissions.

- **[Private Scenario Plug-Ins](./plugins.md)** — Configuration, initializer resolution, import, and restart troubleshooting.

## Model Deployment

- **[Deploy HuggingFace Models on Azure ML](./deploy_hf_model_aml.ipynb)** — Deploy HuggingFace models to Azure ML endpoints.
Expand Down
63 changes: 63 additions & 0 deletions doc/getting_started/troubleshooting/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Plug-In Troubleshooting

## The plug-in did not activate

Restart the backend after changing `.pyrit_conf` or the source:

```powershell
pyrit_scan --stop-server
pyrit_scan --start-server --config-file /path/to/.pyrit_conf
```

`pyrit_scan` is a thin client. An already-running backend keeps the plug-in loaded from
its startup config.

## Configuration is rejected

A plug-in is one entry with three fields:

```yaml
plugins:
- name: my_redteam
source: /repos/my-redteam
initializer: my_redteam.setup.MyInitializer
```
Check that:
- `name` is a valid lowercase snake_case registry name;
- `source` is present (relative paths resolve against the config file);
- `initializer` is a dotted `module.Class` path;
- only one plug-in is configured.

## Source path does not exist

The loader fails closed with a "source path does not exist" error. Point `source` at the
directory that contains your package (the parent of the top-level package) so
`import <your_package>` resolves once that directory is on `sys.path`.

## Initializer cannot be imported

- Confirm `initializer` names a real `module.Class` importable from `source`.
- Install the plug-in's dependencies into the backend Python environment.
- A packaged initializer must import from its own package (for example
`from my_redteam... import ...`); pointing `source` at the package root makes that work.

## Target is not a PyRITInitializer

`initializer` must resolve to a concrete subclass of `PyRITInitializer`. A plug-in
contributes an initializer, not loose scenario or technique objects.

## Nothing shows up in the catalog

PyRIT does not discover components — the initializer must register them. For scanner
discovery, the initializer must call
`ScenarioRegistry.get_registry_singleton().register_class(...)` for scenarios and
`AttackTechniqueRegistry.get_registry_singleton().register_from_factories(...)` for
techniques. Datasets must be registered as providers and loaded into memory.

## Partial state after a failed load

Plug-in loading is fail-closed. A failed load aborts initialization; fix the reported
stage and restart the process. Continuing in the same process after a failed
initialization is not supported.
2 changes: 2 additions & 0 deletions doc/myst.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ project:
children:
- file: getting_started/populating_secrets.md
- file: getting_started/pyrit_conf.md
- file: getting_started/plugins.md
- file: getting_started/troubleshooting/README.md
children:
- file: getting_started/troubleshooting/installation.md
Expand All @@ -60,6 +61,7 @@ project:
- file: getting_started/troubleshooting/local_dev.md
- file: getting_started/troubleshooting/jupyter_setup.md
- file: getting_started/troubleshooting/azure_sql_db.md
- file: getting_started/troubleshooting/plugins.md
- file: getting_started/troubleshooting/deployment.md
children:
- file: getting_started/troubleshooting/deploy_hf_model_aml.ipynb
Expand Down
7 changes: 7 additions & 0 deletions pyrit/cli/pyrit_scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ def _print_cli_exception(*, exc: BaseException) -> None:
Requires a running PyRIT backend server. Use --start-server to launch one,
or connect to an existing server with --server-url.
Private scenario plug-ins:
Use a plug-in when private scenarios or attack techniques must behave like
built-ins in the stock scanner/backend/catalog. If you own a custom Python
application, importing PyRIT as a dependency and composing it directly is
usually simpler. Configure trusted source or wheel artifacts in .pyrit_conf.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale after the pivot - wheels are gone, config only takes source + initializer. This ships to every pyrit_scan --help, so worth fixing: drop "or wheel artifacts."

See doc/getting_started/plugins.md.
Examples:
# Start the backend server
pyrit_scan --start-server
Expand Down
4 changes: 4 additions & 0 deletions pyrit/exceptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
ExperimentalWarning,
InvalidJsonException,
MissingPromptPlaceholderException,
PluginLoadError,
PluginSourceNotFoundError,
PyritException,
RateLimitException,
ScorerLLMResponseBlockedException,
Expand Down Expand Up @@ -53,6 +55,8 @@
"handle_bad_request_exception",
"InvalidJsonException",
"MissingPromptPlaceholderException",
"PluginLoadError",
"PluginSourceNotFoundError",
"PyritException",
"pyrit_custom_result_retry",
"pyrit_json_retry",
Expand Down
8 changes: 8 additions & 0 deletions pyrit/exceptions/exception_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,14 @@ class ExperimentalWarning(FutureWarning):
"""


class PluginLoadError(RuntimeError):
"""Base error raised when a configured plug-in fails to load."""


class PluginSourceNotFoundError(PluginLoadError):
"""The configured plug-in source path does not point to a readable Python file or package."""


def pyrit_custom_result_retry(
retry_function: Callable[..., bool], retry_max_num_attempts: int | None = None
) -> Callable[..., Any]:
Expand Down
4 changes: 3 additions & 1 deletion pyrit/registry/components/attack_technique_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def register_technique(
name: str,
factory: AttackTechniqueFactory,
tags: dict[str, str] | list[str] | None = None,
metadata: dict[str, object] | None = None,
) -> None:
"""
Register an attack technique factory.
Expand All @@ -123,8 +124,9 @@ def register_technique(
tags (dict[str, str] | list[str] | None): Optional tags for categorisation.
Accepts a ``dict[str, str]`` or a ``list[str]`` (each string becomes a
key with value ``""``).
metadata (dict[str, object] | None): Optional non-selection metadata.
"""
self.instances.register(factory, name=name, tags=tags)
self.instances.register(factory, name=name, tags=tags, metadata=metadata)
logger.debug(f"Registered attack technique factory: {name} ({factory.attack_class.__name__})")

def get_factories(self) -> dict[str, AttackTechniqueFactory]:
Expand Down
Loading
Loading