Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
f36db28
FIX: Reject path parameters by default when building components from …
varunj-msft Oct 26, 2026
291c462
Leave out target types that require a path callers may not set
varunj-msft Oct 26, 2026
797831b
Test a target whose required path is the server upload folder
varunj-msft Oct 26, 2026
214a0fa
Merge origin/main
varunj-msft Nov 2, 2026
521ec7e
TEST: Cover saved converter copies in the server path check
varunj-msft Nov 2, 2026
66fd155
FEAT: Report the documents a storage listing cannot use
varunj-msft Nov 2, 2026
2d8c465
Report non-files and missing containers instead of skipping them
varunj-msft Nov 2, 2026
5dec828
Report an entry that can't be checked instead of failing the whole li…
varunj-msft Nov 2, 2026
bf662b7
FEAT: Read saved registry recipes
varunj-msft Nov 2, 2026
e8b0604
Merge the server path check for API input
varunj-msft Nov 2, 2026
9143ea6
FEAT: Rebuild saved registry entries in dependency order
varunj-msft Nov 2, 2026
c29d812
FEAT: Restore saved registry entries and stay not ready if any fail
varunj-msft Nov 2, 2026
cfa4fc0
Limit technique descriptions like the create request and export the r…
varunj-msft Nov 2, 2026
33735f1
Merge the recipe model limits and exports
varunj-msft Nov 2, 2026
11d0519
Keep restore failure messages safe and complete, and check technique …
varunj-msft Nov 2, 2026
8ab7116
Merge the restore message and credential fixes
varunj-msft Nov 2, 2026
b7bf012
Document the recipes setting in the example config and pin the restor…
varunj-msft Nov 2, 2026
e45865b
Test that a description at the limit is accepted
varunj-msft Nov 3, 2026
b6927a9
Merge the description limit test
varunj-msft Nov 3, 2026
c94ffbd
Show only the error type when a URL in it may carry credentials
varunj-msft Nov 3, 2026
ac849a9
Merge the safer failure messages
varunj-msft Nov 3, 2026
26377ea
Point the restore message at where failures are listed and guard the …
varunj-msft Nov 3, 2026
8663852
Cut failure messages at any URL query or fragment and hide any with u…
varunj-msft Nov 3, 2026
4159905
Merge the stricter failure messages
varunj-msft Nov 3, 2026
9a419b4
Escape text that isn't UTF-8 in restore reports so they can be sent a…
varunj-msft Nov 3, 2026
8fb8095
Merge the report text escaping
varunj-msft Nov 3, 2026
9a67367
Read error text as plain normalized text and say when part of it is h…
varunj-msft Nov 3, 2026
82c957f
Merge the plain, normalized failure text
varunj-msft Nov 3, 2026
274b4ce
Escape entry names in restore reports too
varunj-msft Nov 3, 2026
95f9492
Merge the entry name escaping
varunj-msft Nov 3, 2026
a60323a
Show error text with its original characters
varunj-msft Nov 3, 2026
e7558a4
Test that a reference name that isn't valid text can still be reported
varunj-msft Nov 3, 2026
0e8490c
Merge the original-character error text and escaped entry names
varunj-msft Nov 3, 2026
686d475
Test that the runtime status can report a reference name that isn't v…
varunj-msft Nov 3, 2026
c391b0d
Escape report text only when it is sent as JSON
varunj-msft Nov 3, 2026
0b44db6
Merge the JSON-only report escaping
varunj-msft Nov 3, 2026
f986da5
Check each character on its own so long error messages stay fast
varunj-msft Nov 3, 2026
807a904
Merge the per-character check and JSON-only report escaping
varunj-msft Nov 3, 2026
c86bfe6
Check the exact message for every form of each mark and bound the wor…
varunj-msft Nov 3, 2026
454d05b
Merge the exact look-alike message checks
varunj-msft Nov 3, 2026
9f91282
Test that the runtime status can list a file name that isn't valid text
varunj-msft Nov 3, 2026
a455494
Pass the look-alike check's arguments by keyword
varunj-msft Nov 3, 2026
ce8b684
Test that a technique waits for a saved converter in either list and …
varunj-msft Nov 3, 2026
d48a12c
Merge the converter-list and attack-type tests
varunj-msft Nov 3, 2026
74412fe
Test that restore runs after the startup scripts, at startup and on a…
varunj-msft Nov 3, 2026
784cd45
Keep the offline browser fixture away from saved registry entries
varunj-msft Nov 3, 2026
507567b
Check the opened file before reading it and skip links deleted since …
varunj-msft Nov 3, 2026
9d37ba3
Merge the opened-file check
varunj-msft Nov 3, 2026
ff2c836
Merge the opened-file check
varunj-msft Nov 3, 2026
3afdcaa
Merge origin/main
varunj-msft Nov 3, 2026
de2f4e3
Let restore set the path parameters a target lets callers set, as the…
varunj-msft Nov 3, 2026
e821e7e
Merge the opened-file check, main and the caller path parameters
varunj-msft Nov 3, 2026
a45ac23
Close a file that isn't regular before reporting it and report entrie…
varunj-msft Nov 3, 2026
60477e8
Merge the file handle fix
varunj-msft Nov 3, 2026
28f2262
Merge the file handle fix
varunj-msft Nov 3, 2026
ad21f0c
Give only targets the path parameters their class lets callers set
varunj-msft Nov 3, 2026
3dc4768
Merge the file handle fix and the target-only caller paths
varunj-msft Nov 3, 2026
db245dc
Test a file replaced just before it's opened and stop a blocked read …
varunj-msft Nov 4, 2026
5d3d2d0
Merge the opened-file tests
varunj-msft Nov 4, 2026
07a617f
Merge the opened-file tests
varunj-msft Nov 4, 2026
2b4f1cc
Merge the opened-file tests
varunj-msft Nov 4, 2026
80754a2
Read saved recipes separately from building them
varunj-msft Nov 4, 2026
7965020
Merge the separate recipe read
varunj-msft Nov 4, 2026
bd84ef0
Build restored entries on the runtime loop and read them in a worker …
varunj-msft Nov 4, 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: 8 additions & 0 deletions .pyrit_conf_example
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ allow_custom_initializers: false
# as scenario_presets_source. Defaults to ~/.pyrit/scenario_suites.
# scenario_suites_source: https://account.blob.core.windows.net/container/scenario_suites

