Skip to content

FEAT Plug-in mechanism for private scenarios#2131

Open
ValbuenaVC wants to merge 12 commits into
microsoft:mainfrom
ValbuenaVC:vvalbuena-microsoft-plugin-mechanism
Open

FEAT Plug-in mechanism for private scenarios#2131
ValbuenaVC wants to merge 12 commits into
microsoft:mainfrom
ValbuenaVC:vvalbuena-microsoft-plugin-mechanism

Conversation

@ValbuenaVC

Copy link
Copy Markdown
Contributor

Description

Operators who use PyRIT extensively sometimes need to run bespoke scenario logic that cannot be published to the public repo and cannot be represented as database rows. Today there is no delivery path for that code into a stock public install. This PR adds a minimal plug-in mechanism so a private scenario can be shipped as a pre-built Python wheel and loaded at initialization, without those components living in the public repository. Datasets come along as a secondary benefit (a plug-in's dataset providers self-register on import), but the primary purpose is distributing private scenario code.

TL; DR How it works. Plug-in loading runs as a guaranteed-first phase inside initialize_pyrit_async, after central memory is set and before the configured initializers run. Because it is a fixed phase rather than a user-ordered .pyrit_conf initializer, "plug-ins register before anything reads the registry" is true by construction. This deliberately avoids putting a load-bearing ordering requirement into user-editable config (a silent-failure trap: a misordered conf would let scenario metadata warm before plug-in scenarios registered). Set PLUGIN_WHEEL to a wheel on disk and the loader extracts it (stdlib zipfile, never pip or .venv) into .plugin/<name>/, prepends that dir to sys.path, imports the package (dataset providers self-register), runs the plug-in's bootstrap (a top-level register() callable or a shipped PyRITInitializer subclass that registers scenarios), and asserts the plug-in registered something. It is a no-op when PLUGIN_WHEEL is unset.

Optional config: PLUGIN_PACKAGE (auto-detected top-level package), PLUGIN_FAIL_OPEN (or initialize_pyrit_async(plugin_fail_open=True)), and PLUGIN_DIR. It fails closed by default; fail-open skips a broken plug-in with a warning.

Why extraction, not install. Plug-in seed files load via __file__-relative paths; a raw .whl on sys.path (zipimport) makes those resolve inside the zip and the datasets silently vanish. Runtime pip/setuptools are also not guaranteed present (uv venvs ship without them). A wheel is just a zip, so stdlib extraction works on any environment.

Silent-failure guards (the non-obvious part). Each guard exists because it is a way a plug-in could load "successfully" yet be wrong: extraction rather than zipimport; atomic extraction (temp dir then move) so a crash never leaves a poisoned cache; submodule discovery so providers and the bootstrap register even if the package __init__ does not import them; a package-shadowing guard that fails loudly if an installed package of the same name shadows the wheel; fail-loud when a wheel imports cleanly but registers nothing; and full rollback of sys.path, imported modules, and registries on any failure, so a failed (or fail-open) load leaves no trace.

Dataset guardrail (worth a careful look). The dataset resolver treats central memory as authoritative and only consults a provider when memory has no seeds for that name, so a plug-in cannot override an existing same-named dataset (a scan would silently use the existing copy). To surface this, the loader warns loudly at load time (greppable PLUGIN DATASET SHADOWED: prefix, naming both providers) when a plug-in dataset_name collides with an existing registered provider's name. The check compares the provider registry rather than live memory on purpose: at that phase memory is not populated yet, and a live-memory check would false-positive on the plug-in's own datasets persisted from a prior run (the source is undecidable at load time). Precedence for names that exist only in shared storage without a provider is out of scope here and belongs to a separate dataset-management effort.

Trust boundary. Running a plug-in bootstrap executes third-party code in the PyRIT process, so whoever can set PLUGIN_* or write the backend's .env can run code on the host. The config is the trust boundary and should be treated as sensitive.

Scoped out of this PR (natural follow-ups): custom attacks/converters/targets as plug-ins, a live-iteration/editable-install workflow, nested dependency resolution, multi-plug-in composition, and .pyrit_conf composition.

Tests and Documentation

Tests: tests/unit/setup/test_plugin_loader.py builds a mock plug-in wheel at test time (no dependency on any real plug-in) and covers the full spine plus every guard: extract-not-zipimport, provider self-registration on import (including via submodule discovery), scenario register_class surviving lazy discovery and coexisting with built-ins, the no-arg-instantiable contract, the phase running after memory is set and before the configured initializers, package-name resolution, atomic-extraction cache reuse, the package-shadowing guard, the dataset-name collision warning (and no false positive on the plug-in's own reload), and all failure modes (fail-closed, fail-open, loud-on-empty, rollback). 43 test cases; the full tests/unit/setup/ suite passes (323). ruff, ty, and the async-suffix check are clean.

Documentation: operator-facing configuration is documented inline in .env_example (the PLUGIN_* variables) and .pyrit_conf_example (a note that plug-ins are not configured there). No Jupyter notebooks changed, so JupyText was not run.

Adds a plug-in mechanism so an operator can run non-disclosable scenarios (and
self-contained datasets) from a stock public PyRIT install, from a pre-built wheel
referenced by PLUGIN_WHEEL, without those components living in the public repo.

Plug-in loading runs as a guaranteed-first phase inside initialize_pyrit_async:
after central memory is set and before the configured initializers, so plug-in
datasets/scenarios register before LoadDefaultDatasets and PreloadScenarioMetadata
read the registry. Ordering is true by construction, independent of .pyrit_conf
list position. The wheel is extracted (stdlib zipfile - never pip/.venv) to
.plugin/<name>/, prepended to sys.path, imported (dataset providers self-register),
and its bootstrap (a top-level register() callable or a shipped PyRITInitializer
subclass) is run; the loader then asserts the plug-in registered something. No-op
when PLUGIN_WHEEL is unset.

Fails closed by default; fail-open via PLUGIN_FAIL_OPEN or
initialize_pyrit_async(plugin_fail_open=True). Guards against silent-failure modes:
extraction (not zipimport) so __file__-relative datasets resolve, atomic extraction,
submodule discovery so components register even if the package __init__ does not
import them, a package-shadowing guard when an installed package of the same name is
importable, a loud warning when a plug-in dataset name collides with an existing
name (the resolver is memory-authoritative, so the plug-in copy would otherwise be
silently ignored), and rollback of sys.path / sys.modules / registries on failure.

Wiring: .gitignore keeps .plugin/ (ignores its contents); .env_example documents the
PLUGIN_* variables. Tests build a mock wheel (no dependency on any real plug-in)
covering extraction, registration, ordering, collisions, and failure modes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

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.

Pull request overview

Adds a wheel-based plug-in loading mechanism to PyRIT initialization so operators can ship private scenarios (and dataset providers) outside the public repo, while ensuring plug-ins load before other initializers consume registries.

Changes:

  • Introduces pyrit.setup.plugin_loader to extract a configured .whl into .plugin/, import it, run a bootstrap (register() or PyRITInitializer), and validate registration with rollback-on-failure.
  • Hooks plug-in loading into initialize_pyrit_async as a guaranteed-first phase (after memory is set, before other initializers), with a plugin_fail_open override.
  • Adds extensive unit coverage for loader behavior/guards and documents operator configuration via .env_example and .pyrit_conf_example; ensures .plugin/ is kept but ignored in git.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/unit/setup/test_plugin_loader.py New unit test suite that builds a mock wheel and exercises loader spine + guardrails (fail-open/closed, rollback, collisions, cache, package inference).
pyrit/setup/plugin_loader.py New plug-in loader implementation: extraction, import/submodule discovery, bootstrap execution, collision warnings, and rollback.
pyrit/setup/initialization.py Adds the plug-in load phase into initialize_pyrit_async and exposes plugin_fail_open.
.pyrit_conf_example Documents that plug-ins are not configured via .pyrit_conf and are loaded automatically during initialization.
.plugin/.gitkeep Keeps .plugin/ directory present in the repo while allowing extracted artifacts to be ignored.
.gitignore Ignores extracted plug-in artifacts under .plugin/ while retaining .gitkeep.
.env_example Documents PLUGIN_WHEEL, PLUGIN_PACKAGE, PLUGIN_FAIL_OPEN, and PLUGIN_DIR configuration and trust boundary.

Comment thread pyrit/setup/plugin_loader.py Outdated
Comment thread pyrit/setup/plugin_loader.py Outdated
Comment thread pyrit/setup/plugin_loader.py Outdated
Comment thread pyrit/setup/plugin_loader.py Outdated
@ValbuenaVC ValbuenaVC changed the title [DRAFT] FEAT Plug-in mechanism for private scenarios FEAT Plug-in mechanism for private scenarios Jul 8, 2026
ValbuenaVC and others added 7 commits July 8, 2026 10:49
Adds an integration test that loads a plug-in wheel through initialize_pyrit_async
and asserts every scenario the wheel ships is registered in ScenarioRegistry.

Two cases: a self-contained case builds a small wheel at test time and checks its
scenarios are discovered (guards the mechanism in public CI); an injected case loads
a wheel supplied out-of-band via PLUGIN_TEST_WHEEL (+ PLUGIN_TEST_SCENARIO_DIRS or
PLUGIN_TEST_PACKAGE) and verifies all of its scenarios are picked up. The injected
case is skipped unless those variables are set, so the committed test depends on no
specific external package; a downstream CI job can point it at a real scenario wheel
to guarantee full discovery.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both the dataset provider registry (keyed by class name) and the scenario catalog
(keyed by registry name) assign unconditionally, so a plug-in whose provider or
scenario name collides with an existing one silently replaces the original on
registration. Rollback previously only deleted the keys the plug-in added, leaving a
collided entry permanently replaced after a failed (or fail-open) load.

Rollback now restores the pre-load value for any entry the plug-in overwrote, and
only deletes keys the plug-in newly added. Entries present before the load (including
built-ins discovered meanwhile) are still preserved. Adds unit tests covering the
provider and scenario overwrite-then-restore paths.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Replace the raw zipfile.extractall (and the hand-rolled path-traversal check) in the
wheel extractor with pyrit.common.safe_extract.safe_extract_zip, the existing helper
used for untrusted archive extraction. It validates every member before writing
anything: path traversal, absolute/drive paths, symlink and other non-regular entry
types, per-file and total size caps, entry count, and compression ratio (zip bomb),
then sanitizes extracted permissions. Extraction stays atomic (temp dir then
os.replace) and the cache-reuse path is unchanged.

Adds a unit test asserting a wheel with a path-traversal member is rejected and
nothing is written outside the extraction directory.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
_load_plugin_async is reached from the async initialization path but performed wheel
extraction and directory scanning inline, which blocks the event loop and serializes
unrelated async work during startup. Wrap the two filesystem-bound steps
(_extract_wheel and _resolve_package_name) in asyncio.to_thread. Package import and
bootstrap stay on the loop thread since they mutate global registries and sys state
and represent the registration step rather than pure I/O.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A single PluginLoadError collapsed every failure mode into one type. Add subclasses
so callers can distinguish causes: PluginWheelNotFoundError (PLUGIN_WHEEL missing or
not a .whl), PluginImportError (package import failed, incl. an installed package
shadowing the wheel), and PluginRegisteredNothingError (imported but registered
nothing). All subclass PluginLoadError, so existing `except PluginLoadError` handling
and the fail-open path are unchanged.

The load boundary preserves the specific type while still prefixing the standard
"Failed to load plug-in ... <remediation>" guidance; unknown errors (a raising
bootstrap, or an unsafe archive) remain a plain PluginLoadError. Tests assert the
specific type per failure mode and the shared hierarchy.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
_extract_wheel used a temp dir keyed only by wheel stem + PID. Once extraction was
moved off the event loop with asyncio.to_thread, two concurrent loads of the same
wheel in one process could collide on that shared path, where one thread's rmtree
clobbers the other's in-progress extraction (crash or corrupted cache). Allocate a
unique temp dir per extraction with tempfile.mkdtemp (atomic, 0700) so concurrent
extractions never share a path. Surfaced by security review of the to_thread change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@fdubut fdubut left a comment

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.

Also as mentioned in our side conversation, I think it would be useful to share an example of how a customer would package their datasets/scenarios to make them available as a plugin.

so local files take precedence over AKV. Requires ``azure-keyvault-secrets``.
silent (bool): If True, suppresses print statements about environment file loading and
schema migration. Defaults to False.
plugin_fail_open (bool | None): Overrides ``PLUGIN_FAIL_OPEN`` for plug-in loading. When True,

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.

Can we find a naming for this flag that's a bit clearer about what it does?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes. Will patch this

Comment thread .env_example

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.

Is .env the right place for this? Intuitively I would have put it in .pyrit.conf.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That's a much better place for it! I agree, I'll move it

Comment thread .env_example

# Path to a pre-built plug-in wheel on disk. Setting it enables plug-in loading;
# leaving it unset is a no-op.
# PLUGIN_WHEEL="/abs/path/to/my_plugin-0.0.0-py3-none-any.whl"

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.

Does that mean we support only one plugin? If so, I think it's a design limitation that might bite us later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

To answer your first question, yes, but not as an explicit constraint. I see two solutions to plug-in composition: we can just load multiple plug-ins and place the burden of compatibility on the user, or we can add a layer of abstraction, so that plug-in components can be collected, compared, and initialized in a certain order. I lean towards the second long-term, but in either case I don't think we should support only one plugin on purpose.

Load a non-disclosable PyRIT plug-in from a pre-built wheel at initialization time.

A plug-in is a pure-Python wheel that ships dataset providers and/or scenarios that
must not live in the public PyRIT repo. The loader **extracts** the wheel (stdlib

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.

I would even generalize the use case, it's not just about private datasets and scenarios from AIRT but any customer may want to package their code in a plugin that doesn't live collocated with the main PyRIT code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For this PR specifically I'm limiting test coverage to scenarios and datasets for tractability, but this is the long-term intent of this feature.

I want to publish a follow-up PR that handles building the plug-ins for users. I imagine it could look something like this:

pyrit_scan --build-plugin C:\Users\roakey\Dev\operation-super-secret\ --install-after y
Building plug-in from directory \`operation-super-secret\`...
[INFO] Installing plug-in \`A\` with initializer \`InitializerA\`...
[INFO] Discovered 3 scenarios...
[INFO] Scenarios all instantiated with no problems!
[INFO] Installing plug-in \`B\` with initializer \`InitializerB\`...
[INFO] Discovered 12 datasets...
[WARN] Dataset Foo is associated with file \`bar.prompt\`, but bar.prompt was not found! Foo has been excluded.
Done!

The plugin builder solves the packaging problem. A related problem is component support. There is nothing in principle stopping users from extending this to every PyRIT component. But guaranteeing this works smoothly presents a number of challenges I've chosen to scope out of this PR. Among them: naming collisions, syncing dependencies, race conditions/initializer precedence, pyrit/plugin version drift.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants