FEAT Plug-in mechanism for private scenarios#2131
Conversation
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>
There was a problem hiding this comment.
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_loaderto extract a configured.whlinto.plugin/, import it, run a bootstrap (register()orPyRITInitializer), and validate registration with rollback-on-failure. - Hooks plug-in loading into
initialize_pyrit_asyncas a guaranteed-first phase (after memory is set, before other initializers), with aplugin_fail_openoverride. - Adds extensive unit coverage for loader behavior/guards and documents operator configuration via
.env_exampleand.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. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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
left a comment
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
Can we find a naming for this flag that's a bit clearer about what it does?
There was a problem hiding this comment.
Yes. Will patch this
There was a problem hiding this comment.
Is .env the right place for this? Intuitively I would have put it in .pyrit.conf.
There was a problem hiding this comment.
That's a much better place for it! I agree, I'll move it
|
|
||
| # 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" |
There was a problem hiding this comment.
Does that mean we support only one plugin? If so, I think it's a design limitation that might bite us later.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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_confinitializer, "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). SetPLUGIN_WHEELto a wheel on disk and the loader extracts it (stdlibzipfile, never pip or.venv) into.plugin/<name>/, prepends that dir tosys.path, imports the package (dataset providers self-register), runs the plug-in's bootstrap (a top-levelregister()callable or a shippedPyRITInitializersubclass that registers scenarios), and asserts the plug-in registered something. It is a no-op whenPLUGIN_WHEELis unset.Optional config:
PLUGIN_PACKAGE(auto-detected top-level package),PLUGIN_FAIL_OPEN(orinitialize_pyrit_async(plugin_fail_open=True)), andPLUGIN_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.whlonsys.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 ofsys.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-indataset_namecollides 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.envcan 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_confcomposition.Tests and Documentation
Tests:
tests/unit/setup/test_plugin_loader.pybuilds 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), scenarioregister_classsurviving 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 fulltests/unit/setup/suite passes (323). ruff, ty, and the async-suffix check are clean.Documentation: operator-facing configuration is documented inline in
.env_example(thePLUGIN_*variables) and.pyrit_conf_example(a note that plug-ins are not configured there). No Jupyter notebooks changed, so JupyText was not run.