# Optional storage for saved targets, converters, scorers, and techniques, so they
# come back when the backend restarts. Each kind has its own folder under the source.
# This supports the same local directory and Azure Blob sources as
# scenario_presets_source. If any saved entry can't be restored, the backend stays
# not ready and lists the failures in its runtime status. Defaults to
# ~/.pyrit/registry_recipes.
# registry_recipes_source: https://account.blob.core.windows.net/container/registry_recipes

# Upload Folder for API-Created Targets
# -------------------------------------
# Folder that targets created through the REST API may upload local files from
Expand Down
12 changes: 12 additions & 0 deletions doc/getting_started/pyrit_conf.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,18 @@ Suites store ordered references to scenario presets and resolve each preset's cu

A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/scenario_suites`. Local writes use the same hidden lock-file and atomic replacement behavior as scenario presets. Startup scans the configured source so malformed suite documents are reported immediately without preventing the backend from starting.

### `registry_recipes_source`

Stores saved targets, converters, scorers, and techniques in a local directory or Azure Blob container, so they come back when the backend restarts. An Azure URI may include a blob-name prefix:

```yaml
registry_recipes_source: https://account.blob.core.windows.net/pyrit-storage/registry-recipes
```

Each kind has its own folder under the source (`targets/`, `converters/`, `scorers/`, `techniques/`), with one JSON file per saved entry named after the entry. A file holds what's needed to rebuild the entry; restore refuses a file that stores a value for a parameter its class marks as a credential, such as `api_key`. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/registry_recipes`.

The backend restores saved entries at startup and after a live apply, once the configured initializers have registered theirs. If any saved entry can't be restored, the backend stays not ready (state `failed`, outcome `restore-failed`) and lists each failure with its reason in `GET /api/config/runtime`, so a scan can't quietly run without an entry it expects. Fix or delete the listed files, then restart. Files whose names aren't valid entry names (up to 64 lowercase letters, digits, and underscores, starting with a letter) are listed as ignored and don't block startup.

### `initialization_scripts`

Local paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory.
Expand Down
30 changes: 17 additions & 13 deletions frontend/e2e/fixtures/manual_send_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"""Offline backend for manual-send browser tests, with no real configuration or providers."""

import os
import tempfile
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch
Expand All @@ -28,19 +29,22 @@ async def _initialize_offline_async(self: ConfigurationLoader, *, raise_on_initi

@asynccontextmanager
async def _offline_lifespan_async(application: FastAPI) -> AsyncGenerator[None, None]:
config = ConfigurationLoader(memory_db_type="in_memory", env_files=[], env_akv_ref=[])
with (
patch.dict(os.environ, {"PYRIT_AUTH_MODE": "local", "PYRIT_DEV_MODE": "true"}, clear=True),
patch.object(
ConfigurationFileService,
"_read_source_async",
new=AsyncMock(return_value="memory_db_type: in_memory\nenv_files: []\ninitializers: []\n"),
),
patch.object(ConfigurationLoader, "load_with_overrides", return_value=config),
patch.object(ConfigurationLoader, "initialize_pyrit_async", new=_initialize_offline_async),
):
async with lifespan(application):
yield
with tempfile.TemporaryDirectory() as recipes:
config = ConfigurationLoader(
memory_db_type="in_memory", env_files=[], env_akv_ref=[], registry_recipes_source=recipes
)
with (
patch.dict(os.environ, {"PYRIT_AUTH_MODE": "local", "PYRIT_DEV_MODE": "true"}, clear=True),
patch.object(
ConfigurationFileService,
"_read_source_async",
new=AsyncMock(return_value="memory_db_type: in_memory\nenv_files: []\ninitializers: []\n"),
),
patch.object(ConfigurationLoader, "load_with_overrides", return_value=config),
patch.object(ConfigurationLoader, "initialize_pyrit_async", new=_initialize_offline_async),
):
async with lifespan(application):
yield


app.router.lifespan_context = _offline_lifespan_async
5 changes: 3 additions & 2 deletions pyrit/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,9 @@ The chat preserves failed drafts across replacement, but requires converter choi
to be reviewed again rather than restoring outputs tied to the previous registry.

The offline browser fixture `frontend.e2e.fixtures.manual_send_backend:app` uses the real
backend lifecycle with isolated in-memory SQLite, no environment files, and no default
providers. The recovery tests register deterministic loopback targets. For example, start
backend lifecycle with isolated in-memory SQLite, no environment files, no saved registry
entries, and no default providers. The recovery tests register deterministic loopback
targets. For example, start
it with `uv run python -m uvicorn frontend.e2e.fixtures.manual_send_backend:app --host 127.0.0.1 --port 18213`
(set `PYTHONUTF8=1` on Windows). In another shell, set `PYRIT_BACKEND_URL=http://127.0.0.1:18213`
and `E2E_FRONTEND_PORT=31213`, then run `npx playwright test chat-recovery --project seeded --workers 1`
Expand Down
50 changes: 47 additions & 3 deletions pyrit/backend/services/runtime_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import logging
import os
import uuid
from typing import Any
from typing import TYPE_CHECKING, Any

from fastapi import FastAPI

Expand All @@ -26,10 +26,14 @@
from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
from pyrit.memory import CentralMemory
from pyrit.registry import InitializerRegistry, ScenarioPresetRegistry, ScenarioSuiteRegistry
from pyrit.registry.registry_recipe_restore import RegistryRecipeRestorer
from pyrit.setup.configuration_loader import ConfigurationLoader
from pyrit.setup.environment_loading import resolve_environment_async
from pyrit.setup.initialization import validate_reinitialization_memory

if TYPE_CHECKING:
from pyrit.models.catalog.registry_recipe import IgnoredRecipeFile, RestoreFailure

logger = logging.getLogger(__name__)


Expand All @@ -46,6 +50,8 @@ def __init__(self, *, app: FastAPI, source: ConfigurationFileService) -> None:
self.version: str | None = None
self.outcome = "starting"
self.message = ""
self.restore_failures: list[RestoreFailure] = []
self.restore_ignored: list[IgnoredRecipeFile] = []
self.operations: set[asyncio.Task[None]] = set()
self.management_operations: set[asyncio.Task[None]] = set()
self.apply_task: asyncio.Task[None] | None = None
Expand Down Expand Up @@ -75,6 +81,8 @@ def status(self) -> dict[str, Any]:
"message": self.message,
"enabled": self.topology_supported,
"applying": self.apply_task is not None and not self.apply_task.done(),
"restore_failures": [failure.model_dump(mode="json") for failure in self.restore_failures],
"restore_ignored": [file.model_dump(mode="json") for file in self.restore_ignored],
}

async def _load_async(self) -> ConfigurationLoader:
Expand Down Expand Up @@ -119,6 +127,40 @@ def _publish(self, config: ConfigurationLoader) -> None:
self.generation = str(uuid.uuid4())
self.state, self.outcome, self.message = "ready", "success", "PyRIT is ready."

async def _restore_saved_entries_async(self, config: ConfigurationLoader) -> bool:
"""
Rebuild saved registry entries after the startup scripts have registered theirs.

A failure keeps the runtime not ready, since a scan that would have used a missing entry
could quietly fall back to a different one. Fixing the stored file and restarting recovers.

Args:
config (ConfigurationLoader): The loaded configuration naming the storage source.

Returns:
bool: Whether every saved entry was restored.
"""
restorer = RegistryRecipeRestorer(source=config.registry_recipes_source)
# Reading can wait on storage, so it runs in a worker thread. Entries are built on this loop, as
# the target API builds them, because a constructor such as HuggingFaceChatTarget's starts a task
# on it. A slow constructor holds the loop while it runs.
saved = await asyncio.to_thread(restorer.read)
report = restorer.restore(saved=saved)
self.restore_failures, self.restore_ignored = report.failures, report.ignored
if not report.failures:
return True
self.state, self.outcome = "failed", "restore-failed"
problems = f"{len(report.failures)} problem{'s' if len(report.failures) > 1 else ''}"
self.message = (
f"Saved registry entries couldn't be restored ({problems}). "
"Fix or delete the files named in the server log or GET /api/config/runtime, then restart the backend."
)
logger.error(
"Saved registry entries couldn't be restored: %s",
[f"{failure.kind.value}/{failure.name or '*'}: {failure.reason.value}" for failure in report.failures],
)
return False

async def startup_async(self) -> None:
"""Keep management alive even when parsing or initialization fails."""
self.app.state.allow_custom_initializers = False
Expand All @@ -137,7 +179,8 @@ async def startup_async(self) -> None:
await config.initialize_pyrit_async(raise_on_initializer_error=True)
await get_scenario_run_service().reconcile_interrupted_runs_async()
_, self.version = await self.source.read_with_version_async()
self._publish(config)
if await self._restore_saved_entries_async(config):
self._publish(config)
except Exception:
self.state, self.outcome = "restart-required", "initialization-failed"
self.message = "PyRIT setup failed. Repair saved configuration, then restart the backend."
Expand Down Expand Up @@ -219,7 +262,8 @@ async def _apply_async(self, *, version: str) -> None:
await config.apply_prepared_reinitialization_async(prepared=prepared)
await self._management_async(config)
self.version = current_version
self._publish(config)
if await self._restore_saved_entries_async(config):
self._publish(config)
except (Exception, asyncio.CancelledError) as error:
logger.exception(
"PyRIT live apply failed phase=%s generation=%s",
Expand Down
20 changes: 20 additions & 0 deletions pyrit/models/catalog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@

if TYPE_CHECKING:
from pyrit.models.catalog.initializer import RegisteredInitializer
from pyrit.models.catalog.registry_recipe import (
ComponentRecipe,
IgnoredRecipeFile,
RegistryRecipeKind,
RegistryRestoreReport,
RestoreFailure,
RestoreFailureReason,
SavedEntryRef,
StoredRecipe,
TechniqueRecipe,
)
from pyrit.models.catalog.scenario import (
AttackErrorSummary,
AttackRetrySummary,
Expand Down Expand Up @@ -60,9 +71,16 @@
_LAZY_EXPORTS: dict[str, str] = {
"AttackErrorSummary": "pyrit.models.catalog.scenario",
"AttackRetrySummary": "pyrit.models.catalog.scenario",
"ComponentRecipe": "pyrit.models.catalog.registry_recipe",
"IgnoredRecipeFile": "pyrit.models.catalog.registry_recipe",
"PresetEntry": "pyrit.models.catalog.scenario_preset",
"PresetIssue": "pyrit.models.catalog.scenario_preset",
"RegisteredInitializer": "pyrit.models.catalog.initializer",
"RegistryRecipeKind": "pyrit.models.catalog.registry_recipe",
"RegistryRestoreReport": "pyrit.models.catalog.registry_recipe",
"RestoreFailure": "pyrit.models.catalog.registry_recipe",
"RestoreFailureReason": "pyrit.models.catalog.registry_recipe",
"SavedEntryRef": "pyrit.models.catalog.registry_recipe",
"ScorerInstance": "pyrit.models.catalog.scorer",
"RegisteredScenario": "pyrit.models.catalog.scenario",
"RunScenarioRequest": "pyrit.models.catalog.scenario",
Expand All @@ -87,8 +105,10 @@
"ScenarioRunSummary": "pyrit.models.catalog.scenario",
"ScenarioTechniqueSummary": "pyrit.models.catalog.scenario",
"StoredPreset": "pyrit.models.catalog.scenario_preset",
"StoredRecipe": "pyrit.models.catalog.registry_recipe",
"StoredScenarioSuite": "pyrit.models.catalog.scenario_suite",
"TargetInstance": "pyrit.models.catalog.target",
"TechniqueRecipe": "pyrit.models.catalog.registry_recipe",
}

__all__ = list(_LAZY_EXPORTS)
Expand Down
Loading
Loading