diff --git a/.pyrit_conf_example b/.pyrit_conf_example
index 51488ee0d8..4fbad18e24 100644
--- a/.pyrit_conf_example
+++ b/.pyrit_conf_example
@@ -161,6 +161,21 @@ 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. Fix or delete the listed
+# files, then apply the configuration or restart the backend. Defaults to
+# ~/.pyrit/registry_recipes.
+#
+# ⚠️ WARNING: The backend rebuilds every saved entry in this source as an
+# administrator, at startup and live reinitialization. Anyone who can write to this
+# source can make the backend run a model's repository code or use its identity.
+# Restrict write access to trusted administrators, and use a dedicated folder or
+# blob prefix.
+# 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
diff --git a/doc/getting_started/pyrit_conf.md b/doc/getting_started/pyrit_conf.md
index bba64b45d4..c00c7baf78 100644
--- a/doc/getting_started/pyrit_conf.md
+++ b/doc/getting_started/pyrit_conf.md
@@ -194,6 +194,20 @@ 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 the create request the entry was made with, and restore builds the entry through the API's create for its kind; 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`.
+
+A saved file doesn't record who saved it, so the backend rebuilds every entry in this source as an administrator, at startup and after a live apply. A file here can, for example, make the backend run a model's repository code, or use the server's identity on a storage container that the file names. Write access to this source is administrator authority on the backend: grant it only to principals you already trust as backend administrators, and use a dedicated directory or blob prefix that less-trusted users and services cannot write to.
+
+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 doesn't become 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. Until then, only the configuration and status routes answer; the rest return 503. Fix or delete the listed files, or, when a folder couldn't be listed, check that the backend can read this source. Then apply the configuration, if `enable_live_reinitialization` is on, or restart the backend. Files whose names aren't valid entry names (up to 64 lowercase letters, digits, and underscores, starting with a letter) are skipped without blocking startup; administrators can see them under `restore_ignored` in `GET /api/config/runtime`.
+
### `initialization_scripts`
Local paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory.
diff --git a/frontend/e2e/fixtures/manual_send_backend.py b/frontend/e2e/fixtures/manual_send_backend.py
index 0c46ed2398..8be9b44941 100644
--- a/frontend/e2e/fixtures/manual_send_backend.py
+++ b/frontend/e2e/fixtures/manual_send_backend.py
@@ -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
@@ -27,19 +28,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
diff --git a/frontend/src/components/Configuration/Reinitialize.test.tsx b/frontend/src/components/Configuration/Reinitialize.test.tsx
index 78614257fd..4d74590ca0 100644
--- a/frontend/src/components/Configuration/Reinitialize.test.tsx
+++ b/frontend/src/components/Configuration/Reinitialize.test.tsx
@@ -124,6 +124,26 @@ describe('Reinitialize', () => {
expect(screen.getByRole('button', { name: 'Reinitialize PyRIT' })).toBeDisabled()
})
+ it('should let a failed restore be retried by reinitializing', async () => {
+ const user = userEvent.setup()
+ api.getRuntimeStatus.mockResolvedValue({
+ ...ready,
+ state: 'failed',
+ outcome: 'restore-failed',
+ message: "1 saved registry entry couldn't be restored.",
+ })
+ render(
+
+
+ ,
+ )
+ expect(await screen.findByText(/Runtime: failed\. 1 saved registry entry/)).toBeInTheDocument()
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Reinitialize PyRIT' })).toBeEnabled())
+ await user.click(screen.getByRole('button', { name: 'Reinitialize PyRIT' }))
+ await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Reinitialize PyRIT' }))
+ await waitFor(() => expect(api.reinitialize).toHaveBeenCalledWith('saved-v1'))
+ })
+
describe('runtime status polling', () => {
beforeEach(() => {
jest.useFakeTimers()
diff --git a/pyrit/backend/README.md b/pyrit/backend/README.md
index 444cd2be87..ab3aae9ee6 100644
--- a/pyrit/backend/README.md
+++ b/pyrit/backend/README.md
@@ -691,8 +691,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`
diff --git a/pyrit/backend/middleware/error_handlers.py b/pyrit/backend/middleware/error_handlers.py
index 0beaff62cf..6258e6fd05 100644
--- a/pyrit/backend/middleware/error_handlers.py
+++ b/pyrit/backend/middleware/error_handlers.py
@@ -15,6 +15,7 @@
from pyrit.backend.models import FieldError, ProblemDetail
from pyrit.exceptions.analytics_exception import AnalyticsException
from pyrit.exceptions.dataset_exception import DatasetAPIException
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError
logger = logging.getLogger(__name__)
@@ -192,6 +193,38 @@ async def permission_error_handler( # pyrit-async-suffix-exempt
content=problem.model_dump(exclude_none=True),
)
+ @app.exception_handler(DocumentStorageUnavailableError)
+ async def storage_unavailable_handler( # pyrit-async-suffix-exempt
+ request: Request,
+ exc: DocumentStorageUnavailableError,
+ ) -> JSONResponse: # pyrit-keyword-only-exempt Starlette exception handler (request, exc)
+ """
+ Handle stored documents that can't be read or written as 503 Service Unavailable.
+
+ The log names the storage location, without credentials, and the reason. It leaves out the traceback,
+ since the error behind it can carry a SAS URL. The response never carries a path or URL.
+
+ Returns:
+ JSONResponse: RFC 7807 problem detail response with 503 status.
+ """
+ logger.warning(f"{exc} ({request.method} {request.url.path})")
+ if isinstance(exc, DocumentUnreadableError):
+ detail = f"{exc.label} '{exc.name}' can't be read: {exc.reason}"
+ else:
+ detail = f"{exc.label} storage is unavailable"
+ problem = ProblemDetail(
+ type="/errors/storage-unavailable",
+ title="Service Unavailable",
+ status=status.HTTP_503_SERVICE_UNAVAILABLE,
+ detail=detail,
+ instance=str(request.url.path),
+ )
+
+ return JSONResponse(
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
+ content=problem.model_dump(exclude_none=True),
+ )
+
@app.exception_handler(NotImplementedError)
async def not_implemented_handler( # pyrit-async-suffix-exempt
request: Request,
diff --git a/pyrit/backend/routes/initializers.py b/pyrit/backend/routes/initializers.py
index 50fa79db85..529f92d641 100644
--- a/pyrit/backend/routes/initializers.py
+++ b/pyrit/backend/routes/initializers.py
@@ -14,7 +14,6 @@
DELETE /api/initializers/{name} — unregister an initializer
"""
-from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pyrit.backend.middleware.auth import require_admin
@@ -35,19 +34,6 @@
router = APIRouter(prefix="/initializers", tags=["initializers"])
-def _custom_storage_unavailable() -> HTTPException:
- """
- Create a sanitized response for unavailable custom initializer storage.
-
- Returns:
- HTTPException: A service-unavailable response without SDK details.
- """
- return HTTPException(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Custom initializer storage is temporarily unavailable",
- )
-
-
def _configured_initializers(request: Request) -> list[ConfiguredInitializerSetting]:
"""
Read the initializer list captured from ``.pyrit_conf`` at backend startup.
@@ -138,10 +124,7 @@ async def list_custom_initializers(request: Request) -> CustomInitializerListRes
CustomInitializerListResponse: The configured source and stored scripts.
"""
_check_custom_initializers_allowed(request)
- try:
- return await get_initializer_service().list_custom_initializers_async()
- except AzureError as exc:
- raise _custom_storage_unavailable() from exc
+ return await get_initializer_service().list_custom_initializers_async()
@router.get(
@@ -205,8 +188,6 @@ async def register_initializer( # pyrit-async-suffix-exempt
try:
return await service.register_initializer_async(name=body.name, script_content=body.script_content)
- except AzureError as exc:
- raise _custom_storage_unavailable() from exc
except ValueError as e:
detail = str(e)
if "already registered" in detail:
@@ -243,8 +224,6 @@ async def unregister_initializer( # pyrit-async-suffix-exempt
try:
await service.unregister_initializer_async(initializer_name=initializer_name)
- except AzureError as exc:
- raise _custom_storage_unavailable() from exc
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
diff --git a/pyrit/backend/routes/scenario_presets.py b/pyrit/backend/routes/scenario_presets.py
index ad2880dd31..6d727491eb 100644
--- a/pyrit/backend/routes/scenario_presets.py
+++ b/pyrit/backend/routes/scenario_presets.py
@@ -19,7 +19,6 @@
from typing import Annotated
-from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pyrit.backend.middleware.auth import current_user_name, require_admin
@@ -38,19 +37,6 @@
router = APIRouter(prefix="/scenario-presets", tags=["scenario-presets"])
-def _storage_unavailable() -> HTTPException:
- """
- Create a sanitized response for unavailable preset storage.
-
- Returns:
- HTTPException: A service-unavailable response without SDK details.
- """
- return HTTPException(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Scenario preset storage is temporarily unavailable",
- )
-
-
def _not_found(name: str) -> HTTPException:
"""
Create a not-found response for a missing preset.
@@ -79,8 +65,6 @@ async def _load_preset_or_404_async(name: str) -> ScenarioPresetResponse:
"""
try:
preset = await get_scenario_preset_service().get_preset_async(name=name)
- except AzureError as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
@@ -106,10 +90,7 @@ async def list_scenario_presets( # pyrit-async-suffix-exempt
Returns:
ScenarioPresetListResponse: The configured source and the stored presets.
"""
- try:
- return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)
- except AzureError as exc:
- raise _storage_unavailable() from exc
+ return await get_scenario_preset_service().list_presets_async(include_estimates=include_estimates)
@router.get(
@@ -177,8 +158,6 @@ async def create_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{preset.name}' already exists",
) from exc
- except AzureError as exc:
- raise _storage_unavailable() from exc
@router.put(
@@ -239,8 +218,6 @@ async def update_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from exc
- except AzureError as exc:
- raise _storage_unavailable() from exc
@router.delete(
@@ -278,8 +255,6 @@ async def delete_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from None
- except (AzureError, TimeoutError) as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
@@ -329,8 +304,6 @@ async def resolve_scenario_preset( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
) from None
- except AzureError as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
diff --git a/pyrit/backend/routes/scenario_suites.py b/pyrit/backend/routes/scenario_suites.py
index 42501471c4..8cd0112916 100644
--- a/pyrit/backend/routes/scenario_suites.py
+++ b/pyrit/backend/routes/scenario_suites.py
@@ -6,7 +6,6 @@
from typing import Annotated
from uuid import UUID
-from azure.core.exceptions import AzureError
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pyrit.backend.middleware.auth import current_user_name, require_admin
@@ -18,6 +17,7 @@
)
from pyrit.backend.models.common import IdentifierStr
from pyrit.backend.services import get_scenario_suite_execution_service, get_scenario_suite_service
+from pyrit.backend.services.scenario_run_service import SavedEntryLaunchConflictError
from pyrit.backend.services.scenario_suite_execution_service import ScenarioSuiteExecutionCapacityError
from pyrit.models import (
LaunchScenarioSuiteRequest,
@@ -37,19 +37,6 @@
router = APIRouter(prefix="/scenario-suites", tags=["scenario-suites"])
-def _storage_unavailable() -> HTTPException:
- """
- Create a sanitized response for unavailable suite storage.
-
- Returns:
- HTTPException: A service-unavailable response without SDK details.
- """
- return HTTPException(
- status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="Scenario suite storage is temporarily unavailable",
- )
-
-
def _not_found(name: str) -> HTTPException:
"""
Create a not-found response for a missing suite.
@@ -72,8 +59,6 @@ async def _load_suite_or_404_async(name: str) -> ScenarioSuiteResponse:
"""
try:
suite = await get_scenario_suite_service().get_suite_async(name=name)
- except AzureError as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
@@ -90,10 +75,7 @@ async def list_scenario_suites() -> ScenarioSuiteListResponse: # pyrit-async-su
Returns:
ScenarioSuiteListResponse: Suites from both origins with current entry statuses.
"""
- try:
- return await get_scenario_suite_service().list_suites_async()
- except AzureError as exc:
- raise _storage_unavailable() from exc
+ return await get_scenario_suite_service().list_suites_async()
@router.post("/{name}/readiness", response_model=ScenarioReadiness)
@@ -116,8 +98,6 @@ async def get_scenario_suite_readiness_async(
raise HTTPException(status_code=409, detail=str(exc)) from None
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
- except AzureError as exc:
- raise _storage_unavailable() from exc
@router.get(
@@ -173,7 +153,10 @@ async def cancel_scenario_suite_execution( # pyrit-async-suffix-exempt
responses={
400: {"model": ProblemDetail, "description": "The suite has unresolved or invalid entries"},
404: {"model": ProblemDetail, "description": "Scenario suite not found"},
- 409: {"model": ProblemDetail, "description": "The suite or a referenced preset changed"},
+ 409: {
+ "model": ProblemDetail,
+ "description": "The suite or a referenced preset changed, or a launch script would replace a saved entry",
+ },
429: {"model": ProblemDetail, "description": "Active suite execution capacity is full"},
503: {"model": ProblemDetail, "description": "Runtime or definition storage unavailable"},
},
@@ -200,7 +183,7 @@ async def launch_scenario_suite( # pyrit-async-suffix-exempt
return await get_scenario_suite_execution_service().launch_async(name=name, request=body)
except ScenarioSuiteExecutionNotFoundError as exc:
raise _not_found(name) from exc
- except ScenarioSuiteLaunchConflictError as exc:
+ except (ScenarioSuiteLaunchConflictError, SavedEntryLaunchConflictError) as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from None
except ScenarioSuiteConflictError as exc:
raise HTTPException(
@@ -211,8 +194,6 @@ async def launch_scenario_suite( # pyrit-async-suffix-exempt
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
except ScenarioSuiteExecutionCapacityError as exc:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(exc)) from None
- except AzureError as exc:
- raise _storage_unavailable() from exc
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from None
@@ -269,8 +250,6 @@ async def create_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{suite.name}' already exists",
) from exc
- except AzureError as exc:
- raise _storage_unavailable() from exc
@router.put(
@@ -319,8 +298,6 @@ async def update_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{name}' changed since it was read; re-read it and retry",
) from exc
- except AzureError as exc:
- raise _storage_unavailable() from exc
@router.delete(
@@ -350,8 +327,6 @@ async def delete_scenario_suite( # pyrit-async-suffix-exempt
status_code=status.HTTP_409_CONFLICT,
detail=f"Scenario suite '{name}' changed since it was read; re-read it and retry",
) from None
- except (AzureError, TimeoutError) as exc:
- raise _storage_unavailable() from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from None
diff --git a/pyrit/backend/routes/scenarios.py b/pyrit/backend/routes/scenarios.py
index 8ac2cf2d7e..c7339e0146 100644
--- a/pyrit/backend/routes/scenarios.py
+++ b/pyrit/backend/routes/scenarios.py
@@ -248,7 +248,10 @@ async def load_scenario_datasets_async(
responses={
400: {"model": ProblemDetail, "description": "Invalid request (bad scenario/target/technique)"},
404: {"model": ProblemDetail, "description": "Saved run not found"},
- 409: {"model": ProblemDetail, "description": "Saved run cannot be resumed"},
+ 409: {
+ "model": ProblemDetail,
+ "description": "Saved run cannot be resumed, or a launch script would replace a saved entry",
+ },
},
)
async def start_scenario_run( # pyrit-async-suffix-exempt
diff --git a/pyrit/backend/services/runtime_lifecycle.py b/pyrit/backend/services/runtime_lifecycle.py
index daef21f0ed..ebeecf30c8 100644
--- a/pyrit/backend/services/runtime_lifecycle.py
+++ b/pyrit/backend/services/runtime_lifecycle.py
@@ -7,7 +7,8 @@
import logging
import os
import uuid
-from typing import Any
+from functools import partial
+from typing import TYPE_CHECKING, Any
from fastapi import FastAPI
@@ -15,6 +16,7 @@
from pyrit.backend.services.analytics_service import get_analytics_service
from pyrit.backend.services.configuration_file_service import ConfigurationFileService
from pyrit.backend.services.environment_file_service import EnvironmentFileService
+from pyrit.backend.services.saved_entry_restore import build_saved_entry_async
from pyrit.backend.services.scenario_run_service import get_scenario_run_service, peek_scenario_run_service
from pyrit.backend.services.scenario_suite_execution_service import peek_scenario_suite_execution_service
from pyrit.backend.services.service_lifecycle import (
@@ -26,10 +28,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 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 import IgnoredRecipeFile, RestoreFailure
+
logger = logging.getLogger(__name__)
@@ -46,6 +52,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
@@ -75,6 +83,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:
@@ -119,6 +129,52 @@ 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 or deleting the stored file, then applying
+ or 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, through
+ # the API's create for each kind, 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 = await restorer.restore_async(
+ saved=saved, build_async=partial(build_saved_entry_async, upload_directory=config.target_upload_directory)
+ )
+ self.restore_failures, self.restore_ignored = report.failures, report.ignored
+ if not report.failures:
+ return True
+ self.state, self.outcome = "failed", "restore-failed"
+ next_step = (
+ "apply the configuration or restart the backend"
+ if config.enable_live_reinitialization and self.topology_supported
+ else "restart the backend"
+ )
+ unread = sorted({failure.kind.value for failure in report.failures if failure.name is None})
+ if unread:
+ self.message = (
+ f"The saved {' and '.join(unread)} couldn't be read, so no saved entries were restored. "
+ f"Check that the backend can read registry_recipes_source, then {next_step}."
+ )
+ else:
+ count = len(report.failures)
+ self.message = (
+ f"{count} saved registry {'entry' if count == 1 else 'entries'} couldn't be restored. "
+ f"Fix or delete the files named in the server log or GET /api/config/runtime, then {next_step}."
+ )
+ logger.error("PyRIT isn't ready: %s", self.message)
+ return False
+
async def startup_async(self) -> None:
"""Keep management alive even when parsing or initialization fails."""
self.app.state.allow_custom_initializers = False
@@ -137,7 +193,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."
@@ -158,7 +215,7 @@ def begin_apply(self, *, version: str) -> dict[str, Any]:
"outcome": "unsupported",
"message": ("Reinitialization requires one backend worker and one replica."),
}
- if self.state in ("failed", "restart-required"):
+ if self.state == "restart-required":
return {**self.status(), "outcome": "restart-required"}
if (self.apply_task and not self.apply_task.done()) or self.edit_lock.locked() or self.management_operations:
return {**self.status(), "outcome": "busy"}
@@ -179,7 +236,9 @@ async def _apply_async(self, *, version: str) -> None:
if not config.enable_live_reinitialization:
self.outcome = "unsupported"
self.message = (
- "Set enable_live_reinitialization: true in the saved configuration to use live apply."
+ "Live apply is off. Restart the backend to restore the saved entries again."
+ if previous_state == "failed"
+ else "Set enable_live_reinitialization: true in the saved configuration to use live apply."
)
return
# Type compatibility is checked before contacting environment sources.
@@ -219,7 +278,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, BaseExceptionGroup) as error:
logger.exception(
"PyRIT live apply failed phase=%s generation=%s",
diff --git a/pyrit/backend/services/saved_entry_restore.py b/pyrit/backend/services/saved_entry_restore.py
new file mode 100644
index 0000000000..a4ac353988
--- /dev/null
+++ b/pyrit/backend/services/saved_entry_restore.py
@@ -0,0 +1,86 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Build saved registry entries through the API's own create for each kind."""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from pydantic import ValidationError
+
+from pyrit.backend.models import CreateConverterRequest, CreateScorerRequest, CreateTargetRequest
+from pyrit.backend.models.techniques import CreateTechniqueRequest
+from pyrit.backend.services.converter_service import get_converter_service
+from pyrit.backend.services.scorer_service import get_scorer_service
+from pyrit.backend.services.target_service import get_target_service
+from pyrit.backend.services.technique_service import get_technique_service
+from pyrit.models.catalog import RegistryRecipeKind, RestoreFailureReason, TechniqueRecipe
+from pyrit.registry.registry_recipe_restore import RecipeRejectedError
+
+if TYPE_CHECKING:
+ from pyrit.models.catalog import StoredRecipe
+
+
+async def build_saved_entry_async(stored: StoredRecipe, *, upload_directory: str | None) -> None:
+ """
+ Build one saved entry with the create the API uses for its kind, and register it under its name.
+
+ The entry passes the same checks a create request does. Restore runs as the server, and a saved file
+ doesn't record who saved it, so a target is built as an administrator would build it. A target that
+ uploads files gets this server's upload folder, as it does through the API.
+
+ Args:
+ stored (StoredRecipe): The checked recipe to build.
+ upload_directory (str | None): The folder this server lets targets upload files from, or None.
+
+ Raises:
+ RecipeRejectedError: If the recipe doesn't make a valid create request.
+ """
+ request = _create_request(stored)
+ if isinstance(request, CreateTechniqueRequest):
+ await get_technique_service().create_async(request)
+ elif isinstance(request, CreateTargetRequest):
+ await get_target_service().create_target_async(
+ request=request, upload_directory=upload_directory, is_admin=True
+ )
+ elif isinstance(request, CreateConverterRequest):
+ await get_converter_service().create_converter_async(request=request)
+ else:
+ await get_scorer_service().create_scorer_async(request=request)
+
+
+def _create_request(
+ stored: StoredRecipe,
+) -> CreateTargetRequest | CreateConverterRequest | CreateScorerRequest | CreateTechniqueRequest:
+ """
+ Turn a saved recipe back into the create request it was saved from.
+
+ Returns:
+ CreateTargetRequest | CreateConverterRequest | CreateScorerRequest | CreateTechniqueRequest: The request.
+
+ Raises:
+ RecipeRejectedError: If the recipe doesn't make a valid request, such as a technique with a reserved name.
+ """
+ recipe = stored.recipe
+ fields: dict[str, Any] = {"name": stored.ref.name}
+ try:
+ if isinstance(recipe, TechniqueRecipe):
+ return CreateTechniqueRequest.model_validate(
+ fields | recipe.model_dump(exclude_unset=True, exclude={"schema_version"})
+ )
+ fields |= {"type": recipe.class_name, "params": recipe.params}
+ if stored.ref.kind is RegistryRecipeKind.TARGETS:
+ if recipe.auth_mode is not None:
+ fields["auth_mode"] = recipe.auth_mode
+ return CreateTargetRequest.model_validate(fields)
+ if stored.ref.kind is RegistryRecipeKind.CONVERTERS:
+ return CreateConverterRequest.model_validate(fields)
+ return CreateScorerRequest.model_validate(fields)
+ except ValidationError as error:
+ problems = "; ".join(
+ f"{'.'.join(map(str, issue['loc'])) or 'request'}: {issue['msg']}" for issue in error.errors()
+ )
+ raise RecipeRejectedError(
+ reason=RestoreFailureReason.INVALID_RECIPE, message=f"Not a valid create request: {problems}"
+ ) from error
diff --git a/pyrit/backend/services/scenario_run_service.py b/pyrit/backend/services/scenario_run_service.py
index 1b50f68e5f..e5d5a3e6fc 100644
--- a/pyrit/backend/services/scenario_run_service.py
+++ b/pyrit/backend/services/scenario_run_service.py
@@ -103,6 +103,7 @@
from pyrit.models.scenario_readiness import ScenarioConfigurationError
from pyrit.prompt_target import PromptTarget
from pyrit.registry import InitializerRegistry, ScenarioRegistry
+from pyrit.registry.instance_registry import SavedEntryReplacementError, record_saved_entry_refusals
from pyrit.registry.resolution import reject_non_external_params, resolve_declared_params
from pyrit.scenario import IncompatibleTechniquePolicy, Scenario
from pyrit.scenario.core import get_default_adversarial_target, override_default_adversarial_target
@@ -192,6 +193,10 @@ class ScenarioRunConflictError(ValueError):
"""A saved run cannot be admitted to the scheduler in its current state."""
+class SavedEntryLaunchConflictError(ScenarioRunConflictError):
+ """A launch script would replace an entry rebuilt from a saved recipe."""
+
+
class ScenarioRunNotFoundError(ValueError):
"""The requested saved run is not in the active memory database."""
@@ -1657,6 +1662,7 @@ async def _run_initializers_async(self, *, request: RunScenarioRequest) -> None:
Raises:
ValueError: If an initializer name is not found in the registry.
+ SavedEntryLaunchConflictError: If an initializer would replace a saved entry.
"""
if not request.initializers:
return
@@ -1670,7 +1676,42 @@ async def _run_initializers_async(self, *, request: RunScenarioRequest) -> None:
)
except KeyError as e:
raise ValueError(f"Initializer not found: {e}") from None
- await instance.initialize_async()
+ # The registries refuse to replace a saved entry; the record also catches a refusal
+ # that the script itself caught, which would leave the saved entry in its place.
+ with record_saved_entry_refusals() as refusals:
+ try:
+ await instance.initialize_async()
+ except SavedEntryReplacementError as error:
+ refusals.append((error.kind, error.name))
+ except Exception as error:
+ # A script can wrap the refusal in an error of its own; the clash is still why it stopped.
+ if refusals:
+ raise self._saved_entry_conflict(
+ initializer_name=initializer_name, refusals=refusals
+ ) from error
+ raise
+ if refusals:
+ raise self._saved_entry_conflict(initializer_name=initializer_name, refusals=refusals)
+
+ @staticmethod
+ def _saved_entry_conflict(
+ *, initializer_name: str, refusals: list[tuple[str, str]]
+ ) -> SavedEntryLaunchConflictError:
+ """
+ Describe the saved entries a launch script would have replaced.
+
+ Args:
+ initializer_name (str): The launch script's name.
+ refusals (list[tuple[str, str]]): The kind and name of each saved entry that was refused.
+
+ Returns:
+ SavedEntryLaunchConflictError: The error to raise.
+ """
+ entries = ", ".join(f"{kind.removesuffix('s')} '{name}'" for kind, name in dict.fromkeys(refusals))
+ return SavedEntryLaunchConflictError(
+ f"The launch script '{initializer_name}' would replace the saved {entries}. "
+ "Leave that script out of the launch."
+ )
async def _initialize_scenario_async(
self,
diff --git a/pyrit/backend/services/scenario_suite_service.py b/pyrit/backend/services/scenario_suite_service.py
index 65cf984e94..ce1e436c2b 100644
--- a/pyrit/backend/services/scenario_suite_service.py
+++ b/pyrit/backend/services/scenario_suite_service.py
@@ -9,8 +9,6 @@
from functools import lru_cache
from typing import Literal
-from azure.core.exceptions import AzureError
-
from pyrit.backend.models import (
ScenarioSuiteEntryStatus,
ScenarioSuiteListResponse,
@@ -25,6 +23,7 @@
ScenarioSuiteRegistryEntry,
)
from pyrit.registry import ScenarioPresetRegistry, ScenarioSuiteRegistry
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
logger = logging.getLogger(__name__)
@@ -217,12 +216,9 @@ def _resolve_preset_projection(
)
stored_version = registry.get_preset_version(name=preset_name)
- except (AzureError, OSError):
- logger.warning(
- "Could not project scenario preset '%s' because preset storage is unavailable.",
- preset_name,
- exc_info=True,
- )
+ except DocumentStorageUnavailableError as error:
+ # The message leaves out credentials; the error behind it, which a traceback would show, may not.
+ logger.warning(f"Could not project scenario preset '{preset_name}': {error}")
return _PresetProjection(
origin=None,
version=None,
diff --git a/pyrit/common/azure_storage.py b/pyrit/common/azure_storage.py
index d3ed6eeb4f..dfce6b2e08 100644
--- a/pyrit/common/azure_storage.py
+++ b/pyrit/common/azure_storage.py
@@ -3,8 +3,16 @@
"""Lightweight Azure Storage URI helpers."""
+import re
from urllib.parse import parse_qs, urlparse
+# A scheme followed by "://" and everything up to whitespace, so a quote can't end a URL before its query.
+# A scheme starts only where one can't continue from the character before it, such as after "_" or a
+# space, so each run of scheme characters is scanned once and long text stays fast.
+_URL_IN_TEXT = re.compile(r"(? bool:
"""
@@ -48,9 +56,30 @@ def has_sas_signature(value: str) -> bool:
def redact_url_credentials(value: str) -> str:
"""
- Remove query parameters and fragments from a URL for display or logging.
+ Remove the user name and password, query parameters, and fragment from a URL for display or logging.
+
+ Returns:
+ str: The URL without them.
+ """
+ parsed = urlparse(value)
+ return parsed._replace(netloc=parsed.netloc.rpartition("@")[2], query="", fragment="").geturl()
+
+
+def redact_urls(text: str) -> str:
+ """
+ Remove the user name and password, query parameters, and fragment from every URL in a text.
+
+ A query or user name can hold a credential, such as a SAS token. A URL that can't be parsed is
+ hidden entirely, and a SAS signature is hidden wherever it appears.
Returns:
- str: The URL without query parameters or a fragment.
+ str: The text with each URL redacted.
"""
- return urlparse(value)._replace(query="", fragment="").geturl()
+
+ def redact(match: re.Match[str]) -> str:
+ try:
+ return redact_url_credentials(match.group(0))
+ except ValueError:
+ return ""
+
+ return _SAS_SIGNATURE.sub("sig=", _URL_IN_TEXT.sub(redact, text))
diff --git a/pyrit/models/catalog/__init__.py b/pyrit/models/catalog/__init__.py
index 102788e2a8..a113a8bef0 100644
--- a/pyrit/models/catalog/__init__.py
+++ b/pyrit/models/catalog/__init__.py
@@ -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,
@@ -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",
@@ -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)
diff --git a/pyrit/models/catalog/registry_recipe.py b/pyrit/models/catalog/registry_recipe.py
new file mode 100644
index 0000000000..c381f1c763
--- /dev/null
+++ b/pyrit/models/catalog/registry_recipe.py
@@ -0,0 +1,175 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Saved registry entries: the recipe kept for each one and what restoring them found."""
+
+from __future__ import annotations
+
+from enum import Enum
+from typing import Annotated
+
+from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator
+
+from pyrit.common import AuthMode # noqa: TC001 (runtime-required by Pydantic)
+from pyrit.models.request_limits import MAX_IDENTIFIER_LENGTH, MAX_ITEMS, MAX_TEXT_LENGTH
+
+RECIPE_SCHEMA_VERSION = 1
+
+_Identifier = Annotated[str, Field(max_length=MAX_IDENTIFIER_LENGTH)]
+_TechniqueTag = Annotated[str, Field(pattern=r"^[A-Za-z][A-Za-z0-9_]{0,63}$")]
+
+
+class RegistryRecipeKind(str, Enum):
+ """The registries whose entries can be saved, named after their storage folders."""
+
+ TARGETS = "targets"
+ CONVERTERS = "converters"
+ SCORERS = "scorers"
+ TECHNIQUES = "techniques"
+
+
+class _RegistryRecipe(BaseModel):
+ """Fields every saved recipe shares. The entry's name is its document name, so it isn't repeated."""
+
+ model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
+
+ # A literal 1 rather than RECIPE_SCHEMA_VERSION: a document saved without this field is a version 1
+ # document and must keep reading as one after the constant is bumped, or a migration would skip it.
+ schema_version: int = Field(1, ge=1, le=RECIPE_SCHEMA_VERSION, description="Shape of this document")
+
+
+class ComponentRecipe(_RegistryRecipe):
+ """How to rebuild one saved target, converter, or scorer."""
+
+ class_module: _Identifier = Field(
+ ..., min_length=1, description="Module that defined the class when the entry was saved"
+ )
+ class_name: _Identifier = Field(..., min_length=1, description="Registered class name")
+ auth_mode: AuthMode | None = Field(
+ None, description="How a saved target signs in, as its create request chose; None for converters and scorers"
+ )
+ params: dict[_Identifier, JsonValue] = Field(
+ default_factory=dict,
+ max_length=MAX_ITEMS,
+ description="Constructor parameters as the API accepted them; other entries are named, never embedded",
+ )
+
+
+class TechniqueRecipe(_RegistryRecipe):
+ """
+ How to rebuild one saved technique: the fields of its create request, without the name.
+
+ This is a deliberate copy of the technique API's create request, so a change to the API is also a decision
+ about the saved format. Tests keep the two in step.
+ """
+
+ type: str = Field(..., min_length=1, max_length=256, description="Registered attack type")
+ params: dict[_Identifier, JsonValue] = Field(
+ default_factory=dict, max_length=MAX_ITEMS, description="Attack constructor parameters"
+ )
+ description: str | None = Field(default=None, max_length=MAX_TEXT_LENGTH)
+ tags: list[_TechniqueTag] = Field(default_factory=list, max_length=MAX_ITEMS)
+ request_converters: list[_Identifier] | None = Field(default=None, max_length=MAX_ITEMS)
+ response_converters: list[_Identifier] | None = Field(default=None, max_length=MAX_ITEMS)
+ adversarial_chat: _Identifier | None = None
+ adversarial_system_prompt: str | None = None
+ adversarial_seed_prompt: str | None = None
+ adversarial_prompt_template: str | None = None
+
+ @field_validator("tags")
+ @classmethod
+ def _validate_tags(cls, value: list[str]) -> list[str]:
+ folded = {tag.casefold() for tag in value}
+ if len(folded) != len(value):
+ raise ValueError("Tags must be unique, including letter case")
+ if {"all", "default"} & folded:
+ raise ValueError("all and default are reserved selectors")
+ return value
+
+
+class SavedEntryRef(BaseModel):
+ """Points at one saved entry."""
+
+ model_config = ConfigDict(frozen=True)
+
+ kind: RegistryRecipeKind
+ name: str
+
+
+class StoredRecipe(BaseModel):
+ """A saved recipe and the version of the document it was read from."""
+
+ ref: SavedEntryRef
+ recipe: ComponentRecipe | TechniqueRecipe
+ version: str = Field(..., description="Opaque version of the document this recipe was read from")
+
+ @model_validator(mode="after")
+ def _validate_recipe_kind(self) -> StoredRecipe:
+ """
+ Reject a recipe filed under a registry it doesn't fit.
+
+ A technique recipe belongs only under techniques, and only a target has an ``auth_mode``.
+
+ Returns:
+ StoredRecipe: The stored recipe.
+
+ Raises:
+ ValueError: If the recipe doesn't fit the registry it's filed under.
+ """
+ expects_technique = self.ref.kind is RegistryRecipeKind.TECHNIQUES
+ if expects_technique != isinstance(self.recipe, TechniqueRecipe):
+ expected = TechniqueRecipe if expects_technique else ComponentRecipe
+ raise ValueError(f"A saved {self.ref.kind.value} entry needs a {expected.__name__}")
+ has_auth_mode = isinstance(self.recipe, ComponentRecipe) and self.recipe.auth_mode is not None
+ if has_auth_mode and self.ref.kind is not RegistryRecipeKind.TARGETS:
+ raise ValueError(f"Only a saved target has an auth_mode, not a saved {self.ref.kind.value} entry")
+ return self
+
+
+class RestoreFailureReason(str, Enum):
+ """Why a saved entry couldn't be restored."""
+
+ UNREADABLE_STORAGE = "unreadable_storage"
+ UNREADABLE_RECIPE = "unreadable_recipe"
+ INVALID_RECIPE = "invalid_recipe"
+ NEWER_SCHEMA_VERSION = "newer_schema_version"
+ UNKNOWN_CLASS = "unknown_class"
+ MODULE_MISMATCH = "module_mismatch"
+ UNSUPPORTED_CLASS = "unsupported_class"
+ UNSUPPORTED_CREDENTIAL = "unsupported_credential"
+ NAME_CLASH = "name_clash"
+ MISSING_DEPENDENCY = "missing_dependency"
+ FAILED_DEPENDENCY = "failed_dependency"
+ DEPENDENCY_LOOP = "dependency_loop"
+ BUILD_ERROR = "build_error"
+
+
+class RestoreFailure(BaseModel):
+ """One saved entry, or one whole storage folder, that couldn't be restored."""
+
+ model_config = ConfigDict(frozen=True)
+
+ kind: RegistryRecipeKind
+ name: str | None = Field(None, description="Entry name; None when the whole folder couldn't be read")
+ reason: RestoreFailureReason
+ message: str = Field(..., description="Why, in words safe to show users")
+ version: str | None = Field(None, description="Version of the stored document, when it could be read")
+ dependency: SavedEntryRef | None = Field(None, description="The entry this one needed that is missing or failed")
+
+
+class IgnoredRecipeFile(BaseModel):
+ """A stored file whose name can't be an entry name, so it was never read."""
+
+ model_config = ConfigDict(frozen=True)
+
+ kind: RegistryRecipeKind
+ file_name: str
+ message: str
+
+
+class RegistryRestoreReport(BaseModel):
+ """What a restore rebuilt, what it couldn't, and which files it skipped."""
+
+ restored: list[SavedEntryRef] = Field(default_factory=list)
+ failures: list[RestoreFailure] = Field(default_factory=list)
+ ignored: list[IgnoredRecipeFile] = Field(default_factory=list)
diff --git a/pyrit/registry/components/attack_technique_registry.py b/pyrit/registry/components/attack_technique_registry.py
index 059b93afba..0675f95d85 100644
--- a/pyrit/registry/components/attack_technique_registry.py
+++ b/pyrit/registry/components/attack_technique_registry.py
@@ -31,7 +31,12 @@
from pyrit.models import AttackIdentifier, ComponentType
from pyrit.models.component_spec import SourceInstanceSpec
-from pyrit.registry.instance_registry import DefaultInstanceRegistry
+from pyrit.registry.instance_registry import (
+ DefaultInstanceRegistry,
+ RegistryNameTakenError,
+ _record_saved_entry_refusal,
+ refuse_saved_entry,
+)
from pyrit.registry.registry import Registry
from pyrit.registry.registry_metadata import RegistryMetadata
@@ -132,15 +137,29 @@ class AttackTechniqueMetadata(RegistryMetadata):
"""Shared metadata type for the inherited, empty technique class catalog."""
+class TechniqueSelectorConflictError(RegistryNameTakenError):
+ """A technique's name or tag conflicts with the name or a tag of a registered technique."""
+
+
+class TechniqueAdmissionError(ValueError):
+ """A technique can't be registered for a reason of its own, such as a name a scenario can't use."""
+
+
class TechniqueInstanceRegistry(DefaultInstanceRegistry["AttackTechniqueFactory"]):
"""Factory storage with an atomic runtime-admission path."""
- def register_runtime(self, factory: AttackTechniqueFactory) -> None:
+ def register_runtime(self, factory: AttackTechniqueFactory, *, metadata: dict[str, Any] | None = None) -> None:
"""
Check selector collisions while holding the same lock as registration.
+ Args:
+ factory (AttackTechniqueFactory): The technique to register.
+ metadata (dict[str, Any] | None): Optional per-entry metadata. Defaults to None.
+
Raises:
- ValueError: If the name or a tag conflicts with an existing selector.
+ TechniqueSelectorConflictError: If the name or a tag conflicts with another technique's name or tag.
+ TechniqueAdmissionError: If a tag repeats the technique's own name, a selector can't become a scenario
+ member, or the factory doesn't fit the scenarios it would join.
"""
from pyrit.registry.components.scenario_registry import ScenarioRegistry
@@ -152,24 +171,33 @@ def register_runtime(self, factory: AttackTechniqueFactory) -> None:
tags = {tag.casefold(): tag for entry in entries for tag in entry.instance.technique_tags}
folded_name = factory.name.casefold()
if folded_name in names:
- raise ValueError(f"Technique '{factory.name}' already exists (names are case-insensitive)")
+ # Code that catches this error would leave a saved technique standing in, so the clash is recorded.
+ _record_saved_entry_refusal(self.get_entry(names[folded_name]))
+ raise TechniqueSelectorConflictError(
+ f"Technique '{factory.name}' already exists (names are case-insensitive)"
+ )
if folded_name in tags:
- raise ValueError(f"Technique name '{factory.name}' conflicts with an aggregate tag")
+ raise TechniqueSelectorConflictError(f"Technique name '{factory.name}' conflicts with an aggregate tag")
for tag in factory.technique_tags:
folded = tag.casefold()
- if folded in names or folded == folded_name:
- raise ValueError(f"Tag '{tag}' conflicts with a technique name")
+ if folded == folded_name:
+ raise TechniqueAdmissionError(f"Tag '{tag}' repeats the technique's name")
+ if folded in names:
+ raise TechniqueSelectorConflictError(f"Tag '{tag}' conflicts with a technique name")
if folded in tags and tags[folded] != tag:
- raise ValueError(f"Tag '{tag}' conflicts with tag '{tags[folded]}'")
- _validate_generated_member_collisions(
- class_name="ScenarioTechnique",
- factories=[factory],
- aggregate_tags=set(factory.technique_tags) - {"all", "default"},
- )
- AttackTechniqueRegistry.get_registry_singleton().validate_scenario_pools(
- {**{entry.name: entry.instance for entry in entries}, factory.name: factory}
- )
- self.register(factory, name=factory.name, tags=factory.technique_tags)
+ raise TechniqueSelectorConflictError(f"Tag '{tag}' conflicts with tag '{tags[folded]}'")
+ try:
+ _validate_generated_member_collisions(
+ class_name="ScenarioTechnique",
+ factories=[factory],
+ aggregate_tags=set(factory.technique_tags) - {"all", "default"},
+ )
+ AttackTechniqueRegistry.get_registry_singleton().validate_scenario_pools(
+ {**{entry.name: entry.instance for entry in entries}, factory.name: factory}
+ )
+ except ValueError as error:
+ raise TechniqueAdmissionError(str(error)) from error
+ self.register(factory, name=factory.name, tags=factory.technique_tags, metadata=metadata)
class AttackTechniqueRegistry(Registry["AttackTechniqueFactory", AttackTechniqueMetadata]):
@@ -752,8 +780,13 @@ def register_from_factories(
factories (list[AttackTechniqueFactory]): Self-describing factories to
register. Each factory's ``name`` and ``technique_tags`` properties are
used directly.
+
+ Raises:
+ SavedEntryReplacementError: If a factory's name belongs to a technique rebuilt
+ from a saved recipe, which skipping would quietly use in its place.
"""
for factory in factories:
+ refuse_saved_entry(self.instances.get_entry(factory.name))
if factory.name not in self.instances:
tags: dict[str, str] = dict.fromkeys(factory.technique_tags, "")
self.register_technique(
diff --git a/pyrit/registry/components/initializer_registry.py b/pyrit/registry/components/initializer_registry.py
index 6e1e283d2d..53d06fb8a5 100644
--- a/pyrit/registry/components/initializer_registry.py
+++ b/pyrit/registry/components/initializer_registry.py
@@ -420,6 +420,7 @@ def register_from_content(self, *, name: str, script_content: str) -> str:
ValueError: If the source cannot be compiled, does not
define a concrete ``PyRITInitializer`` subclass, or
*name* collides with an existing entry.
+ DocumentStorageUnavailableError: If the script storage can't be written.
"""
self._ensure_discovered()
@@ -429,11 +430,7 @@ def register_from_content(self, *, name: str, script_content: str) -> str:
raise ValueError(f"Initializer '{name}' is already registered. Unregister it first to replace it.")
discovered = self._load_custom_initializer_class(name=name, script_content=script_content)
- storage = self._get_custom_storage()
- try:
- storage.save_script(name=name, content=script_content)
- except OSError as error:
- raise ValueError(f"Failed to write initializer script: {error}") from error
+ self._get_custom_storage().save_script(name=name, content=script_content)
self.register_class(discovered, name=name)
logger.info(f"Registered custom initializer: {name} ({discovered.__name__})")
@@ -484,10 +481,12 @@ def unregister_and_cleanup(self, name: str) -> None:
Raises:
KeyError: If no stored custom initializer has the requested name.
ValueError: If the name refers to a built-in initializer.
+ DocumentStorageUnavailableError: If the script storage can't be used.
"""
self._ensure_discovered()
storage = self._get_custom_storage()
- stored_names = storage.list_scripts().keys()
+ # A script that can't be read or decoded is still deleted, so the API can clean it up.
+ stored_names = sorted(storage.list_script_names())
if name not in stored_names:
if name in self._builtin_names:
raise ValueError(f"Cannot remove built-in initializer '{name}'.")
diff --git a/pyrit/registry/custom_initializer_storage.py b/pyrit/registry/custom_initializer_storage.py
index 572c838c00..90ec10695f 100644
--- a/pyrit/registry/custom_initializer_storage.py
+++ b/pyrit/registry/custom_initializer_storage.py
@@ -67,6 +67,16 @@ def list_scripts(self) -> dict[str, str]:
logger.warning(f"Skipping stored initializer '{name}': it is not valid UTF-8 text.")
return scripts
+ def list_script_names(self) -> set[str]:
+ """
+ List the names of every stored script, including ones that can't be read or decoded.
+
+ Returns:
+ set[str]: The registry names of the stored scripts.
+ """
+ listing = self._list_documents_with_issues()
+ return set(listing.documents) | {unreadable.name for unreadable in listing.unreadable}
+
def save_script(self, *, name: str, content: str) -> None:
"""Persist one custom initializer script."""
self._save_document(name=name, content=content.encode("utf-8"))
diff --git a/pyrit/registry/file_document_storage.py b/pyrit/registry/file_document_storage.py
index 26446ec1dc..89f3cfddf6 100644
--- a/pyrit/registry/file_document_storage.py
+++ b/pyrit/registry/file_document_storage.py
@@ -12,14 +12,19 @@
from __future__ import annotations
+import errno
+import fnmatch
import hashlib
import logging
import os
+import stat
import sys
import tempfile
import time
from contextlib import contextmanager, suppress
+from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
+from types import MappingProxyType
from typing import TYPE_CHECKING
from urllib.parse import unquote, urlparse
@@ -27,12 +32,100 @@
from pyrit.models import validate_registry_name
if TYPE_CHECKING:
- from collections.abc import Generator
+ from collections.abc import Generator, Mapping
from azure.storage.blob import ContainerClient
logger = logging.getLogger(__name__)
+# Called through these names so tests can change the folder between steps without patching ``os`` for the
+# whole process.
+_open = os.open
+_stat = os.stat
+_lstat = os.lstat
+_scandir = os.scandir
+
+
+@dataclass(frozen=True)
+class UnreadableDocument:
+ """
+ A stored document with a valid name whose content could not be read.
+
+ ``message`` is a short reason that is safe to show. ``error`` is what stopped the read, kept for logging and
+ for telling a passing failure from a broken file; it is None when nothing raised, such as for a folder.
+ """
+
+ name: str
+ file_name: str
+ message: str
+ error: BaseException | None = field(default=None, compare=False, repr=False)
+
+
+@dataclass(frozen=True)
+class IgnoredDocument:
+ """A stored file whose name breaks the naming rule, so no single-document call can address it."""
+
+ file_name: str
+ message: str
+
+
+@dataclass(frozen=True)
+class DocumentListing:
+ """Every document a listing found, including the ones it could not use."""
+
+ documents: Mapping[str, bytes] = field(default_factory=lambda: MappingProxyType({}))
+ unreadable: tuple[UnreadableDocument, ...] = ()
+ ignored: tuple[IgnoredDocument, ...] = ()
+
+
+class DocumentStorageUnavailableError(Exception):
+ """
+ The folder or container that holds the documents couldn't be used, so nothing was read or written.
+
+ The message names the location for logs; ``display_source`` already hides a SAS token. ``reason`` is short and
+ carries no path or URL, so an API response can show it.
+ """
+
+ def __init__(self, *, label: str, source: str, reason: str, message: str | None = None) -> None:
+ """
+ Initialize the error.
+
+ Args:
+ label (str): The kind of document, such as "Scenario preset".
+ source (str): The storage location, without credentials.
+ reason (str): A short reason without a path or URL, such as "Permission denied".
+ message (str | None): The full message, or None for the default one. Defaults to None.
+ """
+ super().__init__(message or f"{label} storage at {source} is unavailable: {reason}")
+ self.label = label
+ self.source = source
+ self.reason = reason
+
+
+class DocumentUnreadableError(DocumentStorageUnavailableError):
+ """One stored document exists but couldn't be read, such as a file without read permission or a folder."""
+
+ def __init__(self, *, label: str, source: str, name: str, file_name: str, reason: str) -> None:
+ """
+ Initialize the error.
+
+ Args:
+ label (str): The kind of document, such as "Scenario preset".
+ source (str): The storage location, without credentials.
+ name (str): The document's name.
+ file_name (str): The file or blob name in storage.
+ reason (str): A short reason without a path or URL, such as "Not a regular file".
+ """
+ super().__init__(
+ label=label,
+ source=source,
+ reason=reason,
+ message=f"{label} '{file_name}' in {source} can't be read: {reason}",
+ )
+ self.name = name
+ self.file_name = file_name
+
+
# Both platforms lock a single byte at offset zero, which they allow past the end of an
# empty file. The lock belongs to the open file handle, so it excludes other threads in
# this process as well as other processes, and the kernel drops it if the holder exits.
@@ -81,6 +174,84 @@ def _release_exclusive_lock(descriptor: int) -> None:
fcntl.flock(descriptor, fcntl.LOCK_UN)
+# Opening without blocking keeps a pipe from hanging the read; it changes nothing for a regular
+# file. Windows has no O_NONBLOCK and needs O_BINARY so the bytes aren't translated.
+_READ_FLAGS = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
+
+
+def _read_regular_file(path: str) -> bytes | None:
+ """
+ Read a file only if what was opened is a regular file.
+
+ The opened file is checked rather than the path, so the check still holds if the path
+ was replaced after it was listed. On Windows, opening a folder raises ``PermissionError``,
+ so a folder swapped in after listing is reported as "Permission denied" there.
+
+ Returns:
+ bytes | None: The content, or None if the path names something other than a regular file.
+ """
+ descriptor = _open(path, _READ_FLAGS)
+ try:
+ if not stat.S_ISREG(os.fstat(descriptor).st_mode):
+ return None
+ with open(descriptor, "rb", closefd=False) as file:
+ return file.read()
+ finally:
+ os.close(descriptor)
+
+
+def _affects_whole_store(error: Exception) -> bool:
+ """
+ Return whether a Blob error means the container can't be used, rather than one blob.
+
+ Returns:
+ bool: True for sign-in and permission failures, a missing container, and network errors.
+ """
+ from azure.core.exceptions import ClientAuthenticationError, ServiceRequestError, ServiceResponseError
+ from azure.storage.blob import StorageErrorCode
+
+ if isinstance(error, (ClientAuthenticationError, ServiceRequestError, ServiceResponseError)):
+ return True
+ if getattr(error, "status_code", None) in (401, 403):
+ return True
+ error_code = getattr(error, "error_code", None)
+ return isinstance(error_code, str) and error_code == StorageErrorCode.CONTAINER_NOT_FOUND
+
+
+def _is_missing_blob(error: Exception) -> bool:
+ """
+ Return whether a Blob error says the blob itself doesn't exist, as opposed to its container.
+
+ Returns:
+ bool: True only for ``BlobNotFound``.
+ """
+ from azure.storage.blob import StorageErrorCode
+
+ error_code = getattr(error, "error_code", None)
+ return isinstance(error_code, str) and error_code == StorageErrorCode.BLOB_NOT_FOUND
+
+
+def describe_storage_error(error: Exception) -> str:
+ """
+ Describe a storage error in a few words, leaving out any path or URL it carries.
+
+ Args:
+ error (Exception): The error a local folder or Azure Blob Storage raised.
+
+ Returns:
+ str: For example ``Permission denied`` or ``HttpResponseError (403, AuthorizationPermissionMismatch)``.
+ """
+ if isinstance(error, OSError) and error.strerror:
+ return error.strerror
+ error_code = getattr(error, "error_code", None)
+ details = [
+ str(detail)
+ for detail in (getattr(error, "status_code", None), getattr(error_code, "value", error_code))
+ if detail is not None
+ ]
+ return f"{type(error).__name__} ({', '.join(details)})" if details else type(error).__name__
+
+
class DocumentConflictError(ValueError):
"""A stored document changed after the caller read it."""
@@ -135,6 +306,13 @@ class FileDocumentStorage:
an OS advisory lock held across the whole read-compare-replace locally. That lock binds
only writers using this class; a direct edit is caught by the version instead.
+ A failure of the storage itself (a folder that can't be read, a missing container, a
+ sign-in or network error, a lock that stays taken) raises ``DocumentStorageUnavailableError``.
+ Its message names the location for logs, using ``display_source`` so a SAS token never
+ appears; an API response shows only its short reason. One document that exists but
+ can't be read raises ``DocumentUnreadableError`` from a single read, and is reported in
+ a listing's ``unreadable`` instead.
+
Subclasses supply the extension and an error-message label.
"""
@@ -193,72 +371,262 @@ def _list_documents(self) -> dict[str, bytes]:
"""
Read every stored document that the single-document operations can address.
- A name is checked before its content is read, so a file this class cannot address
- is never opened and cannot fail the listing on its way out.
+ The documents ``_list_documents_with_issues`` can't use are logged and left out. A storage
+ location that can't be listed at all raises instead of looking empty.
Returns:
dict[str, bytes]: Document content keyed by name.
+
+ Raises:
+ DocumentStorageUnavailableError: If the folder or container can't be listed.
+ """
+ listing = self._list_documents_with_issues()
+ for ignored in listing.ignored:
+ logger.warning(
+ f"Ignoring stored document '{ignored.file_name}' in {self.display_source}: {ignored.message}"
+ )
+ for unreadable in listing.unreadable:
+ logger.warning(
+ f"Skipping unreadable document '{unreadable.file_name}' in {self.display_source}: {unreadable.message}"
+ )
+ return dict(listing.documents)
+
+ def _list_documents_with_issues(self) -> DocumentListing:
+ """
+ Read every stored document and report the ones that can't be used.
+
+ A document that can't be read is reported as unreadable, and a file whose name breaks
+ the naming rule is reported as ignored without being read, because no single-document
+ call could address it. A storage location that can't be listed at all raises instead of
+ looking empty, and so does a Blob error that affects the whole container.
+
+ Messages carry only a short reason, never a path or URL, so they can be shown to users
+ without exposing server paths or a SAS token.
+
+ Returns:
+ DocumentListing: Readable documents keyed by name, plus the unreadable and ignored ones.
+
+ Raises:
+ DocumentStorageUnavailableError: If the folder or container can't be listed or read.
"""
- return self._list_blob_documents() if self._is_blob else self._list_local_documents()
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ return self._list_blob_documents_with_issues()
+ return self._list_local_documents_with_issues()
- def _list_local_documents(self) -> dict[str, bytes]:
+ def _list_local_documents_with_issues(self) -> DocumentListing:
"""
- Read addressable documents from the configured local directory.
+ Read the local directory, reporting unreadable and badly named documents.
Returns:
- dict[str, bytes]: Document content keyed by file stem.
+ DocumentListing: The documents and issues found in the directory.
"""
directory = self._local_directory(create=True)
+ # ``Path.glob`` returns nothing for a directory it can't read; listing it directly raises instead.
+ # ``fnmatch`` applies the platform's case rule, as ``glob`` does.
+ with _scandir(directory) as entries:
+ candidates = sorted(
+ (entry for entry in entries if fnmatch.fnmatch(entry.name, f"*{self._extension}")),
+ key=lambda entry: entry.name,
+ )
documents: dict[str, bytes] = {}
- for path in sorted(directory.glob(f"*{self._extension}")):
- if not self._is_addressable_name(path.stem):
+ unreadable: list[UnreadableDocument] = []
+ ignored: list[IgnoredDocument] = []
+ for entry in candidates:
+ name = Path(entry.name).stem
+ problem = self._name_problem(name)
+ if problem is not None:
+ ignored.append(IgnoredDocument(file_name=entry.name, message=problem))
continue
try:
- documents[path.stem] = path.read_bytes()
- except OSError as error:
- logger.warning(f"Skipping unreadable document '{path.name}' in {self.display_source}: {error}")
- return documents
+ content = self._read_local_document(path=entry.path, name=name, file_name=entry.name)
+ except DocumentUnreadableError as error:
+ unreadable.append(
+ UnreadableDocument(name=name, file_name=entry.name, message=error.reason, error=error.__cause__)
+ )
+ continue
+ if content is not None:
+ documents[name] = content
+ return DocumentListing(
+ documents=MappingProxyType(documents), unreadable=tuple(unreadable), ignored=tuple(ignored)
+ )
+
+ def _read_local_document(self, *, path: str, name: str, file_name: str) -> bytes | None:
+ """
+ Read one local document, the same way for a listing and for a single read.
+
+ The folder can change after it is listed, so what is opened is checked again: an entry
+ deleted in between counts as missing, whether or not it was a link, and anything that isn't
+ a regular file is reported without being read.
+
+ Returns:
+ bytes | None: The content, or None if nothing exists at the path.
+
+ Raises:
+ DocumentUnreadableError: If something exists at the path but can't be read as a regular file.
+ """
+ try:
+ if stat.S_ISREG(_stat(path).st_mode):
+ content = _read_regular_file(path)
+ if content is not None:
+ return content
+ except FileNotFoundError:
+ pass
+ except OSError as error:
+ raise self._unreadable(name=name, file_name=file_name, reason=describe_storage_error(error)) from error
+ try:
+ _lstat(path)
+ except FileNotFoundError:
+ return None
+ except OSError as error:
+ raise self._unreadable(name=name, file_name=file_name, reason=describe_storage_error(error)) from error
+ # A broken link, a folder, or a pipe, which would block if read.
+ raise self._unreadable(name=name, file_name=file_name, reason="Not a regular file")
+
+ def _list_blob_documents_with_issues(self) -> DocumentListing:
+ """
+ Read the blob container, reporting unreadable and badly named documents.
- def _is_addressable_name(self, name: str) -> bool:
+ Returns:
+ DocumentListing: The documents and issues found under the configured prefix.
+
+ Raises:
+ DocumentStorageUnavailableError: If a blob error affects the whole container.
"""
- Return whether a discovered document name is one this storage can address.
+ from azure.core.exceptions import AzureError
+
+ documents: dict[str, bytes] = {}
+ unreadable: list[UnreadableDocument] = []
+ ignored: list[IgnoredDocument] = []
+ with self._open_container_client() as client:
+ prefix = f"{self._blob_prefix}/" if self._blob_prefix else None
+ blobs = client.list_blobs(name_starts_with=prefix) if prefix else client.list_blobs()
+ blob_names = sorted(
+ blob.name for blob in blobs if self._is_direct_document_blob(blob_name=blob.name, prefix=prefix)
+ )
+ for blob_name in blob_names:
+ file_name = blob_name.removeprefix(prefix or "")
+ name = PurePosixPath(file_name).stem
+ problem = self._name_problem(name)
+ if problem is not None:
+ ignored.append(IgnoredDocument(file_name=file_name, message=problem))
+ continue
+ try:
+ documents[name] = client.download_blob(blob_name).readall()
+ except AzureError as error:
+ if _is_missing_blob(error):
+ # Deleted after the container was listed, so it is no longer part of the listing.
+ continue
+ if _affects_whole_store(error):
+ raise self._unavailable(error) from error
+ unreadable.append(
+ UnreadableDocument(
+ name=name, file_name=file_name, message=describe_storage_error(error), error=error
+ )
+ )
+ return DocumentListing(
+ documents=MappingProxyType(documents), unreadable=tuple(unreadable), ignored=tuple(ignored)
+ )
- Ordinary files such as ``__init__.py`` or ``My-Script.py`` can sit alongside
- valid documents, and a caller that fed such a name back into a read, write, or
- delete would get a ``ValueError`` it has no way to anticipate.
+ @staticmethod
+ def _name_problem(name: str) -> str | None:
+ """
+ Return why a discovered document name can't be addressed, or None if it can.
Returns:
- bool: Whether the name is a legal registry name.
+ str | None: The naming-rule error, or ``None`` for a legal name.
"""
try:
validate_registry_name(name)
except ValueError as error:
- logger.warning(f"Ignoring stored document '{name}{self._extension}' in {self.display_source}: {error}")
- return False
- return True
+ return str(error)
+ return None
def _read_document_bytes(self, name: str) -> bytes | None:
"""
- Read the raw bytes of one document.
+ Read the raw bytes of one document, agreeing with what a listing reports.
Returns:
bytes | None: Document content, or ``None`` if it does not exist.
Raises:
ValueError: If *name* is not a legal registry name.
+ DocumentUnreadableError: If the document exists but can't be read, such as a folder or a file
+ without read permission.
+ DocumentStorageUnavailableError: If the folder or container can't be used.
"""
validate_registry_name(name)
- if self._is_blob:
- from azure.core.exceptions import ResourceNotFoundError
+ file_name = f"{name}{self._extension}"
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ return self._read_blob_document(name=name, file_name=file_name)
+ path = self._local_directory() / file_name
+ return self._read_local_document(path=str(path), name=name, file_name=file_name)
- with self._open_container_client() as client:
- try:
- return client.download_blob(self._get_blob_name(name)).readall()
- except ResourceNotFoundError:
+ def _read_blob_document(self, *, name: str, file_name: str) -> bytes | None:
+ """
+ Read one blob, treating only a missing blob as missing.
+
+ Returns:
+ bytes | None: The content, or None if the blob doesn't exist.
+
+ Raises:
+ DocumentUnreadableError: If the blob exists but can't be read.
+ DocumentStorageUnavailableError: If the error affects the whole container.
+ """
+ from azure.core.exceptions import AzureError
+
+ with self._open_container_client() as client:
+ try:
+ return client.download_blob(self._get_blob_name(name)).readall()
+ except AzureError as error:
+ if _is_missing_blob(error):
return None
+ if _affects_whole_store(error):
+ raise self._unavailable(error) from error
+ raise self._unreadable(name=name, file_name=file_name, reason=describe_storage_error(error)) from error
+
+ def _unreadable(self, *, name: str, file_name: str, reason: str) -> DocumentUnreadableError:
+ """
+ Build the error for one document that exists but can't be read.
- path = self._local_directory() / f"{name}{self._extension}"
- return path.read_bytes() if path.is_file() else None
+ Returns:
+ DocumentUnreadableError: The error naming the document and the storage location.
+ """
+ return DocumentUnreadableError(
+ label=self._source_label, source=self.display_source, name=name, file_name=file_name, reason=reason
+ )
+
+ @contextmanager
+ def _unavailable_on_storage_error(self) -> Generator[None, None, None]:
+ """
+ Report a failure of the folder or container itself as ``DocumentStorageUnavailableError``.
+
+ Yields:
+ None: Control while storage is used.
+
+ Raises:
+ DocumentStorageUnavailableError: If an ``OSError`` or Azure error escapes the block.
+ """
+ from azure.core.exceptions import AzureError
+
+ try:
+ yield
+ except DocumentStorageUnavailableError:
+ raise
+ except (OSError, AzureError) as error:
+ raise self._unavailable(error) from error
+
+ def _unavailable(self, error: Exception) -> DocumentStorageUnavailableError:
+ """
+ Build the error for a folder or container that can't be used.
+
+ Returns:
+ DocumentStorageUnavailableError: The error naming the storage location and a short reason.
+ """
+ return DocumentStorageUnavailableError(
+ label=self._source_label, source=self.display_source, reason=describe_storage_error(error)
+ )
def _save_document(self, *, name: str, content: bytes) -> None:
"""
@@ -266,14 +634,16 @@ def _save_document(self, *, name: str, content: bytes) -> None:
Raises:
ValueError: If *name* is not a legal registry name.
+ DocumentStorageUnavailableError: If the folder or container can't be written.
"""
validate_registry_name(name)
- if self._is_blob:
- with self._open_container_client() as client:
- client.upload_blob(name=self._get_blob_name(name), data=content, overwrite=True)
- else:
- directory = self._local_directory(create=True)
- self._replace_file(path=directory / f"{name}{self._extension}", content=content)
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ with self._open_container_client() as client:
+ client.upload_blob(name=self._get_blob_name(name), data=content, overwrite=True)
+ else:
+ directory = self._local_directory(create=True)
+ self._replace_file(path=directory / f"{name}{self._extension}", content=content)
@staticmethod
def _compute_version(content: bytes) -> str:
@@ -324,12 +694,14 @@ def _save_document_conditional(self, *, name: str, content: bytes, expected_vers
Raises:
DocumentConflictError: If the stored version does not match *expected_version*.
ValueError: If *name* is not a legal registry name.
- TimeoutError: If a local write could not acquire the document lock.
+ DocumentStorageUnavailableError: If the folder or container can't be written, or a local write
+ could not acquire the document lock.
"""
validate_registry_name(name)
- if self._is_blob:
- return self._save_blob_conditional(name=name, content=content, expected_version=expected_version)
- return self._save_local_conditional(name=name, content=content, expected_version=expected_version)
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ return self._save_blob_conditional(name=name, content=content, expected_version=expected_version)
+ return self._save_local_conditional(name=name, content=content, expected_version=expected_version)
def _save_local_conditional(self, *, name: str, content: bytes, expected_version: str | None) -> str:
"""
@@ -340,11 +712,12 @@ def _save_local_conditional(self, *, name: str, content: bytes, expected_version
Raises:
DocumentConflictError: If the stored version does not match *expected_version*.
+ DocumentUnreadableError: If something that can't be read is stored under the name.
TimeoutError: If the document lock could not be acquired.
"""
path = self._local_directory(create=True) / f"{name}{self._extension}"
with self._local_document_lock(path):
- stored = path.read_bytes() if path.is_file() else None
+ stored = self._read_local_document(path=str(path), name=name, file_name=path.name)
actual_version = None if stored is None else self._compute_version(stored)
if actual_version != expected_version:
raise self._conflict_error(name=name, expected_version=expected_version, actual_version=actual_version)
@@ -363,7 +736,7 @@ def _save_blob_conditional(self, *, name: str, content: bytes, expected_version:
Raises:
DocumentConflictError: If the stored version does not match *expected_version*.
- AzureError: If the container is unreachable or does not exist.
+ DocumentStorageUnavailableError: If the container is missing rather than the blob.
"""
from azure.core import MatchConditions
from azure.core.exceptions import ResourceExistsError, ResourceModifiedError, ResourceNotFoundError
@@ -391,7 +764,9 @@ def _save_blob_conditional(self, *, name: str, content: bytes, expected_version:
etag=etag,
match_condition=MatchConditions.IfNotModified,
)
- except (ResourceModifiedError, ResourceNotFoundError):
+ except (ResourceModifiedError, ResourceNotFoundError) as error:
+ if isinstance(error, ResourceNotFoundError) and not _is_missing_blob(error):
+ raise self._unavailable(error) from error
raise self._conflict_error(
name=name,
expected_version=expected_version,
@@ -413,12 +788,15 @@ def _read_unmodified_blob_etag(
Raises:
DocumentConflictError: If the blob is absent or no longer holds that version.
+ DocumentStorageUnavailableError: If the container is missing rather than the blob.
"""
from azure.core.exceptions import ResourceNotFoundError
try:
downloader = client.download_blob(blob_name)
- except ResourceNotFoundError:
+ except ResourceNotFoundError as error:
+ if not _is_missing_blob(error):
+ raise self._unavailable(error) from error
raise self._conflict_error(name=name, expected_version=expected_version, actual_version=None) from None
actual_version = self._compute_version(downloader.readall())
if actual_version != expected_version:
@@ -431,12 +809,17 @@ def _read_blob_version(self, *, client: ContainerClient, blob_name: str) -> str
Returns:
str | None: The version token, or ``None`` if the blob does not exist.
+
+ Raises:
+ DocumentStorageUnavailableError: If the container is missing rather than the blob.
"""
from azure.core.exceptions import ResourceNotFoundError
try:
return self._compute_version(client.download_blob(blob_name).readall())
- except ResourceNotFoundError:
+ except ResourceNotFoundError as error:
+ if not _is_missing_blob(error):
+ raise self._unavailable(error) from error
return None
@contextmanager
@@ -483,7 +866,9 @@ def _acquire_document_lock(self, *, descriptor: int, lock_path: Path) -> None:
deadline = time.monotonic() + self.LOCK_TIMEOUT_SECONDS
while not _try_acquire_exclusive_lock(descriptor):
if time.monotonic() >= deadline:
- raise TimeoutError(f"Timed out waiting to write '{lock_path.name}'; another writer still holds it.")
+ raise TimeoutError(
+ errno.ETIMEDOUT, f"Timed out waiting for another writer to finish with '{lock_path.name}'"
+ )
time.sleep(self.LOCK_POLL_SECONDS)
@staticmethod
@@ -540,12 +925,14 @@ def _delete_document(self, name: str, *, expected_version: str | None = None) ->
Raises:
DocumentConflictError: If the stored version does not match *expected_version*.
ValueError: If *name* is not a legal registry name.
- TimeoutError: If a local delete could not acquire the document lock.
+ DocumentStorageUnavailableError: If the folder or container can't be written, or a local delete
+ could not acquire the document lock.
"""
validate_registry_name(name)
- if self._is_blob:
- return self._delete_blob(name=name, expected_version=expected_version)
- return self._delete_local(name=name, expected_version=expected_version)
+ with self._unavailable_on_storage_error():
+ if self._is_blob:
+ return self._delete_blob(name=name, expected_version=expected_version)
+ return self._delete_local(name=name, expected_version=expected_version)
def _delete_local(self, *, name: str, expected_version: str | None) -> bool:
"""
@@ -556,20 +943,25 @@ def _delete_local(self, *, name: str, expected_version: str | None) -> bool:
Raises:
DocumentConflictError: If the stored version does not match *expected_version*.
+ DocumentUnreadableError: If a version is given and something that can't be read is stored under
+ the name.
TimeoutError: If the document lock could not be acquired.
"""
path = self._local_directory(create=True) / f"{name}{self._extension}"
with self._local_document_lock(path):
+ if expected_version is not None:
+ stored = self._read_local_document(path=str(path), name=name, file_name=path.name)
+ if stored is None:
+ return False
+ actual_version = self._compute_version(stored)
+ if actual_version != expected_version:
+ raise self._conflict_error(
+ name=name,
+ expected_version=expected_version,
+ actual_version=actual_version,
+ action="deleted",
+ )
try:
- if expected_version is not None:
- actual_version = self._compute_version(path.read_bytes())
- if actual_version != expected_version:
- raise self._conflict_error(
- name=name,
- expected_version=expected_version,
- actual_version=actual_version,
- action="deleted",
- )
path.unlink()
except FileNotFoundError:
return False
@@ -584,7 +976,7 @@ def _delete_blob(self, *, name: str, expected_version: str | None) -> bool:
Raises:
DocumentConflictError: If the stored version does not match *expected_version*.
- AzureError: If the container is unreachable or does not exist.
+ DocumentStorageUnavailableError: If the container is missing rather than the blob.
"""
from azure.core.exceptions import ResourceNotFoundError
@@ -596,7 +988,9 @@ def _delete_blob(self, *, name: str, expected_version: str | None) -> bool:
)
try:
client.delete_blob(blob_name)
- except ResourceNotFoundError:
+ except ResourceNotFoundError as error:
+ if not _is_missing_blob(error):
+ raise self._unavailable(error) from error
return False
return True
@@ -614,13 +1008,16 @@ def _delete_unmodified_blob(
Raises:
DocumentConflictError: If the blob no longer holds *expected_version*.
+ DocumentStorageUnavailableError: If the container is missing rather than the blob.
"""
from azure.core import MatchConditions
from azure.core.exceptions import ResourceModifiedError, ResourceNotFoundError
try:
downloader = client.download_blob(blob_name)
- except ResourceNotFoundError:
+ except ResourceNotFoundError as error:
+ if not _is_missing_blob(error):
+ raise self._unavailable(error) from error
return False
actual_version = self._compute_version(downloader.readall())
if actual_version != expected_version:
@@ -631,7 +1028,9 @@ def _delete_unmodified_blob(
client.delete_blob(
blob_name, etag=downloader.properties.etag, match_condition=MatchConditions.IfNotModified
)
- except ResourceNotFoundError:
+ except ResourceNotFoundError as error:
+ if not _is_missing_blob(error):
+ raise self._unavailable(error) from error
return False
except ResourceModifiedError:
actual_version = self._read_blob_version(client=client, blob_name=blob_name)
@@ -654,32 +1053,6 @@ def _local_directory(self, *, create: bool = False) -> Path:
directory.mkdir(parents=True, exist_ok=True)
return directory
- def _list_blob_documents(self) -> dict[str, bytes]:
- """
- Read addressable documents from the configured Azure Blob container.
-
- Returns:
- dict[str, bytes]: Document content keyed by blob stem.
- """
- from azure.core.exceptions import AzureError
-
- documents: dict[str, bytes] = {}
- with self._open_container_client() as client:
- prefix = f"{self._blob_prefix}/" if self._blob_prefix else None
- blobs = client.list_blobs(name_starts_with=prefix) if prefix else client.list_blobs()
- blob_names = sorted(
- blob.name for blob in blobs if self._is_direct_document_blob(blob_name=blob.name, prefix=prefix)
- )
- for blob_name in blob_names:
- name = PurePosixPath(blob_name.removeprefix(prefix or "")).stem
- if not self._is_addressable_name(name):
- continue
- try:
- documents[name] = client.download_blob(blob_name).readall()
- except AzureError as error:
- logger.warning(f"Skipping unreadable document '{blob_name}' in {self.display_source}: {error}")
- return documents
-
def _parse_blob_source(self) -> tuple[str, str]:
"""
Split the configured source into a container URL and blob prefix.
diff --git a/pyrit/registry/instance_registry.py b/pyrit/registry/instance_registry.py
index 62f0220a2e..1802152755 100644
--- a/pyrit/registry/instance_registry.py
+++ b/pyrit/registry/instance_registry.py
@@ -23,11 +23,13 @@
from __future__ import annotations
import threading
+from contextlib import contextmanager
+from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, runtime_checkable
if TYPE_CHECKING:
- from collections.abc import Callable, Iterator
+ from collections.abc import Callable, Generator, Iterator
from pyrit.models import ComponentIdentifier
from pyrit.registry.tag_query import TagQuery
@@ -43,6 +45,18 @@ def get_identifier(self) -> ComponentIdentifier:
T = TypeVar("T", bound=SupportsIdentifier) # The type of items stored
+#: Entry metadata naming the kind of saved recipe an entry was rebuilt from, such as ``"targets"``.
+SAVED_ENTRY_METADATA_KEY = "saved_recipe_kind"
+
+_saved_entry_refusals: ContextVar[list[tuple[str, str]] | None] = ContextVar("_saved_entry_refusals", default=None)
+
+# The kind and name of the saved entry being rebuilt in this context, so registering that name marks the entry.
+_saved_entry_registration: ContextVar[tuple[str, str] | None] = ContextVar("_saved_entry_registration", default=None)
+
+
+class RegistryNameTakenError(ValueError):
+ """A name is already registered, so another entry can't take it."""
+
@dataclass
class RegistryEntry(Generic[T]):
@@ -66,6 +80,98 @@ class RegistryEntry(Generic[T]):
metadata: dict[str, Any] = field(default_factory=dict)
+class SavedEntryReplacementError(RuntimeError):
+ """
+ Raised when code registers an entry under the name of an entry rebuilt from a saved recipe.
+
+ It isn't a ``ValueError``, so a startup script that skips the entries it can't build doesn't
+ swallow it.
+ """
+
+ def __init__(self, *, kind: str, name: str) -> None:
+ """
+ Describe the saved entry that would have been replaced.
+
+ Args:
+ kind (str): The saved recipe kind, such as ``"targets"``.
+ name (str): The entry's name.
+ """
+ super().__init__(f"Saved {kind.removesuffix('s')} '{name}' can't be replaced.")
+ self.kind = kind
+ self.name = name
+
+
+def refuse_saved_entry(entry: RegistryEntry[Any] | None) -> None:
+ """
+ Refuse to replace, or stand in for, an entry rebuilt from a saved recipe.
+
+ Args:
+ entry (RegistryEntry[Any] | None): The entry registered under the name, if any.
+
+ Raises:
+ SavedEntryReplacementError: If the entry was rebuilt from a saved recipe.
+ """
+ kind = _record_saved_entry_refusal(entry)
+ if entry is not None and kind is not None:
+ raise SavedEntryReplacementError(kind=kind, name=entry.name)
+
+
+def _record_saved_entry_refusal(entry: RegistryEntry[Any] | None) -> str | None:
+ """
+ Record a refused use of an entry rebuilt from a saved recipe, when refusals are being recorded.
+
+ Args:
+ entry (RegistryEntry[Any] | None): The entry registered under the name, if any.
+
+ Returns:
+ str | None: The entry's saved recipe kind, or None when it wasn't rebuilt from a saved recipe.
+ """
+ if entry is None or SAVED_ENTRY_METADATA_KEY not in entry.metadata:
+ return None
+ kind = str(entry.metadata[SAVED_ENTRY_METADATA_KEY])
+ refusals = _saved_entry_refusals.get()
+ if refusals is not None:
+ refusals.append((kind, entry.name))
+ return kind
+
+
+@contextmanager
+def record_saved_entry_refusals() -> Generator[list[tuple[str, str]], None, None]:
+ """
+ Collect the saved entries that code run in this context tried to replace.
+
+ The list holds every refusal, including ones the code caught itself.
+
+ Yields:
+ list[tuple[str, str]]: The kind and name of each saved entry that was refused.
+ """
+ refusals: list[tuple[str, str]] = []
+ token = _saved_entry_refusals.set(refusals)
+ try:
+ yield refusals
+ finally:
+ _saved_entry_refusals.reset(token)
+
+
+@contextmanager
+def saved_entry_registration(*, kind: str, name: str) -> Generator[None, None, None]:
+ """
+ Mark the entry registered under ``name`` in this context as rebuilt from a saved recipe.
+
+ Args:
+ kind (str): The saved recipe kind, such as ``"targets"``.
+ name (str): The saved entry's name.
+
+ Yields:
+ None: Control returns to the caller while the mark applies.
+ """
+ token = _saved_entry_registration.set((kind, name))
+ try:
+ yield
+ finally:
+ _saved_entry_registration.reset(token)
+
+
@runtime_checkable
class InstanceRegistry(Protocol[T]):
"""
@@ -286,8 +392,10 @@ def register(
Raises:
TypeError: If this registry was created with an ``instance_type`` and
``instance`` is not of that type.
- ValueError: If the name is reserved or already registered and ``replace``
- is False.
+ ValueError: If the name is reserved.
+ RegistryNameTakenError: If the name is already registered and ``replace`` is False.
+ SavedEntryReplacementError: If ``replace`` would replace an entry rebuilt
+ from a saved recipe.
"""
expected_type = self._resolve_instance_type()
if expected_type is not None and not isinstance(instance, expected_type):
@@ -298,17 +406,23 @@ def register(
if name is None:
name = instance.get_identifier().unique_name
+ entry_metadata = dict(metadata or {})
+ restoring = _saved_entry_registration.get()
+ if restoring is not None and restoring[1] == name:
+ entry_metadata[SAVED_ENTRY_METADATA_KEY] = restoring[0]
entry = RegistryEntry(
name=name,
instance=instance,
tags=self._normalize_tags(tags),
- metadata=metadata or {},
+ metadata=entry_metadata,
)
with self._lock:
if name in self._reserved_names:
raise ValueError(f"Instance name '{name}' is reserved")
if not replace:
self.validate_name_available(name)
+ else:
+ refuse_saved_entry(self._registry_items.get(name))
self._registry_items[name] = entry
self._metadata_cache = None
self._instance_version += 1
@@ -334,13 +448,16 @@ def validate_name_available(self, name: str) -> None:
name (str): The proposed registry name.
Raises:
- ValueError: If the name is reserved or already registered.
+ ValueError: If the name is reserved.
+ RegistryNameTakenError: If the name is already registered.
"""
with self._lock:
if name in self._reserved_names:
raise ValueError(f"Instance name '{name}' is reserved")
if name in self._registry_items:
- raise ValueError(f"Instance '{name}' already exists")
+ # Code that catches this error would leave a saved entry standing in, so the clash is recorded.
+ _record_saved_entry_refusal(self._registry_items[name])
+ raise RegistryNameTakenError(f"Instance '{name}' already exists")
def unregister(self, name: str, *, expected_entry: RegistryEntry[T] | None = None) -> T | None:
"""
diff --git a/pyrit/registry/registry_recipe_restore.py b/pyrit/registry/registry_recipe_restore.py
new file mode 100644
index 0000000000..55f057b726
--- /dev/null
+++ b/pyrit/registry/registry_recipe_restore.py
@@ -0,0 +1,644 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Rebuild saved registry entries in dependency order and report the ones that can't come back."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import traceback
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Any, ClassVar
+
+from pyrit.common.azure_storage import redact_urls
+from pyrit.models import ComponentType
+from pyrit.models.catalog import (
+ ComponentRecipe,
+ RegistryRecipeKind,
+ RegistryRestoreReport,
+ RestoreFailure,
+ RestoreFailureReason,
+ SavedEntryRef,
+ StoredRecipe,
+ TechniqueRecipe,
+)
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
+from pyrit.registry.instance_registry import RegistryNameTakenError, saved_entry_registration
+from pyrit.registry.registry_recipe_storage import RecipeListing, RegistryRecipeStorage, safe_failure_text
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping, Sequence
+
+ from pyrit.models import Parameter
+ from pyrit.registry.registry import InstanceHoldingRegistry, Registry
+ from pyrit.registry.registry_metadata import RegistryMetadata
+
+logger = logging.getLogger(__name__)
+
+# Identifies a failure for the log: one saved entry, or a whole folder when the name is None.
+_FailureKey = tuple[RegistryRecipeKind, str | None]
+
+# Builds one saved entry and registers it under its name.
+RecipeBuilder = Callable[[StoredRecipe], Awaitable[None]]
+
+
+@dataclass(frozen=True)
+class SavedRecipes:
+ """What reading the saved recipes found: each folder that was read, and a failure for each one that wasn't."""
+
+ listings: tuple[RecipeListing, ...]
+ failures: tuple[RestoreFailure, ...]
+ # The storage error behind each folder that couldn't be listed, for the log only.
+ causes: Mapping[RegistryRecipeKind, BaseException] = field(
+ default_factory=lambda: MappingProxyType({}), compare=False, repr=False
+ )
+
+
+@dataclass(frozen=True)
+class _PlannedEntry:
+ """A saved entry whose class and references checked out, waiting to be built."""
+
+ stored: StoredRecipe
+ dependencies: tuple[SavedEntryRef, ...]
+
+
+class RegistryRecipeRestorer:
+ """
+ Rebuild saved targets, converters, scorers, and techniques into their registries.
+
+ This class reads the saved files, checks them, and works out the order to build them in: each entry
+ is built after the saved entries it names. The caller supplies the builder, so an entry is built the
+ way the caller's create builds one; the backend's builder calls the API's create for each kind. A
+ file never chooses what gets imported: classes and attack types are looked up among the ones already
+ registered. Server paths are refused as they are for API input, and a recipe never holds a value for
+ a parameter its class declares as a credential. Entries that can't come back are reported with the
+ reason instead of being skipped.
+
+ Stored files are otherwise trusted the way configuration is. Rules for credentials a class
+ reads from server settings, and for fields that can carry a credential without being declared
+ one, need each class to declare them, so they arrive with saving and apply to restore too.
+ """
+
+ _KIND_BY_COMPONENT_TYPE: ClassVar[dict[ComponentType, RegistryRecipeKind]] = {
+ ComponentType.TARGET: RegistryRecipeKind.TARGETS,
+ ComponentType.CONVERTER: RegistryRecipeKind.CONVERTERS,
+ ComponentType.SCORER: RegistryRecipeKind.SCORERS,
+ }
+ _KIND_ORDER: ClassVar[tuple[RegistryRecipeKind, ...]] = tuple(RegistryRecipeKind)
+
+ def __init__(self, *, source: str | None = None) -> None:
+ """
+ Initialize the restorer.
+
+ Args:
+ source (str | None): Local directory or Azure Blob URI holding the per-registry folders.
+ Defaults to ``registry_recipes`` under the PyRIT configuration directory.
+ """
+ from pyrit.registry.components.converter_registry import ConverterRegistry
+ from pyrit.registry.components.scorer_registry import ScorerRegistry
+ from pyrit.registry.components.target_registry import TargetRegistry
+
+ self._storages = {kind: RegistryRecipeStorage(kind=kind, source=source) for kind in RegistryRecipeKind}
+ self._registry_types: dict[RegistryRecipeKind, Any] = {
+ RegistryRecipeKind.TARGETS: TargetRegistry,
+ RegistryRecipeKind.CONVERTERS: ConverterRegistry,
+ RegistryRecipeKind.SCORERS: ScorerRegistry,
+ }
+
+ def read(self) -> SavedRecipes:
+ """
+ Read every registry's folder without building anything.
+
+ Reading can wait on storage, so a caller on an event loop can run this in a worker thread and
+ pass the result to ``restore_async`` on the loop. A folder that can't be listed becomes a failure; any
+ other error is a bug rather than a problem with the files, so it isn't caught.
+
+ Returns:
+ SavedRecipes: The folders that were read, and a failure for each one that wasn't.
+ """
+ listings: list[RecipeListing] = []
+ failures: list[RestoreFailure] = []
+ causes: dict[RegistryRecipeKind, BaseException] = {}
+ for kind, storage in self._storages.items():
+ try:
+ listings.append(storage.list_recipes())
+ except DocumentStorageUnavailableError as error:
+ message = safe_failure_text(f"The saved {kind.value} couldn't be listed: {error.reason}")
+ failures.append(
+ RestoreFailure(kind=kind, reason=RestoreFailureReason.UNREADABLE_STORAGE, message=message)
+ )
+ causes[kind] = error
+ return SavedRecipes(listings=tuple(listings), failures=tuple(failures), causes=MappingProxyType(causes))
+
+ async def restore_async(
+ self, *, build_async: RecipeBuilder, saved: SavedRecipes | None = None
+ ) -> RegistryRestoreReport:
+ """
+ Rebuild every saved entry that can be rebuilt and register it.
+
+ Entries are built one at a time, in dependency order, on the calling event loop, because a
+ constructor can need the running loop. Call it when the registries hold only startup entries, as
+ they do at startup and after an apply resets them; an entry restored earlier would otherwise clash
+ with itself by name. If any registry's folder can't be read, nothing is built, since entries
+ in the other folders may name entries in the unread one.
+
+ Each failure is logged once as an error, with the error behind it, if any, and URL credentials removed.
+
+ Args:
+ build_async (RecipeBuilder): Builds one checked entry and registers it under its name. It raises
+ ``RecipeRejectedError`` to report a reason; any other error is reported as a build error.
+ saved (SavedRecipes | None): What ``read`` returned. Defaults to reading now, in a worker thread,
+ so a slow store doesn't hold the event loop.
+
+ Returns:
+ RegistryRestoreReport: What was restored, what failed and why, and which files were ignored.
+ """
+ saved = saved if saved is not None else await asyncio.to_thread(self.read)
+ listings, failures = list(saved.listings), list(saved.failures)
+ causes: dict[_FailureKey, BaseException] = {(kind, None): error for kind, error in saved.causes.items()}
+ for listing in listings:
+ failures.extend(listing.failures)
+ for failure in listing.failures:
+ cause = listing.causes.get(failure.name) if failure.name is not None else None
+ if cause is not None:
+ causes[(failure.kind, failure.name)] = cause
+ ignored = [file for listing in listings for file in listing.ignored]
+ if saved.failures:
+ report = RegistryRestoreReport(failures=self._sorted(failures), ignored=ignored)
+ else:
+ stored = {recipe.ref: recipe for listing in listings for recipe in listing.recipes.values()}
+ planned = self._plan_all(stored=stored, failures=failures, causes=causes)
+ restored = await self._build_in_order_async(
+ planned=planned, build_async=build_async, failures=failures, causes=causes
+ )
+ report = RegistryRestoreReport(restored=restored, failures=self._sorted(failures), ignored=ignored)
+ self._log_failures(failures=report.failures, causes=causes)
+ return report
+
+ def _log_failures(self, *, failures: Sequence[RestoreFailure], causes: Mapping[_FailureKey, BaseException]) -> None:
+ """Log each failure once as an error, with the error behind it, if any, and URL credentials removed."""
+ for failure in failures:
+ subject = (
+ f"the saved {failure.kind.value}"
+ if failure.name is None
+ else self._describe_ref(SavedEntryRef(kind=failure.kind, name=failure.name))
+ )
+ summary = f"Couldn't restore {subject} ({failure.reason.value}): {failure.message}"
+ cause = causes.get((failure.kind, failure.name))
+ if cause is None:
+ logger.error(redact_urls(summary))
+ else:
+ logger.error(redact_urls(f"{summary}\n{''.join(traceback.format_exception(cause)).rstrip()}"))
+
+ def _plan_all(
+ self,
+ *,
+ stored: Mapping[SavedEntryRef, StoredRecipe],
+ failures: list[RestoreFailure],
+ causes: dict[_FailureKey, BaseException],
+ ) -> dict[SavedEntryRef, _PlannedEntry]:
+ """
+ Check every readable recipe's class, credentials, name, and references.
+
+ A reference resolves to a saved entry first, even one that failed to load, so an entry is
+ never silently attached to a startup entry that only shares its name.
+
+ Returns:
+ dict[SavedEntryRef, _PlannedEntry]: The entries ready to build. Failures and their errors are added.
+ """
+ saved_refs = set(stored) | {
+ SavedEntryRef(kind=failure.kind, name=failure.name) for failure in failures if failure.name is not None
+ }
+ planned: dict[SavedEntryRef, _PlannedEntry] = {}
+ for ref in sorted(stored, key=self._sort_key):
+ outcome = self._plan(stored=stored[ref], saved_refs=saved_refs, causes=causes)
+ if isinstance(outcome, RestoreFailure):
+ failures.append(outcome)
+ else:
+ planned[ref] = outcome
+ return planned
+
+ def _plan(
+ self,
+ *,
+ stored: StoredRecipe,
+ saved_refs: set[SavedEntryRef],
+ causes: dict[_FailureKey, BaseException],
+ ) -> _PlannedEntry | RestoreFailure:
+ """
+ Check one recipe and collect the saved entries it depends on.
+
+ Returns:
+ _PlannedEntry | RestoreFailure: The entry ready to build, or why it can't be.
+ """
+ recipe = stored.recipe
+ try:
+ references = (
+ self._technique_references(ref=stored.ref, recipe=recipe)
+ if isinstance(recipe, TechniqueRecipe)
+ else self._component_references(ref=stored.ref, recipe=recipe)
+ )
+ except RecipeRejectedError as rejection:
+ return self._failure(stored=stored, reason=rejection.reason, message=str(rejection))
+ except Exception as error: # A bug here must not stop the others from being checked, or blame the file.
+ causes[(stored.ref.kind, stored.ref.name)] = error
+ return self._failure(
+ stored=stored, reason=RestoreFailureReason.BUILD_ERROR, message=self._safe_message(error)
+ )
+ dependencies: list[SavedEntryRef] = []
+ for reference in references:
+ if reference in saved_refs:
+ dependencies.append(reference)
+ elif reference.name not in self._get_registry(reference.kind).instances:
+ message = f"Uses {self._describe_ref(reference)}, which doesn't exist."
+ return self._failure(
+ stored=stored,
+ reason=RestoreFailureReason.MISSING_DEPENDENCY,
+ message=message,
+ dependency=reference,
+ )
+ return _PlannedEntry(stored=stored, dependencies=tuple(dict.fromkeys(dependencies)))
+
+ def _component_references(self, *, ref: SavedEntryRef, recipe: ComponentRecipe) -> list[SavedEntryRef]:
+ """
+ Check a target, converter, or scorer recipe and list the entries its parameters name.
+
+ Returns:
+ list[SavedEntryRef]: The referenced entries, in parameter order.
+
+ Raises:
+ RecipeRejectedError: If the class is unknown or is now one from outside PyRIT, the recipe holds
+ a credential or sets a server file or folder, or the name is taken or can't be used.
+ """
+ registry = self._get_registry(ref.kind)
+ try:
+ cls = registry.get_class(recipe.class_name)
+ except KeyError as error:
+ message = f"'{recipe.class_name}' is not a registered {self._singular(ref.kind)} class."
+ raise RecipeRejectedError(reason=RestoreFailureReason.UNKNOWN_CLASS, message=message) from error
+ # Classes are found by registered name, so one that moved within PyRIT is still the same class.
+ moved_within_pyrit = _is_pyrit_module(cls.__module__) and _is_pyrit_module(recipe.class_module)
+ if cls.__module__ != recipe.class_module and not moved_within_pyrit:
+ message = (
+ f"'{recipe.class_name}' is registered from '{cls.__module__}', "
+ f"but the saved entry was made with the one in '{recipe.class_module}'."
+ )
+ raise RecipeRejectedError(reason=RestoreFailureReason.MODULE_MISMATCH, message=message)
+ parameters = self._class_parameters(registry=registry, name=recipe.class_name, cls=cls)
+ self._check_stored_credentials(parameters=parameters, params=recipe.params)
+ # As in the target API, a target may let callers set some path parameters, such as a working folder.
+ allowed_paths = (
+ getattr(cls, "caller_path_parameters", frozenset())
+ if ref.kind is RegistryRecipeKind.TARGETS
+ else frozenset()
+ )
+ server_paths = sorted(
+ parameter.name
+ for parameter in parameters
+ if parameter.mentions_path
+ and parameter.name not in allowed_paths
+ and recipe.params.get(parameter.name) not in (None, "")
+ )
+ if server_paths:
+ message = (
+ f"'{recipe.class_name}' reads a server file or folder from '{server_paths[0]}', "
+ "which a saved entry can't set."
+ )
+ raise RecipeRejectedError(reason=RestoreFailureReason.UNSUPPORTED_CLASS, message=message)
+ try:
+ registry.instances.validate_name_available(ref.name)
+ except RegistryNameTakenError as error:
+ raise RecipeRejectedError(reason=RestoreFailureReason.NAME_CLASH, message=str(error)) from error
+ except ValueError as error: # A reserved name, which no entry of this kind can have.
+ raise RecipeRejectedError(reason=RestoreFailureReason.INVALID_RECIPE, message=str(error)) from error
+ return self._parameter_references(parameters=parameters, params=recipe.params)
+
+ def _technique_references(self, *, ref: SavedEntryRef, recipe: TechniqueRecipe) -> list[SavedEntryRef]:
+ """
+ Check a technique recipe's attack type, and list the entries it names.
+
+ The builder checks the name, as the technique API does.
+
+ Returns:
+ list[SavedEntryRef]: The adversarial target, converters, and attack parameter references.
+
+ Raises:
+ RecipeRejectedError: If the attack type isn't registered, or the recipe holds a credential.
+ """
+ from pyrit.registry.components.attack_registry import AttackRegistry
+
+ attack_registry = AttackRegistry.get_registry_singleton()
+ try:
+ attack_class = attack_registry.get_class(recipe.type)
+ except KeyError as error:
+ message = f"'{recipe.type}' is not a registered attack type."
+ raise RecipeRejectedError(reason=RestoreFailureReason.UNKNOWN_CLASS, message=message) from error
+ converters = [*(recipe.request_converters or []), *(recipe.response_converters or [])]
+ references = [SavedEntryRef(kind=RegistryRecipeKind.CONVERTERS, name=name) for name in converters]
+ if recipe.adversarial_chat is not None:
+ references.append(SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name=recipe.adversarial_chat))
+ parameters = self._class_parameters(registry=attack_registry, name=recipe.type, cls=attack_class)
+ self._check_stored_credentials(parameters=parameters, params=recipe.params)
+ return references + self._parameter_references(parameters=parameters, params=recipe.params)
+
+ @staticmethod
+ def _class_parameters(*, registry: Registry[Any, Any], name: str, cls: type) -> tuple[Parameter, ...]:
+ """
+ Get a registered class's constructor parameters from the registry's cached metadata.
+
+ Returns:
+ tuple[Parameter, ...]: The parameters the class declares.
+ """
+ metadata: RegistryMetadata = registry.get_registered_class_metadata(name) or registry.get_class_metadata(cls)
+ return metadata.parameters
+
+ @staticmethod
+ def _check_stored_credentials(*, parameters: Sequence[Parameter], params: Mapping[str, Any]) -> None:
+ """
+ Refuse a recipe that stores a value for a parameter its class declares as a credential.
+
+ Raises:
+ RecipeRejectedError: If a declared credential parameter has a value.
+ """
+ stored = sorted(
+ parameter.name
+ for parameter in parameters
+ if parameter.sensitive and params.get(parameter.name) not in (None, "")
+ )
+ if stored:
+ raise RecipeRejectedError(
+ reason=RestoreFailureReason.UNSUPPORTED_CREDENTIAL,
+ message=f"Saved entries can't hold credentials ('{stored[0]}').",
+ )
+
+ def _parameter_references(
+ self, *, parameters: Sequence[Parameter], params: Mapping[str, Any]
+ ) -> list[SavedEntryRef]:
+ """
+ List the entries that supplied reference parameters name.
+
+ An empty name leaves the reference unset, as it does when the API builds an entry.
+
+ Returns:
+ list[SavedEntryRef]: One reference per named entry of a kind that can be saved.
+ """
+ references: list[SavedEntryRef] = []
+ for parameter in parameters:
+ if parameter.reference is None or params.get(parameter.name) in (None, ""):
+ continue
+ kind = self._KIND_BY_COMPONENT_TYPE.get(parameter.reference.component_type)
+ value = params[parameter.name]
+ names = value if isinstance(value, list) else [value]
+ if kind is not None:
+ references.extend(SavedEntryRef(kind=kind, name=name) for name in names if isinstance(name, str))
+ return references
+
+ async def _build_in_order_async(
+ self,
+ *,
+ planned: dict[SavedEntryRef, _PlannedEntry],
+ build_async: RecipeBuilder,
+ failures: list[RestoreFailure],
+ causes: dict[_FailureKey, BaseException],
+ ) -> list[SavedEntryRef]:
+ """
+ Build entries once everything they depend on has been settled.
+
+ Returns:
+ list[SavedEntryRef]: The entries that were rebuilt and registered. Failures and their errors are added.
+ """
+ failed = {SavedEntryRef(kind=failure.kind, name=failure.name) for failure in failures if failure.name}
+ restored: list[SavedEntryRef] = []
+ pending = dict(planned)
+ ready = self._ready(pending=pending, settled={*failed})
+ while ready:
+ for ref in ready:
+ entry = pending.pop(ref)
+ failure = self._failed_dependency(entry=entry, failed=failed) or await self._build_async(
+ stored=entry.stored, build_async=build_async, causes=causes
+ )
+ if failure is None:
+ restored.append(ref)
+ else:
+ failures.append(failure)
+ failed.add(ref)
+ ready = self._ready(pending=pending, settled={*failed, *restored})
+ failures.extend(self._unresolved(pending=pending))
+ return restored
+
+ def _ready(
+ self, *, pending: Mapping[SavedEntryRef, _PlannedEntry], settled: set[SavedEntryRef]
+ ) -> list[SavedEntryRef]:
+ """
+ List the pending entries whose dependencies have all been built or have failed.
+
+ Returns:
+ list[SavedEntryRef]: The entries to handle next, in a stable order.
+ """
+ ready = [ref for ref, entry in pending.items() if settled.issuperset(entry.dependencies)]
+ return sorted(ready, key=self._sort_key)
+
+ def _failed_dependency(self, *, entry: _PlannedEntry, failed: set[SavedEntryRef]) -> RestoreFailure | None:
+ """
+ Fail an entry if a saved entry it depends on couldn't be restored.
+
+ Returns:
+ RestoreFailure | None: The failure, or None if every dependency was restored.
+ """
+ dependency = next((ref for ref in entry.dependencies if ref in failed), None)
+ if dependency is None:
+ return None
+ return self._failure(
+ stored=entry.stored,
+ reason=RestoreFailureReason.FAILED_DEPENDENCY,
+ message=f"Uses {self._describe_ref(dependency)}, which couldn't be restored.",
+ dependency=dependency,
+ )
+
+ def _unresolved(self, *, pending: Mapping[SavedEntryRef, _PlannedEntry]) -> list[RestoreFailure]:
+ """
+ Fail the entries left waiting: those on a dependency loop, and those waiting on one.
+
+ Returns:
+ list[RestoreFailure]: One failure per entry that was never built.
+ """
+ failures: list[RestoreFailure] = []
+ for ref in sorted(pending, key=self._sort_key):
+ entry = pending[ref]
+ waiting_on = [dependency for dependency in entry.dependencies if dependency in pending]
+ on_loop = [
+ dependency
+ for dependency in waiting_on
+ if dependency == ref or self._reaches(start=dependency, target=ref, pending=pending)
+ ]
+ blocker = (on_loop or waiting_on)[0]
+ if on_loop:
+ reason = RestoreFailureReason.DEPENDENCY_LOOP
+ message = f"Saved entries use each other in a loop, through {self._describe_ref(blocker)}."
+ else:
+ reason = RestoreFailureReason.FAILED_DEPENDENCY
+ message = f"Uses {self._describe_ref(blocker)}, which is waiting on a loop of saved entries."
+ failures.append(self._failure(stored=entry.stored, reason=reason, message=message, dependency=blocker))
+ return failures
+
+ @staticmethod
+ def _reaches(
+ *, start: SavedEntryRef, target: SavedEntryRef, pending: Mapping[SavedEntryRef, _PlannedEntry]
+ ) -> bool:
+ """
+ Return whether following pending dependencies from ``start`` leads to ``target``.
+
+ Returns:
+ bool: True if ``target`` can be reached.
+ """
+ stack = [dependency for dependency in pending[start].dependencies if dependency in pending]
+ seen: set[SavedEntryRef] = set()
+ while stack:
+ current = stack.pop()
+ if current == target:
+ return True
+ if current in seen:
+ continue
+ seen.add(current)
+ stack.extend(dependency for dependency in pending[current].dependencies if dependency in pending)
+ return False
+
+ async def _build_async(
+ self, *, stored: StoredRecipe, build_async: RecipeBuilder, causes: dict[_FailureKey, BaseException]
+ ) -> RestoreFailure | None:
+ """
+ Build one entry with the caller's builder.
+
+ Returns:
+ RestoreFailure | None: Why the entry couldn't be built or registered, or None on success.
+ """
+ from pyrit.registry.components.attack_technique_registry import TechniqueAdmissionError
+
+ try:
+ with saved_entry_registration(kind=stored.ref.kind.value, name=stored.ref.name):
+ await build_async(stored)
+ except RecipeRejectedError as rejection:
+ return self._failure(stored=stored, reason=rejection.reason, message=str(rejection))
+ except RegistryNameTakenError as error:
+ return self._failure(stored=stored, reason=RestoreFailureReason.NAME_CLASH, message=str(error))
+ except TechniqueAdmissionError as error:
+ return self._failure(stored=stored, reason=RestoreFailureReason.INVALID_RECIPE, message=str(error))
+ except Exception as error: # A constructor can raise anything; one bad entry must not stop the rest.
+ causes[(stored.ref.kind, stored.ref.name)] = error
+ return self._failure(
+ stored=stored, reason=RestoreFailureReason.BUILD_ERROR, message=self._safe_message(error)
+ )
+ return None
+
+ def _get_registry(self, kind: RegistryRecipeKind) -> InstanceHoldingRegistry[Any, Any]:
+ """
+ Get the registry singleton that holds saved targets, converters, or scorers.
+
+ Returns:
+ InstanceHoldingRegistry[Any, Any]: The registry.
+ """
+ return self._registry_types[kind].get_registry_singleton()
+
+ def _failure(
+ self,
+ *,
+ stored: StoredRecipe,
+ reason: RestoreFailureReason,
+ message: str,
+ dependency: SavedEntryRef | None = None,
+ ) -> RestoreFailure:
+ """
+ Build a failure for one saved entry.
+
+ The message can repeat stored content, such as a class name or a parameter key, so it's escaped and
+ shortened.
+
+ Returns:
+ RestoreFailure: The failure, carrying the stored document's version.
+ """
+ return RestoreFailure(
+ kind=stored.ref.kind,
+ name=stored.ref.name,
+ reason=reason,
+ message=safe_failure_text(message),
+ version=stored.version,
+ dependency=dependency,
+ )
+
+ def _sort_key(self, ref: SavedEntryRef) -> tuple[int, str]:
+ """
+ Order entries by registry, then by name, so restores are repeatable.
+
+ Returns:
+ tuple[int, str]: The sort key.
+ """
+ return self._KIND_ORDER.index(ref.kind), ref.name
+
+ def _sorted(self, failures: list[RestoreFailure]) -> list[RestoreFailure]:
+ """
+ Order failures by registry, then by name, with whole-folder failures first.
+
+ Returns:
+ list[RestoreFailure]: The sorted failures.
+ """
+ return sorted(failures, key=lambda failure: (self._KIND_ORDER.index(failure.kind), failure.name or ""))
+
+ def _describe_ref(self, ref: SavedEntryRef) -> str:
+ """
+ Describe an entry for a message, such as "target 'contoso_gpt4o'".
+
+ Returns:
+ str: The description.
+ """
+ return f"{self._singular(ref.kind)} '{ref.name}'"
+
+ @staticmethod
+ def _singular(kind: RegistryRecipeKind) -> str:
+ """
+ Name one entry of a kind, such as "target".
+
+ Returns:
+ str: The singular name.
+ """
+ return kind.value.removesuffix("s")
+
+ @staticmethod
+ def _safe_message(error: Exception) -> str:
+ """
+ Describe an error for the failure report: its type and text, without URL credentials.
+
+ Stored files are trusted the way configuration is, so the text is shown as it is, except that each
+ URL in it loses its user name and password, query, and fragment, which can hold a credential such
+ as a SAS token. ``_failure`` escapes and shortens it; the log has the whole error.
+
+ Returns:
+ str: The error's type and text.
+ """
+ name = type(error).__name__
+ try:
+ text = str(error)
+ except Exception:
+ return name
+ return f"{name}: {redact_urls(text)}" if text else name
+
+
+def _is_pyrit_module(module: str) -> bool:
+ """
+ Return whether a module is part of PyRIT.
+
+ Returns:
+ bool: True for ``pyrit`` and its submodules.
+ """
+ return module == "pyrit" or module.startswith("pyrit.")
+
+
+class RecipeRejectedError(Exception):
+ """A saved recipe failed a check before or while it was built, for the reason it carries."""
+
+ def __init__(self, *, reason: RestoreFailureReason, message: str) -> None:
+ """Initialize the error with the reason to report."""
+ super().__init__(message)
+ self.reason = reason
diff --git a/pyrit/registry/registry_recipe_storage.py b/pyrit/registry/registry_recipe_storage.py
new file mode 100644
index 0000000000..616f58ce58
--- /dev/null
+++ b/pyrit/registry/registry_recipe_storage.py
@@ -0,0 +1,290 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""Storage for saved registry recipes, one folder per registry."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from pathlib import Path
+from types import MappingProxyType
+from typing import TYPE_CHECKING
+from urllib.parse import urlparse
+
+from pydantic import ValidationError
+
+from pyrit.common.azure_storage import is_azure_blob_uri, redact_urls
+from pyrit.models.catalog import (
+ ComponentRecipe,
+ IgnoredRecipeFile,
+ RegistryRecipeKind,
+ RestoreFailure,
+ RestoreFailureReason,
+ SavedEntryRef,
+ StoredRecipe,
+ TechniqueRecipe,
+)
+from pyrit.models.catalog.registry_recipe import RECIPE_SCHEMA_VERSION
+from pyrit.registry.file_document_storage import FileDocumentStorage
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+
+MAX_FAILURE_MESSAGE_LENGTH = 1_024
+_MAX_LOCATION_PART_LENGTH = 64
+
+
+def shorten_text(text: str, *, limit: int) -> str:
+ """
+ Cut text that's longer than *limit*, marking the cut.
+
+ Returns:
+ str: The text, at most *limit* characters long.
+ """
+ return text if len(text) <= limit else f"{text[: limit - 3]}..."
+
+
+def escape_unprintable(text: str) -> str:
+ """
+ Write each character that can't be shown as it is as a backslash escape.
+
+ That covers a new line or a terminal escape in a stored key, which would otherwise start a new log line or
+ change a terminal's colors, and the bytes of a file name that isn't UTF-8.
+
+ Returns:
+ str: The text, on one line and safe to encode as UTF-8.
+ """
+ if text.isprintable():
+ return text
+ return "".join(
+ character if character.isprintable() else character.encode("unicode_escape").decode("ascii")
+ for character in text
+ )
+
+
+def safe_failure_text(text: str) -> str:
+ """
+ Make text that can repeat stored content safe for a failure report: escaped, then shortened.
+
+ Returns:
+ str: The text, at most ``MAX_FAILURE_MESSAGE_LENGTH`` characters long.
+ """
+ # Escaping never makes text shorter, so what's past the cap can't show and needn't be escaped.
+ return shorten_text(escape_unprintable(text[: MAX_FAILURE_MESSAGE_LENGTH + 1]), limit=MAX_FAILURE_MESSAGE_LENGTH)
+
+
+@dataclass(frozen=True)
+class RecipeListing:
+ """Every saved recipe of one kind, with the ones that couldn't be read or parsed."""
+
+ recipes: Mapping[str, StoredRecipe] = field(default_factory=lambda: MappingProxyType({}))
+ failures: tuple[RestoreFailure, ...] = ()
+ ignored: tuple[IgnoredRecipeFile, ...] = ()
+ # For the log only, by name: the error behind each recipe that couldn't be read, and every problem with each
+ # one that isn't a valid recipe.
+ causes: Mapping[str, BaseException] = field(default_factory=lambda: MappingProxyType({}), compare=False, repr=False)
+
+
+class RegistryRecipeStorage(FileDocumentStorage):
+ """
+ Read the saved recipes of one registry as JSON documents.
+
+ Each registry has its own folder under the configured source (``targets``, ``converters``,
+ ``scorers``, ``techniques``), so a target and a converter can share a name. Storage is read
+ through on every call, like scenario presets, because the folder can be shared between
+ processes.
+ """
+
+ def __init__(self, *, kind: RegistryRecipeKind, source: str | None = None) -> None:
+ """
+ Initialize storage for one registry's folder.
+
+ Args:
+ kind (RegistryRecipeKind): The registry whose recipes this storage reads.
+ source (str | None): Local directory or Azure Blob URI that holds the per-registry
+ folders. Defaults to ``registry_recipes`` under the PyRIT configuration directory.
+
+ Raises:
+ ValueError: If the source has an unsupported URI scheme.
+ """
+ root = source or str(self._get_default_storage_dir())
+ super().__init__(
+ source=self._get_kind_source(root=root, kind=kind),
+ extension=".json",
+ source_label=f"Saved {kind.value}",
+ )
+ self._kind = kind
+
+ @staticmethod
+ def _get_default_storage_dir() -> Path:
+ """
+ Get the default directory that holds the per-registry folders.
+
+ Returns:
+ Path: Path to ``~/.pyrit/registry_recipes/``.
+ """
+ # Deferred: importing pyrit.common.path triggers pyrit __init__.py
+ from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
+
+ return CONFIGURATION_DIRECTORY_PATH / "registry_recipes"
+
+ @staticmethod
+ def _get_kind_source(*, root: str, kind: RegistryRecipeKind) -> str:
+ """
+ Append the registry's folder to the configured source.
+
+ For Azure Blob the folder goes on the URL path, so a SAS token in the query string is kept.
+
+ Returns:
+ str: The local directory or Blob URI for the registry's folder.
+ """
+ if is_azure_blob_uri(root):
+ parsed = urlparse(root)
+ return parsed._replace(path=f"{parsed.path.rstrip('/')}/{kind.value}").geturl()
+ return str(Path(root) / kind.value)
+
+ def list_recipes(self) -> RecipeListing:
+ """
+ Read every saved recipe of this kind and report the ones that can't be used.
+
+ Text from the OS, such as a file name that isn't UTF-8, is escaped here so a report can be sent as JSON.
+
+ Returns:
+ RecipeListing: Parsed recipes keyed by name, plus the failures and ignored files.
+
+ Raises:
+ DocumentStorageUnavailableError: If the folder or container can't be listed.
+ """
+ listing = self._list_documents_with_issues()
+ recipes: dict[str, StoredRecipe] = {}
+ failures = [
+ self._failure(
+ name=issue.name,
+ reason=RestoreFailureReason.UNREADABLE_RECIPE,
+ message=issue.message,
+ )
+ for issue in listing.unreadable
+ ]
+ causes = {issue.name: issue.error for issue in listing.unreadable if issue.error is not None}
+ for name, content in listing.documents.items():
+ parsed = self._parse_recipe(name=name, content=content, causes=causes)
+ if isinstance(parsed, RestoreFailure):
+ failures.append(parsed)
+ else:
+ recipes[name] = parsed
+ ignored = tuple(
+ IgnoredRecipeFile(
+ kind=self._kind,
+ file_name=escape_unprintable(issue.file_name),
+ message=safe_failure_text(issue.message),
+ )
+ for issue in listing.ignored
+ )
+ return RecipeListing(
+ recipes=MappingProxyType(recipes),
+ failures=tuple(failures),
+ ignored=ignored,
+ causes=MappingProxyType(causes),
+ )
+
+ def _parse_recipe(
+ self, *, name: str, content: bytes, causes: dict[str, BaseException] | None = None
+ ) -> StoredRecipe | RestoreFailure:
+ """
+ Parse one stored document, checking its schema version before anything else.
+
+ Args:
+ name (str): The document's name.
+ content (bytes): The stored document.
+ causes (dict[str, BaseException] | None): Where to add, by name, every problem with a document that
+ isn't a valid recipe, for the log. Defaults to None, which adds nothing.
+
+ Returns:
+ StoredRecipe | RestoreFailure: The recipe, or why it can't be used.
+ """
+ version = self._compute_version(content)
+ invalid = RestoreFailureReason.INVALID_RECIPE
+ too_deep = "Nested too deeply to read."
+ try:
+ # Recipes can be written by hand, and Windows tools often start a file with a byte-order mark.
+ payload = json.loads(content.decode("utf-8-sig"))
+ except ValueError:
+ return self._failure(name=name, version=version, reason=invalid, message="Not valid UTF-8 JSON.")
+ except RecursionError:
+ return self._failure(name=name, version=version, reason=invalid, message=too_deep)
+ if not isinstance(payload, dict):
+ return self._failure(name=name, version=version, reason=invalid, message="Not a JSON object.")
+ try:
+ json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ except UnicodeEncodeError:
+ message = "Has a \\u escape that isn't a character (an unpaired surrogate)."
+ return self._failure(name=name, version=version, reason=invalid, message=message)
+ except RecursionError:
+ return self._failure(name=name, version=version, reason=invalid, message=too_deep)
+ model = TechniqueRecipe if self._kind is RegistryRecipeKind.TECHNIQUES else ComponentRecipe
+ schema_version = payload.get("schema_version")
+ if isinstance(schema_version, int) and schema_version > RECIPE_SCHEMA_VERSION:
+ message = (
+ f"Saved by a newer PyRIT (format version {schema_version}; "
+ f"this one reads up to {RECIPE_SCHEMA_VERSION})."
+ )
+ return self._failure(
+ name=name, version=version, reason=RestoreFailureReason.NEWER_SCHEMA_VERSION, message=message
+ )
+ try:
+ recipe = model.model_validate(payload)
+ return StoredRecipe(ref=SavedEntryRef(kind=self._kind, name=name), recipe=recipe, version=version)
+ except ValidationError as error:
+ # pydantic stops at a fixed depth and reports it as a recursion error, though a JSON file can't hold a loop.
+ if any(issue["type"] == "recursion_loop" for issue in error.errors()):
+ return self._failure(name=name, version=version, reason=invalid, message=too_deep)
+ if causes is not None:
+ # Not the error itself: its text repeats the stored values.
+ causes[name] = ValueError(self._describe_validation_error(error, full=True))
+ return self._failure(
+ name=name, version=version, reason=invalid, message=self._describe_validation_error(error)
+ )
+
+ def _failure(
+ self, *, name: str, reason: RestoreFailureReason, message: str, version: str | None = None
+ ) -> RestoreFailure:
+ """
+ Build a failure for one entry in this registry's folder.
+
+ The message can repeat stored content, such as a key name or a version number, so it's escaped and shortened.
+
+ Returns:
+ RestoreFailure: The failure.
+ """
+ return RestoreFailure(
+ kind=self._kind, name=name, reason=reason, message=safe_failure_text(message), version=version
+ )
+
+ @staticmethod
+ def _describe_validation_error(error: ValidationError, *, full: bool = False) -> str:
+ """
+ Describe a validation error, naming each field and what's wrong with it, without the stored values.
+
+ A field's location can repeat a stored key, such as an unknown one, so each part of a location, and each
+ message, is escaped and loses any URL credentials. The summary also shortens each part of a location, and
+ ``_failure`` shortens the whole summary. The full description, for the log, keeps every location whole and
+ puts each problem on its own line.
+
+ Args:
+ error (ValidationError): The error from checking the recipe.
+ full (bool): Whether to describe every problem in full, for the log. Defaults to False.
+
+ Returns:
+ str: The description.
+ """
+ problems = []
+ for issue in error.errors(include_input=False, include_url=False, include_context=False):
+ # Credentials go before shortening, which could cut a URL before the end of its user name and password.
+ parts = [redact_urls(escape_unprintable(str(part))) for part in issue["loc"]]
+ if not full:
+ parts = [shorten_text(part, limit=_MAX_LOCATION_PART_LENGTH) for part in parts]
+ problems.append(f"{'.'.join(parts) or 'document'}: {redact_urls(escape_unprintable(issue['msg']))}")
+ if full:
+ return "Every problem in the file:\n" + "\n".join(f" {problem}" for problem in problems)
+ return "Not a valid saved recipe: " + "; ".join(problems)
diff --git a/pyrit/setup/configuration_loader.py b/pyrit/setup/configuration_loader.py
index 63839d1f1a..da6e2aff56 100644
--- a/pyrit/setup/configuration_loader.py
+++ b/pyrit/setup/configuration_loader.py
@@ -115,6 +115,8 @@ class ConfigurationLoader(YamlLoadable):
followed by a blob prefix, used to persist scenario presets.
scenario_suites_source: Local directory or Azure Blob container URI, optionally
followed by a blob prefix, used to persist scenario suites.
+ registry_recipes_source: Local directory or Azure Blob container URI, optionally
+ followed by a blob prefix, used to persist saved targets, converters, scorers, and techniques.
silent: Whether to suppress initialization messages.
seed: Optional root seed for deterministic converter operations.
operator: Name for the current operator, e.g. a team or username.
@@ -170,6 +172,7 @@ class ConfigurationLoader(YamlLoadable):
custom_initializers_source: str | None = None
scenario_presets_source: str | None = None
scenario_suites_source: str | None = None
+ registry_recipes_source: str | None = None
target_upload_directory: str | None = None
server: dict[str, Any] | None = None
extensions: dict[str, Any] = field(default_factory=dict)
@@ -199,6 +202,7 @@ def __post_init__(self) -> None:
self._validate_custom_initializers_source()
self._validate_scenario_presets_source()
self._validate_scenario_suites_source()
+ self._validate_registry_recipes_source()
self._validate_target_upload_directory()
self._normalize_server()
@@ -239,6 +243,15 @@ def _validate_scenario_suites_source(self) -> None:
"""
self._validate_document_source(name="scenario_suites_source", value=self.scenario_suites_source)
+ def _validate_registry_recipes_source(self) -> None:
+ """
+ Validate the optional storage source for saved registry entries.
+
+ Raises:
+ ValueError: If the source is not a non-empty string.
+ """
+ self._validate_document_source(name="registry_recipes_source", value=self.registry_recipes_source)
+
@staticmethod
def _validate_document_source(*, name: str, value: Any) -> None:
"""
diff --git a/pyrit/setup/initializers/scorers.py b/pyrit/setup/initializers/scorers.py
index 2dcdda7c35..aabb077b7e 100644
--- a/pyrit/setup/initializers/scorers.py
+++ b/pyrit/setup/initializers/scorers.py
@@ -26,6 +26,7 @@
from pyrit.common.async_compatibility import run_legacy_sync_async
from pyrit.models import Parameter, SeedPrompt
from pyrit.registry import ScorerRegistry, TargetRegistry
+from pyrit.registry.instance_registry import refuse_saved_entry
from pyrit.score import (
AzureContentFilterScorer,
DecisionScorer,
@@ -513,6 +514,8 @@ def _tag_best_per_category(self) -> None:
for best_tag, (preferred_name, category_tag) in self._PREFERRED_BEST.items():
entry = scorer_registry.instances.get_entry(preferred_name)
if entry is not None:
+ # A saved scorer under this name would stand in for the one this script couldn't build.
+ refuse_saved_entry(entry)
scorer_registry.instances.add_tags(name=preferred_name, tags=[best_tag])
logger.info(f"Tagged {preferred_name} as {best_tag}")
continue
@@ -635,7 +638,10 @@ def _tag_best_objective(self) -> None:
# Fall back: prefer scale_and_refusal, then first composite
best_tags: list[str] = [ScorerInitializerTags.BEST_OBJECTIVE, ScorerInitializerTags.DEFAULT_OBJECTIVE_SCORER]
- if scorer_registry.instances.get_entry(self.SCALE_AND_REFUSAL):
+ scale_and_refusal = scorer_registry.instances.get_entry(self.SCALE_AND_REFUSAL)
+ if scale_and_refusal is not None:
+ # A saved scorer under this name would stand in for the one this script couldn't build.
+ refuse_saved_entry(scale_and_refusal)
fallback_name = self.SCALE_AND_REFUSAL
else:
composites = scorer_registry.instances.get_by_tag(tag=ScorerInitializerTags.OBJECTIVE_COMPOSITE)
diff --git a/pyrit/setup/initializers/targets.py b/pyrit/setup/initializers/targets.py
index 35d05043fe..92c00655ed 100644
--- a/pyrit/setup/initializers/targets.py
+++ b/pyrit/setup/initializers/targets.py
@@ -37,6 +37,7 @@
RoundRobinTarget,
)
from pyrit.registry import TargetRegistry
+from pyrit.registry.instance_registry import refuse_saved_entry
from pyrit.setup.pyrit_initializer import PyRITInitializer
logger = logging.getLogger(__name__)
@@ -827,6 +828,8 @@ def _auto_group_targets(self) -> None:
rr_name = generate_rr_name(key)
if rr_name in registry.instances:
+ # Skipping would leave a saved target standing in for this group.
+ refuse_saved_entry(registry.instances.get_entry(rr_name))
logger.debug(f"Skipping auto-group {rr_name}: name already exists in registry")
continue
diff --git a/pyrit/setup/initializers/tools.py b/pyrit/setup/initializers/tools.py
index 0c3dbe0554..d6f4a23f78 100644
--- a/pyrit/setup/initializers/tools.py
+++ b/pyrit/setup/initializers/tools.py
@@ -8,6 +8,7 @@
from pyrit.executor.attack import AttackConverterConfig, PromptSendingAttack
from pyrit.prompt_normalizer import ConverterConfiguration
from pyrit.registry import AttackTechniqueRegistry, ConverterRegistry
+from pyrit.registry.instance_registry import refuse_saved_entry
from pyrit.scenario import AttackTechniqueFactory
from pyrit.setup.pyrit_initializer import PyRITInitializer
@@ -18,8 +19,11 @@ class ToolsInitializer(PyRITInitializer):
async def initialize_async(self) -> None:
"""Register tool-use techniques using the configured adversarial chat."""
registry = AttackTechniqueRegistry.get_registry_singleton()
+ # Returning early would leave a saved technique standing in for this one.
+ refuse_saved_entry(registry.instances.get_entry("role_play_tool_calls"))
if "role_play_tool_calls" in registry.instances:
return
+ refuse_saved_entry(ConverterRegistry.get_registry_singleton().instances.get_entry("role_play_tool_calls"))
converter = await asyncio.to_thread(
ConverterRegistry.get_registry_singleton().create_instance,
"RolePlayToolCallConverter",
diff --git a/test_support/analytics_benchmark.py b/test_support/analytics_benchmark.py
index 55208ed889..f1086765aa 100644
--- a/test_support/analytics_benchmark.py
+++ b/test_support/analytics_benchmark.py
@@ -65,6 +65,7 @@ def benchmark_directory(tmp_path: Path) -> Iterator[Path]:
"initializers": ["technique"],
"initialization_scripts": [],
"custom_initializers_source": str(directory / "initializers"),
+ "registry_recipes_source": str(directory / "registry_recipes"),
"silent": True,
}
),
diff --git a/tests/unit/backend/conftest.py b/tests/unit/backend/conftest.py
index c015f8bf0c..bcea879c42 100644
--- a/tests/unit/backend/conftest.py
+++ b/tests/unit/backend/conftest.py
@@ -12,11 +12,14 @@
from pyrit.analytics import AttackResultAnalytics
from pyrit.analytics._execution import AnalyticsExecution
-from pyrit.backend.services import get_attack_service, get_dataset_service
+from pyrit.backend.services import get_attack_service, get_converter_service, get_dataset_service, get_target_service
from pyrit.backend.services.analytics_service import close_analytics_service_async, get_analytics_service
from pyrit.backend.services.manual_send_scheduler import get_manual_send_scheduler
from pyrit.backend.services.message_send_service import get_message_send_service
+from pyrit.backend.services.scorer_service import get_scorer_service
+from pyrit.backend.services.technique_service import get_technique_service
from pyrit.memory import SQLiteMemory
+from pyrit.registry.registry_recipe_storage import RegistryRecipeStorage
from test_support.fixtures import authentication_policy, compatibility_headers, compatibility_id # noqa: F401
from unit.async_utils import TEST_WATCHDOG_SECONDS
@@ -48,11 +51,30 @@ def managed_media_path(*, sqlite_instance: SQLiteMemory, patch_central_database:
return path.resolve()
+_ISOLATED_SERVICE_FACTORIES = (
+ get_attack_service,
+ get_message_send_service,
+ get_manual_send_scheduler,
+ get_dataset_service,
+ get_target_service,
+ get_converter_service,
+ get_scorer_service,
+ get_technique_service,
+)
+
+
@pytest.fixture(autouse=True)
def isolated_manual_message_services() -> Iterator[None]:
- """Keep cached service owners bound to this test's memory and event loop."""
- for factory in (get_attack_service, get_message_send_service, get_manual_send_scheduler, get_dataset_service):
+ """Keep cached service owners bound to this test's memory, registries, and event loop."""
+ for factory in _ISOLATED_SERVICE_FACTORIES:
factory.cache_clear()
yield
- for factory in (get_attack_service, get_message_send_service, get_manual_send_scheduler, get_dataset_service):
+ for factory in _ISOLATED_SERVICE_FACTORIES:
factory.cache_clear()
+
+
+@pytest.fixture(autouse=True)
+def isolated_saved_registry_entries(tmp_path: Path) -> Iterator[None]:
+ """Keep backend startup and apply from restoring entries saved on the machine running the tests."""
+ with patch.object(RegistryRecipeStorage, "_get_default_storage_dir", return_value=tmp_path / "registry_recipes"):
+ yield
diff --git a/tests/unit/backend/test_analytics_benchmark.py b/tests/unit/backend/test_analytics_benchmark.py
index 9e5b75096c..8dcbb30e59 100644
--- a/tests/unit/backend/test_analytics_benchmark.py
+++ b/tests/unit/backend/test_analytics_benchmark.py
@@ -4,6 +4,7 @@
"""Local SQLite smoke coverage and unit tests of the integration workload's failure gates."""
import asyncio
+import json
import os
import subprocess
import sys
@@ -60,6 +61,12 @@ def measurements() -> AnalyticsBenchmark:
return benchmark
+def test_benchmark_configuration_keeps_saved_entries_in_its_own_folder(benchmark_directory: Path) -> None:
+ config = json.loads((benchmark_directory / "config.yaml").read_text(encoding="utf-8"))
+
+ assert config["registry_recipes_source"] == str(benchmark_directory / "registry_recipes")
+
+
@pytest.mark.timeout(120)
@pytest.mark.usefixtures("semantic_analytics_budgets")
@pytest.mark.parametrize("elapsed_report_seconds", [0.0, 31.0], ids=["normal", "expired-report-deadline"])
diff --git a/tests/unit/backend/test_error_handlers.py b/tests/unit/backend/test_error_handlers.py
index 2992a232c6..86207bda56 100644
--- a/tests/unit/backend/test_error_handlers.py
+++ b/tests/unit/backend/test_error_handlers.py
@@ -5,12 +5,16 @@
Tests for backend error handler middleware.
"""
+import logging
+
import pytest
+from azure.core.exceptions import ServiceRequestError
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
from pyrit.backend.middleware import register_error_handlers
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError
class TestErrorHandlers:
@@ -125,6 +129,77 @@ async def test_endpoint() -> dict:
assert data["title"] == "Forbidden"
assert data["status"] == 403
+ def test_storage_failure_returns_503_and_names_the_location_only_in_the_log(
+ self, app: FastAPI, client: TestClient, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ """Test that the log names the storage location and the response doesn't."""
+ error = DocumentStorageUnavailableError(
+ label="Scenario preset", source="/srv/presets", reason="Permission denied"
+ )
+
+ @app.get("/test")
+ async def test_endpoint() -> dict:
+ raise error
+
+ with caplog.at_level(logging.DEBUG, logger="pyrit.backend.middleware.error_handlers"):
+ response = client.get("/test")
+
+ assert response.status_code == 503
+ assert response.json() == {
+ "type": "/errors/storage-unavailable",
+ "title": "Service Unavailable",
+ "status": 503,
+ "detail": "Scenario preset storage is unavailable",
+ "instance": "/test",
+ }
+ assert "Scenario preset storage at /srv/presets is unavailable: Permission denied (GET /test)" in (
+ caplog.messages
+ )
+ assert all(record.exc_info is None for record in caplog.records)
+
+ def test_storage_failure_log_leaves_out_a_sas_url_from_the_error_behind_it(
+ self, app: FastAPI, client: TestClient, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ """Test that a SAS URL in the error behind a storage failure stays out of the log, even at debug level."""
+
+ @app.get("/test")
+ async def test_endpoint() -> dict:
+ cause = ServiceRequestError(message="https://account.blob.core.windows.net/presets?sp=r&sig=secret-sig")
+ raise DocumentStorageUnavailableError(
+ label="Scenario preset",
+ source="https://account.blob.core.windows.net/presets",
+ reason="ServiceRequestError",
+ ) from cause
+
+ with caplog.at_level(logging.DEBUG, logger="pyrit.backend.middleware.error_handlers"):
+ response = client.get("/test")
+
+ assert response.status_code == 503
+ assert (
+ "Scenario preset storage at https://account.blob.core.windows.net/presets is unavailable: "
+ "ServiceRequestError (GET /test)"
+ ) in caplog.messages
+ assert "secret-sig" not in caplog.text
+
+ def test_unreadable_document_returns_503_with_the_reason(self, app: FastAPI, client: TestClient) -> None:
+ """Test that one document that can't be read is named with its reason."""
+
+ @app.get("/test")
+ async def test_endpoint() -> dict:
+ raise DocumentUnreadableError(
+ label="Scenario preset",
+ source="/srv/presets",
+ name="nightly",
+ file_name="nightly.json",
+ reason="Not a regular file",
+ )
+
+ response = client.get("/test")
+
+ assert response.status_code == 503
+ assert response.json()["detail"] == "Scenario preset 'nightly' can't be read: Not a regular file"
+ assert "/srv/presets" not in response.text
+
def test_not_implemented_error_returns_501(self, app: FastAPI, client: TestClient) -> None:
"""Test that NotImplementedError returns 501 with RFC 7807 format."""
diff --git a/tests/unit/backend/test_initializer_service.py b/tests/unit/backend/test_initializer_service.py
index e1d06ed5bf..18c6ea3f2e 100644
--- a/tests/unit/backend/test_initializer_service.py
+++ b/tests/unit/backend/test_initializer_service.py
@@ -5,11 +5,11 @@
Tests for backend initializer service and routes.
"""
+import logging
from collections.abc import Iterator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from azure.core.exceptions import AzureError
from fastapi import HTTPException, status
from fastapi.testclient import TestClient
@@ -26,6 +26,7 @@
from pyrit.backend.services import InitializerService, get_initializer_service
from pyrit.models import Parameter
from pyrit.registry import InitializerMetadata
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError
@pytest.fixture
@@ -470,13 +471,20 @@ def test_custom_initializer_routes_return_503_for_storage_failure(
self,
client_with_custom_initializers_enabled: TestClient,
operation: str,
+ caplog: pytest.LogCaptureFixture,
) -> None:
- """Test Blob failures are returned without exposing Azure SDK details."""
- with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service:
+ """Test that the log names the storage location and the response doesn't."""
+ error = DocumentStorageUnavailableError(
+ label="Custom initializer", source="/srv/initializers", reason="Permission denied"
+ )
+ with (
+ patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service,
+ caplog.at_level(logging.WARNING, logger="pyrit.backend.middleware.error_handlers"),
+ ):
mock_service = MagicMock()
- mock_service.list_custom_initializers_async = AsyncMock(side_effect=AzureError("credential details"))
- mock_service.register_initializer_async = AsyncMock(side_effect=AzureError("credential details"))
- mock_service.unregister_initializer_async = AsyncMock(side_effect=AzureError("credential details"))
+ mock_service.list_custom_initializers_async = AsyncMock(side_effect=error)
+ mock_service.register_initializer_async = AsyncMock(side_effect=error)
+ mock_service.unregister_initializer_async = AsyncMock(side_effect=error)
mock_get_service.return_value = mock_service
if operation == "list":
response = client_with_custom_initializers_enabled.get("/api/initializers/custom")
@@ -489,7 +497,29 @@ def test_custom_initializer_routes_return_503_for_storage_failure(
response = client_with_custom_initializers_enabled.delete("/api/initializers/custom")
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
- assert response.json()["detail"] == "Custom initializer storage is temporarily unavailable"
+ assert response.json()["detail"] == "Custom initializer storage is unavailable"
+ assert "/srv/initializers" not in response.text
+ assert "Custom initializer storage at /srv/initializers is unavailable: Permission denied" in caplog.text
+
+ def test_deleting_a_script_that_cant_be_read_returns_503_with_the_reason(
+ self, client_with_custom_initializers_enabled: TestClient
+ ) -> None:
+ error = DocumentUnreadableError(
+ label="Custom initializer",
+ source="/srv/initializers",
+ name="custom",
+ file_name="custom.py",
+ reason="Not a regular file",
+ )
+ with patch("pyrit.backend.routes.initializers.get_initializer_service") as mock_get_service:
+ mock_service = MagicMock()
+ mock_service.unregister_initializer_async = AsyncMock(side_effect=error)
+ mock_get_service.return_value = mock_service
+
+ response = client_with_custom_initializers_enabled.delete("/api/initializers/custom")
+
+ assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
+ assert response.json()["detail"] == "Custom initializer 'custom' can't be read: Not a regular file"
def test_post_root_still_registers_runtime_initializer(
self, client_with_custom_initializers_enabled: TestClient
diff --git a/tests/unit/backend/test_runtime_lifecycle.py b/tests/unit/backend/test_runtime_lifecycle.py
index 9330f62389..94c47971ac 100644
--- a/tests/unit/backend/test_runtime_lifecycle.py
+++ b/tests/unit/backend/test_runtime_lifecycle.py
@@ -4,12 +4,14 @@
"""Admission and fail-fast runtime replacement invariants."""
import asyncio
+import json
+import logging
import os
-from collections.abc import Generator
+from collections.abc import Callable, Generator
from contextlib import ExitStack
from datetime import UTC, datetime
from pathlib import Path
-from threading import Event
+from threading import Event, Thread, current_thread
from unittest.mock import AsyncMock, MagicMock, call, patch
import httpx
@@ -22,18 +24,36 @@
from pyrit.backend.middleware.auth import require_admin
from pyrit.backend.middleware.runtime import RuntimeAdmissionMiddleware
from pyrit.backend.routes import configuration, health
-from pyrit.backend.services import ScenarioRunService
+from pyrit.backend.services import ScenarioRunService, get_converter_service, get_target_service
from pyrit.backend.services.configuration_file_service import ConfigurationFileService
from pyrit.backend.services.manual_send_scheduler import get_manual_send_scheduler
from pyrit.backend.services.runtime_lifecycle import RuntimeLifecycle
from pyrit.backend.services.scenario_run_service import _PreparedRun
+from pyrit.backend.services.scorer_service import get_scorer_service
+from pyrit.backend.services.technique_service import get_technique_service
from pyrit.memory import CentralMemory, MemoryInterface
from pyrit.models import ScenarioRunState
from pyrit.models.catalog import RunScenarioRequest
+from pyrit.prompt_target import OpenAIChatTarget
+from pyrit.registry import AttackTechniqueRegistry, ConverterRegistry, ScorerRegistry, TargetRegistry
+from pyrit.registry.registry_recipe_restore import RegistryRecipeRestorer, SavedRecipes
from pyrit.scenario import Scenario
from pyrit.setup import ConfigurationLoader
+from pyrit.setup.initialization import reset_setup_registries
from unit.async_utils import TEST_WATCHDOG_SECONDS
-from unit.mocks import make_scenario_result
+from unit.mocks import MockPromptTarget, make_scenario_result
+
+_RESTORED_REGISTRIES = (TargetRegistry, ConverterRegistry, ScorerRegistry, AttackTechniqueRegistry)
+
+
+@pytest.fixture(autouse=True)
+def fresh_registries() -> Generator[None, None, None]:
+ """Start each test from the registries a new process has, as a real startup or apply does."""
+ for registry in _RESTORED_REGISTRIES:
+ registry.reset_registry_singleton()
+ yield
+ for registry in _RESTORED_REGISTRIES:
+ registry.reset_registry_singleton()
@pytest.fixture
@@ -978,3 +998,381 @@ async def close_async() -> None:
finally:
release.set()
await asyncio.gather(shutdown, return_exceptions=True)
+
+
+def _save_recipe(root: Path, *, kind: str, name: str, document: dict[str, object]) -> Path:
+ folder = root / kind
+ folder.mkdir(parents=True, exist_ok=True)
+ path = folder / f"{name}.json"
+ path.write_text(json.dumps(document), encoding="utf-8")
+ return path
+
+
+async def _start_with_recipes_async(
+ runtime: RuntimeLifecycle, *, recipes: Path, initialize: Callable[..., object] | None = None
+) -> None:
+ config = await runtime._load_async()
+ config.registry_recipes_source = str(recipes)
+ with (
+ patch.object(config, "initialize_pyrit_async", AsyncMock(side_effect=initialize)),
+ patch.object(lifecycle_module, "get_scenario_run_service", return_value=MagicMock(spec=ScenarioRunService)),
+ ):
+ await runtime.startup_async()
+
+
+@pytest.fixture
+def startup_chat() -> Generator[OpenAIChatTarget, None, None]:
+ TargetRegistry.reset_registry_singleton()
+ ConverterRegistry.reset_registry_singleton()
+ yield OpenAIChatTarget(
+ endpoint="https://startup.openai.azure.com/openai/v1", model_name="gpt-4o", auth_mode="identity"
+ )
+ TargetRegistry.reset_registry_singleton()
+ ConverterRegistry.reset_registry_singleton()
+
+
+def _save_converter_using_startup_chat(recipes: Path) -> None:
+ _save_recipe(
+ recipes,
+ kind="converters",
+ name="formal",
+ document={
+ "class_module": "pyrit.converter.tone_converter",
+ "class_name": "ToneConverter",
+ "params": {"converter_target": "startup_chat", "tone": "formal"},
+ },
+ )
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_startup_restores_an_entry_that_uses_a_startup_target_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, startup_chat: OpenAIChatTarget
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_converter_using_startup_chat(recipes)
+ targets = TargetRegistry.get_registry_singleton()
+
+ await _start_with_recipes_async(
+ runtime, recipes=recipes, initialize=lambda **_: targets.instances.register(startup_chat, name="startup_chat")
+ )
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ converter = ConverterRegistry.get_registry_singleton().instances.get("formal")
+ assert converter is not None
+ assert converter._converter_target is startup_chat
+
+
+class _LoopBoundTarget(MockPromptTarget):
+ """A target whose constructor needs the running event loop, as HuggingFaceChatTarget's does."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.loop = asyncio.get_running_loop()
+
+
+@pytest.mark.usefixtures("patch_central_database", "startup_chat")
+async def test_startup_builds_restored_entries_on_the_runtime_loop_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ TargetRegistry.get_registry_singleton().register_class(_LoopBoundTarget)
+ recipes = tmp_path / "recipes"
+ _save_recipe(
+ recipes,
+ kind="targets",
+ name="loop_bound",
+ document={"class_module": _LoopBoundTarget.__module__, "class_name": "_LoopBoundTarget", "params": {}},
+ )
+
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ target = TargetRegistry.get_registry_singleton().instances.get("loop_bound")
+ assert isinstance(target, _LoopBoundTarget)
+ assert target.loop is asyncio.get_running_loop()
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_startup_reads_saved_entries_off_the_runtime_loop_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ read = RegistryRecipeRestorer.read
+ read_threads: list[Thread] = []
+
+ def read_noting_thread(restorer: RegistryRecipeRestorer) -> SavedRecipes:
+ read_threads.append(current_thread())
+ return read(restorer)
+
+ with patch.object(RegistryRecipeRestorer, "read", read_noting_thread):
+ await _start_with_recipes_async(runtime, recipes=tmp_path / "recipes")
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ assert len(read_threads) == 1
+ assert read_threads[0] is not current_thread()
+
+
+async def _forget_registry_services_async() -> None:
+ """Stand in for close_services_async, which drops services bound to the registries an apply replaces."""
+ for factory in (get_target_service, get_converter_service, get_scorer_service, get_technique_service):
+ factory.cache_clear()
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_apply_after_a_startup_restore_restores_the_same_entries_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, startup_chat: OpenAIChatTarget
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_converter_using_startup_chat(recipes)
+ _save_recipe(
+ recipes,
+ kind="scorers",
+ name="refusal",
+ document={
+ "class_module": "pyrit.score.true_false.self_ask_refusal_scorer",
+ "class_name": "SelfAskRefusalScorer",
+ "params": {"chat_target": "startup_chat"},
+ },
+ )
+
+ def register_startup_chat(**_: object) -> None:
+ TargetRegistry.get_registry_singleton().instances.register(startup_chat, name="startup_chat")
+
+ def reinitialize(**_: object) -> None:
+ reset_setup_registries()
+ register_startup_chat()
+
+ await _start_with_recipes_async(runtime, recipes=recipes, initialize=register_startup_chat)
+ assert runtime.restore_failures == []
+ config = await runtime._load_async()
+ config.apply_prepared_reinitialization_async.side_effect = reinitialize
+ lifecycle_module.close_services_async.side_effect = _forget_registry_services_async
+
+ await apply_async(runtime)
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ assert runtime.restore_failures == []
+ converter = ConverterRegistry.get_registry_singleton().instances.get("formal")
+ assert converter is not None
+ assert converter._converter_target is startup_chat
+ assert ScorerRegistry.get_registry_singleton().instances.get("refusal") is not None
+
+
+class _UnreachableTarget(MockPromptTarget):
+ """A target whose constructor fails with a URL that carries a SAS token."""
+
+ def __init__(self) -> None:
+ raise ValueError("can't reach https://contoso.blob.core.windows.net/c?sv=2024&sig=SYNTHETIC-SAS")
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_startup_logs_each_failure_once_without_url_credentials_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ TargetRegistry.get_registry_singleton().register_class(_UnreachableTarget)
+ recipes = tmp_path / "recipes"
+ _save_recipe(
+ recipes,
+ kind="targets",
+ name="unreachable",
+ document={"class_module": _UnreachableTarget.__module__, "class_name": "_UnreachableTarget", "params": {}},
+ )
+ _save_recipe(recipes, kind="targets", name="broken", document={"class_module": "nowhere", "class_name": "Nope"})
+
+ with caplog.at_level(logging.WARNING):
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert (runtime.state, runtime.outcome) == ("failed", "restore-failed")
+ for name in ("unreachable", "broken"):
+ assert len([record for record in caplog.records if f"target '{name}' (" in record.getMessage()]) == 1
+ assert "can't reach https://contoso.blob.core.windows.net/c" in caplog.text
+ assert "sig=" not in caplog.text
+ assert "SYNTHETIC-SAS" not in caplog.text
+
+
+async def test_startup_says_when_a_saved_folder_couldnt_be_read_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ recipes = tmp_path / "recipes"
+ recipes.mkdir()
+ (recipes / "targets").write_text("a file where the folder should be")
+
+ with caplog.at_level(logging.WARNING, logger="pyrit.backend.services.runtime_lifecycle"):
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert (runtime.state, runtime.outcome) == ("failed", "restore-failed")
+ assert runtime.message == (
+ "The saved targets couldn't be read, so no saved entries were restored. Check that the backend can read "
+ "registry_recipes_source, then apply the configuration or restart the backend."
+ )
+ assert runtime.message in caplog.text
+ assert "Fix or delete" not in caplog.text
+
+
+async def test_startup_with_an_unrestorable_entry_is_not_ready_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, caplog: pytest.LogCaptureFixture
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(recipes, kind="targets", name="broken", document={"class_module": "nowhere", "class_name": "Nope"})
+
+ with caplog.at_level(logging.WARNING, logger="pyrit.backend.services.runtime_lifecycle"):
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert (runtime.state, runtime.outcome) == ("failed", "restore-failed")
+ assert runtime.message.startswith("1 saved registry entry couldn't be restored. Fix or delete the files")
+ assert runtime.message in caplog.text
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ health = await client.get("/api/health")
+ status = await client.get("/api/config/runtime")
+ assert health.json()["ready"] is False
+ assert status.json()["version"] == runtime.version
+ assert runtime.version is not None
+ (failure,) = status.json()["restore_failures"]
+ assert (failure["kind"], failure["name"], failure["reason"]) == ("targets", "broken", "unknown_class")
+ assert failure["version"]
+
+
+async def test_status_reports_a_recipe_whose_text_is_not_valid_unicode_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(
+ recipes,
+ kind="targets",
+ name="pool",
+ document={
+ "class_module": "pyrit.prompt_target.round_robin_target",
+ "class_name": "RoundRobinTarget",
+ "params": {"targets": ["missing\udcff"]},
+ },
+ )
+
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ status = await client.get("/api/config/runtime")
+ assert status.status_code == 200
+ (failure,) = status.json()["restore_failures"]
+ assert (failure["name"], failure["reason"]) == ("pool", "invalid_recipe")
+
+
+async def test_status_lists_a_file_name_that_is_not_valid_text_async(runtime: RuntimeLifecycle, tmp_path: Path) -> None:
+ folder = tmp_path / "recipes" / "converters"
+ folder.mkdir(parents=True)
+ try:
+ (folder / os.fsdecode(b"bad\xffname.json")).write_text("{}")
+ except (OSError, UnicodeError):
+ pytest.skip("This file system only allows UTF-8 file names")
+
+ await _start_with_recipes_async(runtime, recipes=tmp_path / "recipes")
+
+ async with httpx.AsyncClient(transport=httpx.ASGITransport(app=runtime.app), base_url="http://test") as client:
+ status = await client.get("/api/config/runtime")
+ assert status.status_code == 200
+ assert [item["file_name"] for item in status.json()["restore_ignored"]] == ["bad\\udcffname.json"]
+
+
+async def test_startup_lists_ignored_files_without_blocking_readiness_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(recipes, kind="converters", name="Not-A-Name", document={})
+
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert runtime.state == "ready"
+ status = runtime.status()
+ assert status["restore_failures"] == []
+ assert [(item["kind"], item["file_name"]) for item in status["restore_ignored"]] == [
+ ("converters", "Not-A-Name.json")
+ ]
+
+
+async def test_fixing_the_file_and_restarting_reaches_ready_async(runtime: RuntimeLifecycle, tmp_path: Path) -> None:
+ recipes = tmp_path / "recipes"
+ broken = _save_recipe(recipes, kind="scorers", name="broken", document={"schema_version": 99})
+ await _start_with_recipes_async(runtime, recipes=recipes)
+ assert runtime.status()["restore_failures"][0]["reason"] == "newer_schema_version"
+
+ broken.unlink()
+ await _start_with_recipes_async(runtime, recipes=recipes)
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ assert runtime.status()["restore_failures"] == []
+
+
+async def test_apply_with_an_unrestorable_entry_keeps_the_new_version_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(recipes, kind="techniques", name="broken", document={"type": "NoSuchAttack"})
+ config = await runtime._load_async()
+ config.registry_recipes_source = str(recipes)
+
+ await apply_async(runtime)
+
+ _, current_version = await runtime.source.read_with_version_async()
+ assert (runtime.state, runtime.outcome) == ("failed", "restore-failed")
+ assert runtime.version == current_version
+ assert runtime.status()["restore_failures"][0]["reason"] == "unknown_class"
+ config.apply_prepared_reinitialization_async.assert_awaited_once()
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_apply_from_failed_restores_again_once_the_file_is_fixed_async(
+ runtime: RuntimeLifecycle, tmp_path: Path, startup_chat: OpenAIChatTarget
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_converter_using_startup_chat(recipes)
+ broken = _save_recipe(
+ recipes, kind="targets", name="broken", document={"class_module": "nowhere", "class_name": "Nope"}
+ )
+
+ def register_startup_chat(**_: object) -> None:
+ TargetRegistry.get_registry_singleton().instances.register(startup_chat, name="startup_chat")
+
+ def reinitialize(**_: object) -> None:
+ reset_setup_registries()
+ register_startup_chat()
+
+ await _start_with_recipes_async(runtime, recipes=recipes, initialize=register_startup_chat)
+ assert (runtime.state, runtime.outcome) == ("failed", "restore-failed")
+ assert runtime.message.endswith("then apply the configuration or restart the backend.")
+ broken.unlink()
+ config = await runtime._load_async()
+ config.apply_prepared_reinitialization_async.side_effect = reinitialize
+ lifecycle_module.close_services_async.side_effect = _forget_registry_services_async
+
+ await apply_async(runtime)
+
+ assert (runtime.state, runtime.outcome) == ("ready", "success")
+ assert runtime.restore_failures == []
+ assert ConverterRegistry.get_registry_singleton().instances.get("formal") is not None
+
+
+async def test_apply_from_failed_without_live_apply_says_to_restart_async(
+ runtime: RuntimeLifecycle, tmp_path: Path
+) -> None:
+ recipes = tmp_path / "recipes"
+ _save_recipe(recipes, kind="targets", name="broken", document={"class_module": "nowhere", "class_name": "Nope"})
+ config = await runtime._load_async()
+ config.enable_live_reinitialization = False
+
+ await _start_with_recipes_async(runtime, recipes=recipes)
+ assert (runtime.state, runtime.outcome) == ("failed", "restore-failed")
+ assert runtime.message.endswith("named in the server log or GET /api/config/runtime, then restart the backend.")
+
+ await apply_async(runtime)
+
+ assert (runtime.state, runtime.outcome) == ("failed", "unsupported")
+ assert runtime.message == "Live apply is off. Restart the backend to restore the saved entries again."
+ config.preflight_reinitialization_async.assert_not_awaited()
+ lifecycle_module.close_services_async.assert_not_awaited()
+
+
+async def test_restore_cancellation_propagates_from_startup_async(runtime: RuntimeLifecycle, tmp_path: Path) -> None:
+ runtime.state = "initializing"
+
+ with patch.object(RegistryRecipeRestorer, "read", side_effect=asyncio.CancelledError):
+ with pytest.raises(asyncio.CancelledError):
+ await _start_with_recipes_async(runtime, recipes=tmp_path / "recipes")
+
+ assert runtime.state == "initializing"
diff --git a/tests/unit/backend/test_saved_entry_restore.py b/tests/unit/backend/test_saved_entry_restore.py
new file mode 100644
index 0000000000..dde2721798
--- /dev/null
+++ b/tests/unit/backend/test_saved_entry_restore.py
@@ -0,0 +1,176 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import json
+import os
+import sys
+from collections.abc import Iterator
+from functools import partial
+from pathlib import Path
+from types import ModuleType
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+
+from pyrit.backend.services.saved_entry_restore import build_saved_entry_async
+from pyrit.models.catalog import RegistryRecipeKind, RegistryRestoreReport, RestoreFailureReason
+from pyrit.prompt_target import HTTPXAPITarget, OpenAIChatTarget
+from pyrit.registry import AttackTechniqueRegistry, ConverterRegistry, ScorerRegistry, TargetRegistry
+from pyrit.registry.registry_recipe_restore import RegistryRecipeRestorer
+
+_IDENTITY_TARGET = {
+ "class_module": "pyrit.prompt_target.openai.openai_chat_target",
+ "class_name": "OpenAIChatTarget",
+ "auth_mode": "identity",
+ "params": {"endpoint": "https://contoso.openai.azure.com/openai/v1", "model_name": "gpt-4o"},
+}
+_REGISTRIES = (TargetRegistry, ConverterRegistry, ScorerRegistry, AttackTechniqueRegistry)
+
+
+@pytest.fixture(autouse=True)
+def isolated_registries() -> Iterator[None]:
+ for registry in _REGISTRIES:
+ registry.reset_registry_singleton()
+ yield
+ for registry in _REGISTRIES:
+ registry.reset_registry_singleton()
+
+
+def _save(root: Path, kind: RegistryRecipeKind, name: str, document: dict[str, Any]) -> None:
+ folder = root / kind.value
+ folder.mkdir(parents=True, exist_ok=True)
+ (folder / f"{name}.json").write_text(json.dumps(document))
+
+
+async def _restore(root: Path, *, upload_directory: str | None = None) -> RegistryRestoreReport:
+ build_async = partial(build_saved_entry_async, upload_directory=upload_directory)
+ return await RegistryRecipeRestorer(source=str(root)).restore_async(build_async=build_async)
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_restores_each_kind_through_its_create(tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "tone_formal",
+ {
+ "class_module": "pyrit.converter.tone_converter",
+ "class_name": "ToneConverter",
+ "params": {"converter_target": "contoso_gpt4o", "tone": "formal"},
+ },
+ )
+ _save(
+ tmp_path,
+ RegistryRecipeKind.SCORERS,
+ "refusal",
+ {
+ "class_module": "pyrit.score.true_false.self_ask_refusal_scorer",
+ "class_name": "SelfAskRefusalScorer",
+ "params": {"chat_target": "contoso_gpt4o"},
+ },
+ )
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TECHNIQUES,
+ "formal_red_team",
+ {
+ "type": "RedTeamingAttack",
+ "params": {"max_turns": 5},
+ "tags": ["custom"],
+ "adversarial_chat": "contoso_gpt4o",
+ "adversarial_system_prompt": "You are a red teaming assistant.",
+ "request_converters": ["tone_formal"],
+ },
+ )
+
+ report = await _restore(tmp_path)
+
+ assert report.failures == []
+ target = TargetRegistry.get_registry_singleton().instances.get("contoso_gpt4o")
+ assert isinstance(target, OpenAIChatTarget)
+ assert target._auth_mode == "identity"
+ converter = ConverterRegistry.get_registry_singleton().instances.get("tone_formal")
+ assert converter is not None
+ assert converter._converter_target is target
+ assert ScorerRegistry.get_registry_singleton().instances.get("refusal") is not None
+ factory = AttackTechniqueRegistry.get_registry_singleton().instances.get("formal_red_team")
+ assert factory is not None
+ assert factory.technique_tags == ["custom"]
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_a_target_its_create_refuses_fails_only_that_entry(tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TARGETS,
+ "copilot",
+ {
+ "class_module": "pyrit.prompt_target.github_copilot_target",
+ "class_name": "GitHubCopilotTarget",
+ "params": {"model_name": "gpt-5-mini"},
+ },
+ )
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+
+ with patch.dict(sys.modules, {"copilot": ModuleType("copilot")}), patch.dict(os.environ, {"GITHUB_TOKEN": ""}):
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("copilot", RestoreFailureReason.BUILD_ERROR)
+ assert "GitHub Copilot requires a GitHub token" in failure.message
+ assert [ref.name for ref in report.restored] == ["contoso_gpt4o"]
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_a_target_that_uploads_files_gets_this_servers_upload_folder(tmp_path: Path) -> None:
+ uploads = tmp_path / "uploads"
+ uploads.mkdir()
+ recipes = tmp_path / "recipes"
+ _save(
+ recipes,
+ RegistryRecipeKind.TARGETS,
+ "uploader",
+ {
+ "class_module": "pyrit.prompt_target.http_target.httpx_api_target",
+ "class_name": "HTTPXAPITarget",
+ "params": {"http_url": "https://example.test/upload"},
+ },
+ )
+
+ report = await _restore(recipes, upload_directory=str(uploads))
+
+ assert report.failures == []
+ target = TargetRegistry.get_registry_singleton().instances.get("uploader")
+ assert isinstance(target, HTTPXAPITarget)
+ assert target.allowed_upload_directory == uploads.resolve()
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_a_target_that_uploads_files_fails_where_the_server_sets_no_upload_folder(tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TARGETS,
+ "uploader",
+ {
+ "class_module": "pyrit.prompt_target.http_target.httpx_api_target",
+ "class_name": "HTTPXAPITarget",
+ "params": {"http_url": "https://example.test/upload"},
+ },
+ )
+
+ (failure,) = (await _restore(tmp_path)).failures
+
+ assert (failure.name, failure.reason) == ("uploader", RestoreFailureReason.BUILD_ERROR)
+ assert "target_upload_directory" in failure.message
+
+
+async def test_a_technique_its_create_request_refuses_is_invalid(tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "types", {"type": "RedTeamingAttack"})
+
+ (failure,) = (await _restore(tmp_path)).failures
+
+ assert (failure.name, failure.reason) == ("types", RestoreFailureReason.INVALID_RECIPE)
+ assert failure.message.startswith("Not a valid create request: name:")
+ assert "types" not in AttackTechniqueRegistry.get_registry_singleton().instances.get_names()
diff --git a/tests/unit/backend/test_scenario_preset_routes.py b/tests/unit/backend/test_scenario_preset_routes.py
index 5a2a47c7b2..50a4ce0833 100644
--- a/tests/unit/backend/test_scenario_preset_routes.py
+++ b/tests/unit/backend/test_scenario_preset_routes.py
@@ -3,11 +3,11 @@
"""Tests for the scenario preset routes."""
+import logging
from collections.abc import Iterator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from azure.core.exceptions import AzureError
from fastapi.testclient import TestClient
from pyrit.backend.main import app
@@ -24,6 +24,7 @@
ScenarioPresetNameRegisteredError,
resolve_scenario_preset_run_request,
)
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
PRESET_NAME = "quick_scan"
SCENARIO_NAME = "foundry.red_team_agent"
@@ -146,13 +147,22 @@ def test_list_is_readable_without_admin(self, anonymous_client: TestClient, serv
assert anonymous_client.get("/api/scenario-presets").status_code == 200
- def test_storage_failure_is_reported_without_sdk_detail(self, client: TestClient, service: MagicMock) -> None:
- service.list_presets_async = AsyncMock(side_effect=AzureError("container 'x' key=secret"))
+ def test_storage_failure_names_the_source_only_in_the_log(
+ self, client: TestClient, service: MagicMock, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ service.list_presets_async = AsyncMock(
+ side_effect=DocumentStorageUnavailableError(
+ label="Scenario preset", source="/srv/presets", reason="Permission denied"
+ )
+ )
- response = client.get("/api/scenario-presets")
+ with caplog.at_level(logging.WARNING, logger="pyrit.backend.middleware.error_handlers"):
+ response = client.get("/api/scenario-presets")
assert response.status_code == 503
- assert "secret" not in response.text
+ assert response.json()["detail"] == "Scenario preset storage is unavailable"
+ assert "/srv/presets" not in response.text
+ assert "Scenario preset storage at /srv/presets is unavailable: Permission denied" in caplog.text
def test_each_preset_carries_its_own_run_size(self, client: TestClient, service: MagicMock) -> None:
estimate = _estimate(42)
@@ -416,7 +426,9 @@ def test_a_missing_preset_still_conflicts_instead_of_being_recreated(
def test_a_failed_author_read_is_reported_without_storage_details(
self, *, client: TestClient, service: MagicMock
) -> None:
- service.get_preset_async.side_effect = AzureError("key=secret")
+ service.get_preset_async.side_effect = DocumentStorageUnavailableError(
+ label="Scenario preset", source="/srv/presets", reason="Permission denied"
+ )
response = client.put(
f"/api/scenario-presets/{PRESET_NAME}",
@@ -424,7 +436,7 @@ def test_a_failed_author_read_is_reported_without_storage_details(
)
assert response.status_code == 503
- assert "secret" not in response.text
+ assert "/srv/presets" not in response.text
service.save_preset_async.assert_not_called()
def test_a_body_naming_a_different_preset_is_rejected(self, client: TestClient, service: MagicMock) -> None:
@@ -522,7 +534,14 @@ def test_delete_reports_a_missing_preset(self, client: TestClient, service: Magi
@pytest.mark.parametrize(
"error",
- [AzureError("key=secret"), TimeoutError("Timed out waiting to write 'secret.lock'")],
+ [
+ DocumentStorageUnavailableError(
+ label="Scenario preset", source="https://secret.test/c", reason="AzureError"
+ ),
+ DocumentStorageUnavailableError(
+ label="Scenario preset", source="/srv/secret", reason="Timed out waiting to write '.secret.lock'"
+ ),
+ ],
)
def test_a_delete_storage_failure_is_unavailable(
self, *, client: TestClient, service: MagicMock, error: Exception
diff --git a/tests/unit/backend/test_scenario_run_service.py b/tests/unit/backend/test_scenario_run_service.py
index bd95653556..f5113a5b70 100644
--- a/tests/unit/backend/test_scenario_run_service.py
+++ b/tests/unit/backend/test_scenario_run_service.py
@@ -9,6 +9,7 @@
import logging
import threading
import uuid
+from collections.abc import Iterator
from dataclasses import replace
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
@@ -22,9 +23,10 @@
import pyrit.backend.services.scenario_run_service as _svc_mod
from pyrit.backend.services import ScenarioRunService
from pyrit.backend.services.scenario_progress_read_model import ScenarioPlanLookup, ScenarioProgressReadModel
-from pyrit.backend.services.scenario_run_service import ScenarioRunConflictError
+from pyrit.backend.services.scenario_run_service import SavedEntryLaunchConflictError, ScenarioRunConflictError
from pyrit.common.utils import to_sha256
-from pyrit.converter import Converter
+from pyrit.converter import Base64Converter, Converter, ROT13Converter
+from pyrit.executor.attack import PromptSendingAttack
from pyrit.memory import (
AttackResultKeysetCursor,
AzureSQLMemory,
@@ -64,8 +66,9 @@
)
from pyrit.models.catalog import RunScenarioRequest
from pyrit.prompt_target import TargetCapabilities
-from pyrit.registry import ConverterRegistry, ScenarioMetadata, ScenarioRegistry
-from pyrit.scenario import IncompatibleTechniquePolicy, Scenario, ScenarioTechnique
+from pyrit.registry import AttackTechniqueRegistry, ConverterRegistry, ScenarioMetadata, ScenarioRegistry
+from pyrit.registry.instance_registry import SAVED_ENTRY_METADATA_KEY
+from pyrit.scenario import AttackTechniqueFactory, IncompatibleTechniquePolicy, Scenario, ScenarioTechnique
from pyrit.scenario.core import (
DatasetAttackConfiguration,
DatasetConfiguration,
@@ -73,6 +76,7 @@
)
from pyrit.scenario.core.dataset_overrides import apply_dataset_overrides
from pyrit.scenario.scenarios.airt import Jailbreak, Scam
+from pyrit.setup.initializers import ConverterInitializer, ToolsInitializer
from unit.async_utils import wait_for_completion_async
from unit.mocks import (
MockPromptTarget,
@@ -263,6 +267,126 @@ async def prepare_async() -> _svc_mod._PreparedRun:
await service.close_async()
+@pytest.fixture
+def converter_registry() -> Iterator[ConverterRegistry]:
+ ConverterRegistry.reset_registry_singleton()
+ yield ConverterRegistry.get_registry_singleton()
+ ConverterRegistry.reset_registry_singleton()
+
+
+class _LaunchScript:
+ """A launch script that registers one converter, optionally swallowing or wrapping errors."""
+
+ def __init__(self, *, name: str, swallow: bool = False, wrap: bool = False) -> None:
+ self._name = name
+ self._swallow = swallow
+ self._wrap = wrap
+
+ async def initialize_async(self) -> None:
+ registry = ConverterRegistry.get_registry_singleton()
+ try:
+ registry.instances.register(Base64Converter(), name=self._name, replace=True)
+ except Exception as error:
+ if self._wrap:
+ raise ValueError("The launch script failed.") from error
+ if not self._swallow:
+ raise
+
+
+async def _run_launch_scripts_async(scripts: dict[str, Any]) -> None:
+ initializers = MagicMock()
+ initializers.create_and_configure.side_effect = lambda name, initializer_params=None: scripts[name]
+ with patch(f"{_REGISTRY_PATCH_BASE}.InitializerRegistry.get_registry_singleton", return_value=initializers):
+ await ScenarioRunService()._run_initializers_async(request=_make_request(initializers=list(scripts)))
+
+
+@pytest.mark.usefixtures("patch_central_database")
+@pytest.mark.parametrize(
+ "script",
+ [
+ ConverterInitializer(),
+ _LaunchScript(name="rot13"),
+ _LaunchScript(name="rot13", swallow=True),
+ _LaunchScript(name="rot13", wrap=True),
+ ],
+)
+async def test_launch_scripts_cannot_replace_a_saved_entry_async(
+ converter_registry: ConverterRegistry, script: Any
+) -> None:
+ saved = ROT13Converter()
+ converter_registry.instances.register(saved, name="rot13", metadata={SAVED_ENTRY_METADATA_KEY: "converters"})
+
+ with pytest.raises(SavedEntryLaunchConflictError, match="'converters' would replace the saved converter 'rot13'"):
+ await _run_launch_scripts_async({"converters": script})
+
+ assert converter_registry.instances.get("rot13") is saved
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_tools_launch_script_cannot_use_a_saved_technique_in_its_place_async() -> None:
+ AttackTechniqueRegistry.reset_registry_singleton()
+ registry = AttackTechniqueRegistry.get_registry_singleton()
+ saved = AttackTechniqueFactory(name="role_play_tool_calls", attack_class=PromptSendingAttack)
+ registry.instances.register_runtime(saved, metadata={SAVED_ENTRY_METADATA_KEY: "techniques"})
+ try:
+ with pytest.raises(
+ SavedEntryLaunchConflictError, match="'tools' would replace the saved technique 'role_play_tool_calls'"
+ ):
+ await _run_launch_scripts_async({"tools": ToolsInitializer()})
+
+ assert registry.instances.get("role_play_tool_calls") is saved
+ finally:
+ AttackTechniqueRegistry.reset_registry_singleton()
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_tools_launch_script_cannot_replace_a_saved_converter_async(
+ converter_registry: ConverterRegistry,
+) -> None:
+ AttackTechniqueRegistry.reset_registry_singleton()
+ saved = ROT13Converter()
+ converter_registry.instances.register(
+ saved, name="role_play_tool_calls", metadata={SAVED_ENTRY_METADATA_KEY: "converters"}
+ )
+ try:
+ with pytest.raises(
+ SavedEntryLaunchConflictError, match="'tools' would replace the saved converter 'role_play_tool_calls'"
+ ):
+ await _run_launch_scripts_async({"tools": ToolsInitializer()})
+
+ assert converter_registry.instances.get("role_play_tool_calls") is saved
+ assert "role_play_tool_calls" not in AttackTechniqueRegistry.get_registry_singleton().instances
+ finally:
+ AttackTechniqueRegistry.reset_registry_singleton()
+
+
+class _FailingLaunchScript:
+ """A launch script that fails for a reason unrelated to saved entries."""
+
+ async def initialize_async(self) -> None:
+ raise ValueError("The launch script failed.")
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_launch_script_errors_without_a_saved_entry_clash_pass_through_async(
+ converter_registry: ConverterRegistry,
+) -> None:
+ with pytest.raises(ValueError, match="The launch script failed") as raised:
+ await _run_launch_scripts_async({"failing": _FailingLaunchScript()})
+
+ assert not isinstance(raised.value, SavedEntryLaunchConflictError)
+
+
+@pytest.mark.usefixtures("patch_central_database")
+async def test_launch_scripts_still_replace_startup_entries_async(converter_registry: ConverterRegistry) -> None:
+ startup = ROT13Converter()
+ converter_registry.instances.register(startup, name="rot13")
+
+ await _run_launch_scripts_async({"converters": _LaunchScript(name="rot13")})
+
+ assert isinstance(converter_registry.instances.get("rot13"), Base64Converter)
+
+
def _make_request(
*,
scenario_name: str = "foundry.red_team_agent",
diff --git a/tests/unit/backend/test_scenario_suite_routes.py b/tests/unit/backend/test_scenario_suite_routes.py
index 970dd2b24d..ce659e0ed0 100644
--- a/tests/unit/backend/test_scenario_suite_routes.py
+++ b/tests/unit/backend/test_scenario_suite_routes.py
@@ -3,6 +3,7 @@
"""Tests for scenario suite definition routes."""
+import logging
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
@@ -11,7 +12,6 @@
from uuid import UUID
import pytest
-from azure.core.exceptions import AzureError
from fastapi.testclient import TestClient
from pyrit.backend.main import app
@@ -22,14 +22,18 @@
ScenarioSuiteResponse,
)
from pyrit.backend.services import (
+ ScenarioRunService,
ScenarioSuiteExecutionService,
ScenarioSuiteService,
)
from pyrit.backend.services.scenario_suite_execution_service import (
ScenarioSuiteExecutionCapacityError,
)
+from pyrit.converter import Base64Converter
from pyrit.models import (
+ PresetEntry,
PresetIssue,
+ ScenarioPreset,
ScenarioSuite,
ScenarioSuiteEntry,
ScenarioSuiteExecutionEntrySummary,
@@ -39,6 +43,8 @@
)
from pyrit.models.catalog import ScenarioSuiteRegistryEntry
from pyrit.registry import (
+ ConverterRegistry,
+ InitializerRegistry,
ScenarioPresetRegistry,
ScenarioSuiteConflictError,
ScenarioSuiteExecutionNotFoundError,
@@ -48,6 +54,9 @@
ScenarioSuiteRegistry,
ScenarioSuiteStorage,
)
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
+from pyrit.registry.instance_registry import SAVED_ENTRY_METADATA_KEY
+from pyrit.setup.initializers import ConverterInitializer
SUITE_NAME = "weekly"
ENTRY_ID = UUID("11111111-1111-4111-8111-111111111111")
@@ -181,7 +190,9 @@ def unavailable_preset_backend(
) -> Iterator[tuple[ScenarioSuiteService, ScenarioSuiteStorage]]:
"""Run routes against real suite storage and an unavailable preset registry."""
registry = MagicMock(spec=ScenarioPresetRegistry)
- registry.get_preset.side_effect = AzureError("expired preset container signature")
+ registry.get_preset.side_effect = DocumentStorageUnavailableError(
+ label="Scenario preset", source="/srv/presets", reason="Permission denied"
+ )
storage = ScenarioSuiteStorage(source=str(tmp_path))
scenario_service = ScenarioSuiteService(registry=ScenarioSuiteRegistry(storage=storage))
with (
@@ -204,6 +215,14 @@ def signed_in_user() -> Iterator[None]:
app.dependency_overrides.pop(current_user_name, None)
+@pytest.fixture
+def converter_registry() -> Iterator[ConverterRegistry]:
+ """Give the test a fresh converter registry and drop what launch scripts registered."""
+ ConverterRegistry.reset_registry_singleton()
+ yield ConverterRegistry.get_registry_singleton()
+ ConverterRegistry.reset_registry_singleton()
+
+
def test_list_is_open_and_returns_entry_issues(anonymous_client: TestClient, service: MagicMock) -> None:
"""Test open reads with entry-scoped advisory issues."""
issue = PresetIssue(field="preset_name", message="Preset is missing.")
@@ -257,7 +276,10 @@ def test_launch_is_rejected_during_runtime_reinitialization(
(ScenarioSuiteLaunchConflictError("changed"), 409),
(ScenarioSuiteNotRunnableError("not runnable"), 400),
(ScenarioSuiteExecutionCapacityError("wait for an active suite"), 429),
- (AzureError("storage unavailable"), 503),
+ (
+ DocumentStorageUnavailableError(label="Scenario suite", source="/srv/suites", reason="Permission denied"),
+ 503,
+ ),
(RuntimeError("scheduling is stopping"), 503),
],
)
@@ -273,12 +295,55 @@ def test_launch_maps_preflight_failures(
response = anonymous_client.post(f"/api/scenario-suites/{SUITE_NAME}/launch", json=_launch_body())
assert response.status_code == expected_status
- if isinstance(error, AzureError):
- assert "storage unavailable" not in response.text
+ if isinstance(error, DocumentStorageUnavailableError):
+ assert response.json()["detail"] == "Scenario suite storage is unavailable"
if isinstance(error, ScenarioSuiteExecutionCapacityError):
assert response.json()["detail"] == "wait for an active suite"
+@pytest.mark.usefixtures("patch_central_database")
+def test_launch_refuses_a_launch_script_that_would_replace_a_saved_entry(
+ *, anonymous_client: TestClient, converter_registry: ConverterRegistry, tmp_path: Path
+) -> None:
+ """Test a launch script that would replace a saved entry is a conflict before any suite execution or scan."""
+ saved = Base64Converter()
+ converter_registry.instances.register(saved, name="rot13", metadata={SAVED_ENTRY_METADATA_KEY: "converters"})
+ suite_registry = ScenarioSuiteRegistry(storage=ScenarioSuiteStorage(source=str(tmp_path)))
+ stored = suite_registry.save_suite(suite=_suite(), expected_version=None)
+ preset_registry = MagicMock(spec=ScenarioPresetRegistry)
+ preset_registry.get_preset.return_value = PresetEntry(
+ preset=ScenarioPreset(name="nightly", scenario_name="scenario.nightly"), origin="stored", version="preset-v1"
+ )
+ preset_registry.check_preset.return_value = []
+ run_service = ScenarioRunService()
+ execution_service = ScenarioSuiteExecutionService(
+ suite_registry=suite_registry, preset_registry=preset_registry, run_service=run_service
+ )
+ initializers = MagicMock(spec=InitializerRegistry)
+ initializers.create_and_configure.return_value = ConverterInitializer()
+
+ with (
+ patch(
+ "pyrit.backend.routes.scenario_suites.get_scenario_suite_execution_service",
+ return_value=execution_service,
+ ),
+ patch.object(InitializerRegistry, "get_registry_singleton", return_value=initializers),
+ patch.object(run_service, "start_run_async", new_callable=AsyncMock) as start_run,
+ ):
+ response = anonymous_client.post(
+ f"/api/scenario-suites/{SUITE_NAME}/launch",
+ json={**_launch_body(), "expected_version": stored.version, "initializers": ["converters"]},
+ )
+
+ assert response.status_code == 409
+ assert response.json()["detail"] == (
+ "The launch script 'converters' would replace the saved converter 'rot13'. Leave that script out of the launch."
+ )
+ assert execution_service._executions == {}
+ start_run.assert_not_awaited()
+ assert converter_registry.instances.get("rot13") is saved
+
+
def test_launch_limits_the_expected_version(anonymous_client: TestClient, execution_service: MagicMock) -> None:
"""Test the reviewed suite version has the identifier limit."""
execution_service.launch_async = AsyncMock(return_value=_execution_summary())
@@ -369,14 +434,25 @@ def test_get_registered_suite_returns_origin_and_null_version(client: TestClient
assert response.json()["version"] is None
-def test_storage_failure_is_sanitized(client: TestClient, service: MagicMock) -> None:
- """Test that Azure SDK details do not reach API callers."""
- service.list_suites_async = AsyncMock(side_effect=AzureError("container key=secret"))
+def test_storage_failure_names_the_source_only_in_the_log(
+ client: TestClient, service: MagicMock, caplog: pytest.LogCaptureFixture
+) -> None:
+ """Test that the log names the storage location and the response doesn't."""
+ service.list_suites_async = AsyncMock(
+ side_effect=DocumentStorageUnavailableError(
+ label="Scenario suite",
+ source="https://account.blob.core.windows.net/suites",
+ reason="ClientAuthenticationError",
+ )
+ )
- response = client.get("/api/scenario-suites")
+ with caplog.at_level(logging.WARNING, logger="pyrit.backend.middleware.error_handlers"):
+ response = client.get("/api/scenario-suites")
assert response.status_code == 503
- assert "secret" not in response.text
+ assert response.json()["detail"] == "Scenario suite storage is unavailable"
+ assert "account.blob.core.windows.net" not in response.text
+ assert "https://account.blob.core.windows.net/suites is unavailable: ClientAuthenticationError" in caplog.text
def test_create_succeeds_when_preset_storage_is_unavailable(
@@ -674,7 +750,12 @@ def test_delete_of_a_changed_suite_is_a_conflict(client: TestClient, service: Ma
@pytest.mark.parametrize(
"error",
- [AzureError("key=secret"), TimeoutError("Timed out waiting to write 'secret.lock'")],
+ [
+ DocumentStorageUnavailableError(label="Scenario suite", source="https://secret.test/c", reason="AzureError"),
+ DocumentStorageUnavailableError(
+ label="Scenario suite", source="/srv/secret", reason="Timed out waiting to write '.secret.lock'"
+ ),
+ ],
)
def test_a_delete_storage_failure_is_unavailable(client: TestClient, service: MagicMock, error: Exception) -> None:
"""Test that blob failures and local lock timeouts during delete map to a sanitized 503."""
diff --git a/tests/unit/backend/test_scenario_suite_service.py b/tests/unit/backend/test_scenario_suite_service.py
index ef10621774..b41e077725 100644
--- a/tests/unit/backend/test_scenario_suite_service.py
+++ b/tests/unit/backend/test_scenario_suite_service.py
@@ -9,7 +9,7 @@
from uuid import UUID
import pytest
-from azure.core.exceptions import AzureError
+from azure.core.exceptions import ServiceRequestError
from pyrit.backend.services import ScenarioSuiteService, get_scenario_suite_service
from pyrit.models import PresetEntry, PresetIssue, ScenarioPreset, ScenarioSuite, ScenarioSuiteEntry
@@ -19,6 +19,7 @@
ScenarioSuiteRegistry,
ScenarioSuiteStorage,
)
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, DocumentUnreadableError
ENTRY_ONE = UUID("11111111-1111-4111-8111-111111111111")
ENTRY_TWO = UUID("22222222-2222-4222-8222-222222222222")
@@ -119,8 +120,17 @@ async def test_registered_suite_is_listed_with_nullable_version(
@pytest.mark.parametrize(
"error",
- [AzureError("expired preset credentials"), OSError("preset directory unavailable")],
- ids=["azure", "local"],
+ [
+ DocumentStorageUnavailableError(label="Scenario preset", source="/srv/presets", reason="Permission denied"),
+ DocumentUnreadableError(
+ label="Scenario preset",
+ source="/srv/presets",
+ name="nightly",
+ file_name="nightly.json",
+ reason="Not a regular file",
+ ),
+ ],
+ ids=["storage", "one_document"],
)
async def test_preset_storage_failure_is_advisory_after_committed_save(
service: ScenarioSuiteService,
@@ -142,6 +152,25 @@ async def test_preset_storage_failure_is_advisory_after_committed_save(
assert "preset storage is temporarily unavailable" in response.entry_statuses[0].issues[0].message
+async def test_preset_storage_failure_log_leaves_out_a_sas_url_from_the_error_behind_it(
+ service: ScenarioSuiteService,
+ preset_registry: MagicMock,
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ """Test that the warning names the storage without the credentials the error behind it carries."""
+ error = DocumentStorageUnavailableError(
+ label="Scenario preset", source="https://account.blob.core.windows.net/presets", reason="ServiceRequestError"
+ )
+ error.__cause__ = ServiceRequestError(message="https://account.blob.core.windows.net/presets?sp=r&sig=secret-sig")
+ preset_registry.get_preset.side_effect = error
+
+ with caplog.at_level("DEBUG", logger="pyrit.backend.services.scenario_suite_service"):
+ await service.save_suite_async(suite=_suite(preset_names=("nightly",)), expected_version=None)
+
+ assert f"Could not project scenario preset 'nightly': {error}" in caplog.messages
+ assert "secret-sig" not in caplog.text
+
+
async def test_current_preset_origin_version_and_issues_are_projected(
service: ScenarioSuiteService,
preset_registry: MagicMock,
diff --git a/tests/unit/common/test_azure_storage.py b/tests/unit/common/test_azure_storage.py
index 300ca706dc..05da411884 100644
--- a/tests/unit/common/test_azure_storage.py
+++ b/tests/unit/common/test_azure_storage.py
@@ -5,7 +5,7 @@
import pytest
-from pyrit.common.azure_storage import has_sas_signature, is_azure_blob_uri, redact_url_credentials
+from pyrit.common.azure_storage import has_sas_signature, is_azure_blob_uri, redact_url_credentials, redact_urls
@pytest.mark.parametrize(
@@ -56,3 +56,27 @@ def test_redact_url_credentials_removes_query_and_fragment() -> None:
uri = "https://account.blob.core.windows.net/container/blob.yaml?sp=rw&sig=secret#fragment"
assert redact_url_credentials(uri) == "https://account.blob.core.windows.net/container/blob.yaml"
+
+
+def test_redact_url_credentials_removes_user_name_and_password() -> None:
+ assert redact_url_credentials("https://user:SECRET@host:8443/path?q=1") == "https://host:8443/path"
+
+
+@pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("got https://acct.blob.core.windows.net/c/b?sv=1&sig=SECRET.", "got https://acct.blob.core.windows.net/c/b"),
+ ("to wss://user:SECRET@host/p#part failed", "to wss://host/p failed"),
+ ("first http://a/b?x=1 then https://c/d", "first http://a/b then https://c/d"),
+ ("bad https://[x?sig=SECRET", "bad "),
+ ("endpoint_https://user:SECRET@host/v1", "endpoint_https://host/v1"),
+ (
+ "404 for https://graph.example/users/alex@contoso.com/mail?x=1",
+ "404 for https://graph.example/users/alex@contoso.com/mail",
+ ),
+ ("host/path?api-version=1&sig=SECRET&se=2030", "host/path?api-version=1&sig=&se=2030"),
+ ("could not be coerced to float: 'hot'", "could not be coerced to float: 'hot'"),
+ ],
+)
+def test_redact_urls_removes_credentials_from_each_url_in_text(text: str, expected: str) -> None:
+ assert redact_urls(text) == expected
diff --git a/tests/unit/registry/test_attack_technique_registry.py b/tests/unit/registry/test_attack_technique_registry.py
index 14d8915a90..6f4809390c 100644
--- a/tests/unit/registry/test_attack_technique_registry.py
+++ b/tests/unit/registry/test_attack_technique_registry.py
@@ -14,6 +14,11 @@
from pyrit.models import ComponentIdentifier
from pyrit.prompt_target import PromptTarget, TargetRequirements
from pyrit.registry import AttackTechniqueRegistry, Registry, TargetRegistry
+from pyrit.registry.instance_registry import (
+ SAVED_ENTRY_METADATA_KEY,
+ SavedEntryReplacementError,
+ record_saved_entry_refusals,
+)
from pyrit.scenario import AttackTechniqueFactory, ScenarioTechnique
from pyrit.scenario.core import ScorerOverridePolicy
from pyrit.setup.initializers.techniques import build_technique_factories
@@ -362,6 +367,35 @@ def test_policy_passed_to_factories_via_register_from_factories(self):
stored = self.registry.instances.get_entry("stub_policy").instance
assert stored._scorer_override_policy == ScorerOverridePolicy.WARN
+ def test_register_from_factories_refuses_to_skip_a_saved_technique(self):
+ saved = AttackTechniqueFactory(name="formal", attack_class=_StubAttack, technique_tags=["custom"])
+ self.registry.instances.register_runtime(saved, metadata={SAVED_ENTRY_METADATA_KEY: "techniques"})
+
+ with pytest.raises(SavedEntryReplacementError, match="Saved technique 'formal'"):
+ self.registry.register_from_factories([AttackTechniqueFactory(name="formal", attack_class=_StubAttack)])
+
+ assert self.registry.instances.get("formal") is saved
+
+ def test_register_runtime_records_a_caught_clash_with_a_saved_technique(self):
+ saved = AttackTechniqueFactory(name="formal", attack_class=_StubAttack, technique_tags=["custom"])
+ self.registry.instances.register_runtime(saved, metadata={SAVED_ENTRY_METADATA_KEY: "techniques"})
+
+ with record_saved_entry_refusals() as refusals:
+ with pytest.raises(ValueError, match="names are case-insensitive"):
+ clash = AttackTechniqueFactory(name="FORMAL", attack_class=_StubAttack)
+ self.registry.instances.register_runtime(clash)
+
+ assert refusals == [("techniques", "formal")]
+ assert self.registry.instances.get("formal") is saved
+
+ def test_register_from_factories_still_skips_an_existing_startup_technique(self):
+ startup = AttackTechniqueFactory(name="startup", attack_class=_StubAttack)
+ self.registry.register_from_factories([startup])
+
+ self.registry.register_from_factories([AttackTechniqueFactory(name="startup", attack_class=_StubAttack)])
+
+ assert self.registry.instances.get("startup") is startup
+
SCENARIO_FACTORIES_FIXTURE: list[AttackTechniqueFactory] = []
diff --git a/tests/unit/registry/test_converter_registry.py b/tests/unit/registry/test_converter_registry.py
index db7a5cccf9..0df5345925 100644
--- a/tests/unit/registry/test_converter_registry.py
+++ b/tests/unit/registry/test_converter_registry.py
@@ -36,6 +36,7 @@
ConverterRegistry,
TargetRegistry,
)
+from pyrit.registry.instance_registry import SAVED_ENTRY_METADATA_KEY, record_saved_entry_refusals
from pyrit.registry.resolution import derive_parameters
@@ -197,6 +198,17 @@ def test_create_named_instance_builds_and_stores_converter(self, registry: Conve
assert isinstance(converter, Base64Converter)
assert registry.instances.get("base64") is converter
+ def test_create_named_instance_records_a_caught_clash_with_a_saved_entry(self, registry: ConverterRegistry):
+ saved = Base64Converter()
+ registry.instances.register(saved, name="base64", metadata={SAVED_ENTRY_METADATA_KEY: "converters"})
+
+ with record_saved_entry_refusals() as refusals:
+ with pytest.raises(ValueError, match="already exists"):
+ registry.create_named_instance(name="base64", type_name="Base64Converter")
+
+ assert refusals == [("converters", "base64")]
+ assert registry.instances.get("base64") is saved
+
def test_create_named_instance_stores_registry_metadata(self, registry: ConverterRegistry):
converter = registry.create_named_instance(
name="base64",
diff --git a/tests/unit/registry/test_file_document_storage.py b/tests/unit/registry/test_file_document_storage.py
new file mode 100644
index 0000000000..7b991e442c
--- /dev/null
+++ b/tests/unit/registry/test_file_document_storage.py
@@ -0,0 +1,636 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import os
+import sys
+from collections.abc import Callable, Generator, Iterator
+from contextlib import contextmanager
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+from azure.core.exceptions import (
+ ClientAuthenticationError,
+ HttpResponseError,
+ ResourceNotFoundError,
+ ServiceRequestError,
+ ServiceResponseError,
+)
+from azure.storage.blob import ContainerClient, StorageErrorCode
+
+import pyrit.registry.file_document_storage as storage_module
+from pyrit.registry.file_document_storage import (
+ DocumentConflictError,
+ DocumentListing,
+ DocumentStorageUnavailableError,
+ DocumentUnreadableError,
+ FileDocumentStorage,
+ UnreadableDocument,
+ _read_regular_file,
+ describe_storage_error,
+)
+
+_SAS_SOURCE = "https://account.blob.core.windows.net/documents/saved?sp=rl&sig=secret-signature"
+
+
+def _local_storage(directory: Path) -> FileDocumentStorage:
+ return FileDocumentStorage(source=str(directory), extension=".json", source_label="Test documents")
+
+
+def _blob_storage() -> FileDocumentStorage:
+ return FileDocumentStorage(source=_SAS_SOURCE, extension=".json", source_label="Test documents")
+
+
+def _blob_client(*, names: list[str]) -> MagicMock:
+ client = MagicMock(spec=ContainerClient)
+ client.__enter__.return_value = client
+ client.list_blobs.return_value = [SimpleNamespace(name=name) for name in names]
+ return client
+
+
+def _blob_error(
+ *, status_code: int, error_code: str, message: str = "Storage error.", missing: bool = False
+) -> HttpResponseError:
+ error_type = ResourceNotFoundError if missing else HttpResponseError
+ error = error_type(message=message)
+ error.status_code = status_code
+ error.error_code = error_code
+ return error
+
+
+def _count_open_files() -> int | None:
+ # Linux lists this process's open files here; elsewhere the count is skipped.
+ descriptors = Path("/proc/self/fd")
+ return len(list(descriptors.iterdir())) if descriptors.is_dir() else None
+
+
+@contextmanager
+def _change_after_listing(*, change: Callable[[], None], cache_types: bool = False) -> Generator[None, None, None]:
+ # Runs `change` after the folder is listed and before its entries are read. With `cache_types`,
+ # each entry's type is looked up first, so it is out of date once `change` runs.
+ real_scandir = os.scandir
+
+ @contextmanager
+ def scandir(path: Path) -> Generator[Iterator[os.DirEntry[str]], None, None]:
+ with real_scandir(path) as entries:
+ listed = list(entries)
+ if cache_types:
+ for entry in listed:
+ entry.is_file()
+ yield iter(listed)
+ change()
+
+ with patch.object(storage_module, "_scandir", side_effect=scandir):
+ yield
+
+
+class TestLocalListingWithIssues:
+ def test_reports_unreadable_document_and_keeps_the_others(self, tmp_path: Path) -> None:
+ (tmp_path / "first.json").write_bytes(b"one")
+ (tmp_path / "second.json").write_bytes(b"two")
+
+ def read(path: str) -> bytes | None:
+ if Path(path).name == "second.json":
+ raise PermissionError(13, "Permission denied", path)
+ return _read_regular_file(path)
+
+ with patch("pyrit.registry.file_document_storage._read_regular_file", side_effect=read):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+ documents = _local_storage(tmp_path)._list_documents()
+
+ assert listing == DocumentListing(
+ documents={"first": b"one"},
+ unreadable=(UnreadableDocument(name="second", file_name="second.json", message="Permission denied"),),
+ )
+ assert isinstance(listing.unreadable[0].error, PermissionError)
+ assert documents == {"first": b"one"}
+
+ def test_reports_badly_named_files_as_ignored_without_reading_them(self, tmp_path: Path) -> None:
+ (tmp_path / "valid_name.json").write_bytes(b"ok")
+ (tmp_path / "Bad-Name.json").write_bytes(b"never read")
+ (tmp_path / "notes.txt").write_bytes(b"other extension")
+ (tmp_path / "nested").mkdir()
+ (tmp_path / "nested" / "inner.json").write_bytes(b"not a direct child")
+ read_names: list[str] = []
+
+ def read(path: str) -> bytes | None:
+ read_names.append(Path(path).name)
+ return _read_regular_file(path)
+
+ with patch("pyrit.registry.file_document_storage._read_regular_file", side_effect=read):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing.documents == {"valid_name": b"ok"}
+ assert listing.unreadable == ()
+ assert [issue.file_name for issue in listing.ignored] == ["Bad-Name.json"]
+ assert "Bad-Name" in listing.ignored[0].message
+ assert read_names == ["valid_name.json"]
+ assert _local_storage(tmp_path)._list_documents() == listing.documents
+
+ def test_skips_a_document_deleted_after_listing(self, tmp_path: Path) -> None:
+ (tmp_path / "kept.json").write_bytes(b"{}\r\n")
+ (tmp_path / "empty.json").write_bytes(b"")
+ deleted = tmp_path / "deleted.json"
+ deleted.write_bytes(b"gone")
+
+ with _change_after_listing(change=deleted.unlink, cache_types=True):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(documents={"empty": b"", "kept": b"{}\r\n"})
+
+ def test_skips_a_link_deleted_after_listing(self, tmp_path: Path) -> None:
+ (tmp_path / "kept.json").write_bytes(b"kept")
+ (tmp_path / "target.txt").write_bytes(b"linked")
+ link = tmp_path / "linked.json"
+ try:
+ link.symlink_to(tmp_path / "target.txt")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+
+ with _change_after_listing(change=link.unlink):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(documents={"kept": b"kept"})
+
+ @pytest.mark.parametrize("replacement", ["pipe", "folder", "dangling link"])
+ @pytest.mark.timeout(60, method="thread")
+ def test_reports_a_non_file_put_in_place_of_a_listed_document_without_reading_it(
+ self, tmp_path: Path, replacement: str
+ ) -> None:
+ if replacement == "pipe" and not hasattr(os, "mkfifo"):
+ pytest.skip("Named pipes need os.mkfifo")
+ if replacement == "folder" and sys.platform == "win32":
+ pytest.skip("Windows can't open a folder as a file")
+ if replacement == "dangling link" and sys.platform == "win32":
+ pytest.skip("Creating symbolic links may not be permitted on Windows")
+ (tmp_path / "kept.json").write_bytes(b"kept")
+ swapped = tmp_path / "swapped.json"
+ swapped.write_bytes(b"replaced")
+
+ def replace() -> None:
+ swapped.unlink()
+ if replacement == "pipe":
+ os.mkfifo(swapped)
+ elif replacement == "folder":
+ swapped.mkdir()
+ else:
+ swapped.symlink_to(tmp_path / "missing_target.json")
+
+ open_files = _count_open_files()
+ with _change_after_listing(change=replace, cache_types=True):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(
+ documents={"kept": b"kept"},
+ unreadable=(UnreadableDocument(name="swapped", file_name="swapped.json", message="Not a regular file"),),
+ )
+ assert listing.unreadable[0].error is None
+ assert _count_open_files() == open_files
+
+ @pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="Named pipes need os.mkfifo")
+ @pytest.mark.timeout(60, method="thread")
+ def test_checks_the_opened_file_rather_than_the_path(self, tmp_path: Path) -> None:
+ document = tmp_path / "document.json"
+ document.write_bytes(b"original")
+ real_open = os.open
+
+ def open_then_replace(path: str, flags: int, *args: int) -> int:
+ descriptor = real_open(path, flags, *args)
+ os.unlink(path)
+ os.mkfifo(path)
+ return descriptor
+
+ with patch.object(storage_module, "_open", side_effect=open_then_replace):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(documents={"document": b"original"})
+ assert document.is_fifo()
+
+ @pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="Named pipes need os.mkfifo")
+ @pytest.mark.timeout(60, method="thread")
+ def test_checks_a_file_replaced_just_before_it_is_opened(self, tmp_path: Path) -> None:
+ (tmp_path / "document.json").write_bytes(b"original")
+ real_open = os.open
+
+ def replace_then_open(path: str, flags: int, *args: int) -> int:
+ os.unlink(path)
+ os.mkfifo(path)
+ return real_open(path, flags, *args)
+
+ with patch.object(storage_module, "_open", side_effect=replace_then_open):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(
+ unreadable=(UnreadableDocument(name="document", file_name="document.json", message="Not a regular file"),)
+ )
+
+ def test_reports_entries_that_are_not_regular_files_without_reading_them(self, tmp_path: Path) -> None:
+ (tmp_path / "folder.json").mkdir()
+ try:
+ (tmp_path / "broken_link.json").symlink_to(tmp_path / "missing_target.json")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+
+ with patch("pyrit.registry.file_document_storage._read_regular_file") as read:
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing.unreadable == (
+ UnreadableDocument(name="broken_link", file_name="broken_link.json", message="Not a regular file"),
+ UnreadableDocument(name="folder", file_name="folder.json", message="Not a regular file"),
+ )
+ read.assert_not_called()
+
+ @pytest.mark.skipif(sys.platform == "win32", reason="The null device is a POSIX character device here")
+ def test_reads_only_regular_files(self) -> None:
+ assert _read_regular_file(os.devnull) is None
+
+ def test_reports_an_entry_that_cannot_be_checked_after_listing(self, tmp_path: Path) -> None:
+ deleted = tmp_path / "deleted.json"
+ deleted.write_bytes(b"gone")
+
+ with (
+ _change_after_listing(change=deleted.unlink, cache_types=True),
+ patch.object(storage_module, "_lstat", side_effect=PermissionError(13, "Permission denied")),
+ ):
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing == DocumentListing(
+ unreadable=(UnreadableDocument(name="deleted", file_name="deleted.json", message="Permission denied"),)
+ )
+
+ def test_reports_a_link_loop_and_keeps_the_others(self, tmp_path: Path) -> None:
+ (tmp_path / "good.json").write_bytes(b"ok")
+ try:
+ (tmp_path / "loop.json").symlink_to(tmp_path / "loop.json")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert listing.documents == {"good": b"ok"}
+ assert [issue.name for issue in listing.unreadable] == ["loop"]
+
+ def test_matches_extensions_with_the_platform_case_rule(self, tmp_path: Path) -> None:
+ (tmp_path / "lower.json").write_bytes(b"one")
+ (tmp_path / "upper.JSON").write_bytes(b"two")
+
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert sorted(listing.documents) == sorted(path.stem for path in tmp_path.glob("*.json"))
+
+ def test_creates_missing_directory_and_lists_nothing(self, tmp_path: Path) -> None:
+ listing = _local_storage(tmp_path / "missing")._list_documents_with_issues()
+
+ assert listing == DocumentListing()
+ assert (tmp_path / "missing").is_dir()
+
+ def test_raises_when_the_directory_cannot_be_listed(self, tmp_path: Path) -> None:
+ (tmp_path / "first.json").write_bytes(b"one")
+
+ with patch.object(storage_module, "_scandir", side_effect=PermissionError(13, "denied")):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _local_storage(tmp_path)._list_documents_with_issues()
+
+ assert isinstance(raised.value.__cause__, PermissionError)
+ assert (raised.value.label, raised.value.source, raised.value.reason) == (
+ "Test documents",
+ str(tmp_path),
+ "denied",
+ )
+ assert str(raised.value) == f"Test documents storage at {tmp_path} is unavailable: denied"
+
+
+class TestBlobListingWithIssues:
+ def test_reports_unreadable_and_ignored_blobs_without_exposing_the_url(self) -> None:
+ client = _blob_client(
+ names=[
+ "saved/good.json",
+ "saved/broken.json",
+ "saved/Bad-Name.json",
+ "saved/notes.txt",
+ "saved/deeper/inner.json",
+ ]
+ )
+
+ def download_blob(blob_name: str) -> SimpleNamespace:
+ if blob_name == "saved/broken.json":
+ raise _blob_error(status_code=409, error_code="BlobArchived", message=f"Archived: {_SAS_SOURCE}")
+ return SimpleNamespace(readall=lambda: b"content")
+
+ client.download_blob.side_effect = download_blob
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ listing = _blob_storage()._list_documents_with_issues()
+
+ assert listing.documents == {"good": b"content"}
+ assert listing.unreadable == (
+ UnreadableDocument(name="broken", file_name="broken.json", message="HttpResponseError (409, BlobArchived)"),
+ )
+ assert isinstance(listing.unreadable[0].error, HttpResponseError)
+ assert [issue.file_name for issue in listing.ignored] == ["Bad-Name.json"]
+ assert all("secret-signature" not in issue.message for issue in (*listing.unreadable, *listing.ignored))
+ client.list_blobs.assert_called_once_with(name_starts_with="saved/")
+ downloaded = [call.args[0] for call in client.download_blob.call_args_list]
+ assert downloaded == ["saved/broken.json", "saved/good.json"]
+
+ def test_skips_only_a_blob_deleted_after_listing(self) -> None:
+ client = _blob_client(names=["saved/kept.json", "saved/deleted.json"])
+
+ def download_blob(blob_name: str) -> SimpleNamespace:
+ if blob_name == "saved/deleted.json":
+ raise _blob_error(status_code=404, error_code=StorageErrorCode.BLOB_NOT_FOUND, missing=True)
+ return SimpleNamespace(readall=lambda: b"kept")
+
+ client.download_blob.side_effect = download_blob
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ listing = _blob_storage()._list_documents_with_issues()
+
+ assert listing == DocumentListing(documents={"kept": b"kept"})
+
+ @pytest.mark.parametrize(
+ "error",
+ [
+ _blob_error(status_code=404, error_code=StorageErrorCode.CONTAINER_NOT_FOUND, missing=True),
+ _blob_error(status_code=403, error_code=StorageErrorCode.AUTHORIZATION_FAILURE),
+ _blob_error(status_code=401, error_code="InvalidAuthenticationInfo"),
+ ClientAuthenticationError(message="no token"),
+ ServiceRequestError(message="connection refused"),
+ ServiceResponseError(message="connection reset"),
+ ],
+ )
+ def test_raises_when_an_error_affects_the_whole_container(self, error: Exception) -> None:
+ client = _blob_client(names=["saved/first.json", "saved/second.json"])
+ client.download_blob.side_effect = error
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _blob_storage()._list_documents_with_issues()
+
+ assert raised.value.__cause__ is error
+ assert "secret-signature" not in str(raised.value)
+
+ def test_raises_when_the_container_cannot_be_listed(self) -> None:
+ def failing_pages() -> Iterator[SimpleNamespace]:
+ raise HttpResponseError(message="listing failed")
+ yield SimpleNamespace(name="saved/never.json")
+
+ client = _blob_client(names=[])
+ client.list_blobs.return_value = failing_pages()
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _blob_storage()._list_documents_with_issues()
+
+ assert isinstance(raised.value.__cause__, HttpResponseError)
+
+
+class TestReadDocument:
+ def test_reads_a_document_and_treats_a_missing_one_as_missing(self, tmp_path: Path) -> None:
+ (tmp_path / "present.json").write_bytes(b"content")
+ storage = _local_storage(tmp_path)
+
+ assert storage._read_document_bytes("present") == b"content"
+ assert storage._read_document_bytes("absent") is None
+ assert _local_storage(tmp_path / "missing_folder")._read_document_bytes("absent") is None
+
+ @pytest.mark.parametrize("entry", ["folder", "broken link", "pipe"])
+ @pytest.mark.timeout(60, method="thread")
+ def test_reports_what_the_listing_reports_as_unreadable(self, tmp_path: Path, entry: str) -> None:
+ path = tmp_path / "odd.json"
+ if entry == "folder":
+ path.mkdir()
+ elif entry == "broken link":
+ try:
+ path.symlink_to(tmp_path / "missing_target.json")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+ elif not hasattr(os, "mkfifo"):
+ pytest.skip("Named pipes need os.mkfifo")
+ else:
+ os.mkfifo(path)
+ storage = _local_storage(tmp_path)
+
+ with pytest.raises(DocumentUnreadableError) as raised:
+ storage._read_document_bytes("odd")
+
+ assert (raised.value.name, raised.value.file_name, raised.value.reason) == (
+ "odd",
+ "odd.json",
+ "Not a regular file",
+ )
+ assert [issue.name for issue in storage._list_documents_with_issues().unreadable] == ["odd"]
+
+ def test_reports_a_file_it_cannot_open(self, tmp_path: Path) -> None:
+ (tmp_path / "locked.json").write_bytes(b"secret")
+
+ with patch.object(storage_module, "_open", side_effect=PermissionError(13, "Permission denied")):
+ with pytest.raises(DocumentUnreadableError) as raised:
+ _local_storage(tmp_path)._read_document_bytes("locked")
+
+ assert raised.value.reason == "Permission denied"
+ assert isinstance(raised.value.__cause__, PermissionError)
+ assert str(raised.value) == f"Test documents 'locked.json' in {tmp_path} can't be read: Permission denied"
+
+ def test_blob_treats_only_a_missing_blob_as_missing(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.BLOB_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ assert _blob_storage()._read_document_bytes("absent") is None
+
+ def test_blob_raises_when_the_container_is_gone(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.CONTAINER_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _blob_storage()._read_document_bytes("present")
+
+ assert not isinstance(raised.value, DocumentUnreadableError)
+
+ def test_blob_reports_a_blob_it_cannot_read(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(status_code=409, error_code="BlobArchived")
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentUnreadableError) as raised:
+ _blob_storage()._read_document_bytes("archived")
+
+ assert raised.value.reason == "HttpResponseError (409, BlobArchived)"
+
+
+class TestDeleteDocument:
+ def test_blob_reports_a_missing_blob_as_already_gone(self) -> None:
+ client = _blob_client(names=[])
+ client.delete_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.BLOB_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ assert _blob_storage()._delete_document("absent") is False
+
+ def test_blob_raises_when_the_container_is_gone(self) -> None:
+ client = _blob_client(names=[])
+ client.delete_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.CONTAINER_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError):
+ _blob_storage()._delete_document("present")
+
+
+class TestConditionalSave:
+ def test_local_refuses_to_replace_something_it_cannot_read(self, tmp_path: Path) -> None:
+ (tmp_path / "odd.json").mkdir()
+
+ with pytest.raises(DocumentUnreadableError):
+ _local_storage(tmp_path)._save_document_conditional(name="odd", content=b"new", expected_version=None)
+
+ assert (tmp_path / "odd.json").is_dir()
+
+ def test_blob_update_of_a_deleted_blob_is_a_conflict(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.BLOB_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentConflictError) as raised:
+ _blob_storage()._save_document_conditional(name="gone", content=b"new", expected_version="v1")
+
+ assert raised.value.actual_version is None
+
+ def test_blob_update_raises_when_the_container_is_gone(self) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.side_effect = _blob_error(
+ status_code=404, error_code=StorageErrorCode.CONTAINER_NOT_FOUND, missing=True
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _blob_storage()._save_document_conditional(name="present", content=b"new", expected_version="v1")
+
+ assert not isinstance(raised.value, DocumentUnreadableError)
+ client.upload_blob.assert_not_called()
+
+ @pytest.mark.parametrize("error_type", [ServiceRequestError, ClientAuthenticationError])
+ @pytest.mark.parametrize("updating", [False, True])
+ def test_blob_upload_failure_is_storage_unavailable(
+ self, error_type: type[ServiceRequestError | ClientAuthenticationError], updating: bool
+ ) -> None:
+ client = _blob_client(names=[])
+ client.download_blob.return_value.readall.return_value = b"old"
+ client.download_blob.return_value.properties.etag = "etag-1"
+ error = error_type(message=f"Upload failed: {_SAS_SOURCE}")
+ client.upload_blob.side_effect = error
+ expected_version = FileDocumentStorage._compute_version(b"old") if updating else None
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ with pytest.raises(DocumentStorageUnavailableError) as raised:
+ _blob_storage()._save_document_conditional(
+ name="present", content=b"new", expected_version=expected_version
+ )
+
+ client.upload_blob.assert_called_once()
+ assert type(raised.value) is DocumentStorageUnavailableError
+ assert raised.value.__cause__ is error
+ assert str(raised.value) == (
+ "Test documents storage at https://account.blob.core.windows.net/documents/saved "
+ f"is unavailable: {error_type.__name__}"
+ )
+
+
+def test_a_listing_cannot_be_changed_by_its_reader(tmp_path: Path) -> None:
+ (tmp_path / "first.json").write_bytes(b"one")
+
+ listing = _local_storage(tmp_path)._list_documents_with_issues()
+
+ with pytest.raises(TypeError):
+ listing.documents["second"] = b"two" # type: ignore[index]
+
+
+class TestListDocuments:
+ def test_logs_what_it_leaves_out_and_returns_the_rest(self, tmp_path: Path) -> None:
+ (tmp_path / "good.json").write_bytes(b"ok")
+ (tmp_path / "Bad-Name.json").write_bytes(b"never read")
+ (tmp_path / "folder.json").mkdir()
+
+ with patch("pyrit.registry.file_document_storage.logger") as log:
+ documents = _local_storage(tmp_path)._list_documents()
+
+ assert documents == {"good": b"ok"}
+ messages = [call.args[0] for call in log.warning.call_args_list]
+ assert len(messages) == 2
+ assert messages[0].startswith(f"Ignoring stored document 'Bad-Name.json' in {tmp_path}: ")
+ assert messages[1] == f"Skipping unreadable document 'folder.json' in {tmp_path}: Not a regular file"
+
+ @pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="Named pipes need os.mkfifo")
+ @pytest.mark.timeout(60, method="thread")
+ def test_leaves_out_a_pipe_named_like_a_document_without_blocking(self, tmp_path: Path) -> None:
+ (tmp_path / "good.json").write_bytes(b"ok")
+ os.mkfifo(tmp_path / "pipe.json")
+
+ assert _local_storage(tmp_path)._list_documents() == {"good": b"ok"}
+
+ def test_raises_when_the_directory_cannot_be_listed(self, tmp_path: Path) -> None:
+ with patch.object(storage_module, "_scandir", side_effect=PermissionError(13, "denied")):
+ with pytest.raises(DocumentStorageUnavailableError):
+ _local_storage(tmp_path)._list_documents()
+
+ def test_logs_an_unreadable_blob_without_exposing_the_url(self) -> None:
+ client = _blob_client(names=["saved/good.json", "saved/broken.json"])
+
+ def download_blob(blob_name: str) -> SimpleNamespace:
+ if blob_name == "saved/broken.json":
+ raise HttpResponseError(message=f"Forbidden: {_SAS_SOURCE}")
+ return SimpleNamespace(readall=lambda: b"content")
+
+ client.download_blob.side_effect = download_blob
+
+ with (
+ patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client),
+ patch("pyrit.registry.file_document_storage.logger") as log,
+ ):
+ documents = _blob_storage()._list_documents()
+
+ assert documents == {"good": b"content"}
+ message = log.warning.call_args.args[0]
+ assert message.startswith(
+ "Skipping unreadable document 'broken.json' in https://account.blob.core.windows.net/"
+ )
+ assert message.endswith(": HttpResponseError")
+ assert "secret-signature" not in message
+
+
+class TestDescribeStorageError:
+ @pytest.mark.parametrize(
+ ("error", "expected"),
+ [
+ (PermissionError(13, "Permission denied", "/server/path/secret.json"), "Permission denied"),
+ (OSError(), "OSError"),
+ (ServiceRequestError(message="https://account.blob.core.windows.net/x?sig=secret"), "ServiceRequestError"),
+ ],
+ )
+ def test_describes_errors_without_their_details(self, error: Exception, expected: str) -> None:
+ assert describe_storage_error(error) == expected
+
+ @pytest.mark.parametrize(
+ ("error_code", "expected"),
+ [
+ (StorageErrorCode.AUTHORIZATION_FAILURE, "HttpResponseError (403, AuthorizationFailure)"),
+ ("AuthorizationPermissionMismatch", "HttpResponseError (403, AuthorizationPermissionMismatch)"),
+ ],
+ )
+ def test_includes_the_azure_status_and_error_code(self, error_code: str, expected: str) -> None:
+ error = HttpResponseError(message="https://account.blob.core.windows.net/x?sig=secret")
+ error.status_code = 403
+ error.error_code = error_code
+
+ assert describe_storage_error(error) == expected
diff --git a/tests/unit/registry/test_initializer_registry.py b/tests/unit/registry/test_initializer_registry.py
index e2762ce122..a57321845e 100644
--- a/tests/unit/registry/test_initializer_registry.py
+++ b/tests/unit/registry/test_initializer_registry.py
@@ -180,7 +180,7 @@ def test_unregister_and_cleanup_removes_entry_and_file(lazy_registry):
def test_unregister_and_cleanup_keeps_entry_when_storage_delete_fails(lazy_registry: InitializerRegistry) -> None:
"""Test that failed storage cleanup leaves the runtime registration retryable."""
storage = MagicMock(spec=CustomInitializerStorage)
- storage.list_scripts.return_value = {"custom": _VALID_SCRIPT}
+ storage.list_script_names.return_value = {"custom"}
storage.delete_script.side_effect = OSError("storage unavailable")
lazy_registry._custom_storage = storage
lazy_registry._classes["custom"] = _ParamInitializer
@@ -194,7 +194,7 @@ def test_unregister_and_cleanup_keeps_entry_when_storage_delete_fails(lazy_regis
def test_unregister_and_cleanup_removes_invalid_stored_source(lazy_registry: InitializerRegistry) -> None:
"""Test invalid stored scripts can be removed without a runtime registration."""
storage = MagicMock(spec=CustomInitializerStorage)
- storage.list_scripts.return_value = {"invalid": "not Python"}
+ storage.list_script_names.return_value = {"invalid"}
lazy_registry._custom_storage = storage
lazy_registry.unregister_and_cleanup("invalid")
@@ -205,7 +205,7 @@ def test_unregister_and_cleanup_removes_invalid_stored_source(lazy_registry: Ini
def test_unregister_and_cleanup_removes_source_shadowed_by_builtin(lazy_registry: InitializerRegistry) -> None:
"""Test deleting stored source preserves a built-in registration with the same name."""
storage = MagicMock(spec=CustomInitializerStorage)
- storage.list_scripts.return_value = {"builtin": _VALID_SCRIPT}
+ storage.list_script_names.return_value = {"builtin"}
lazy_registry._custom_storage = storage
lazy_registry._classes["builtin"] = _ParamInitializer
lazy_registry._builtin_names.add("builtin")
@@ -216,6 +216,24 @@ def test_unregister_and_cleanup_removes_source_shadowed_by_builtin(lazy_registry
assert lazy_registry.get_class("builtin") is _ParamInitializer
+def test_unregister_and_cleanup_removes_a_script_that_cannot_be_read(
+ lazy_registry: InitializerRegistry, tmp_path: Path
+) -> None:
+ """Test that a stored script the listing reports as unreadable, or can't decode, can still be deleted."""
+ lazy_registry.configure_custom_scripts_source(str(tmp_path))
+ (tmp_path / "not_utf8.py").write_bytes(b"\xff\xfe")
+ try:
+ (tmp_path / "broken_link.py").symlink_to(tmp_path / "missing_target.py")
+ except OSError:
+ pytest.skip("Creating symbolic links isn't permitted here")
+
+ lazy_registry.unregister_and_cleanup("not_utf8")
+ lazy_registry.unregister_and_cleanup("broken_link")
+
+ assert not (tmp_path / "not_utf8.py").exists()
+ assert not (tmp_path / "broken_link.py").is_symlink()
+
+
def test_register_from_content_uses_configured_storage(lazy_registry: InitializerRegistry, tmp_path: Path) -> None:
"""Test runtime registration persists source in the configured directory."""
lazy_registry.configure_custom_scripts_source(str(tmp_path))
diff --git a/tests/unit/registry/test_instance_registry.py b/tests/unit/registry/test_instance_registry.py
index c6a2959f96..9f26b6df6d 100644
--- a/tests/unit/registry/test_instance_registry.py
+++ b/tests/unit/registry/test_instance_registry.py
@@ -7,6 +7,7 @@
``SupportsInstances`` protocols.
"""
+import contextlib
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, Event
from typing import assert_type
@@ -22,6 +23,12 @@
SupportsInstances,
TagQuery,
)
+from pyrit.registry.instance_registry import (
+ SAVED_ENTRY_METADATA_KEY,
+ SavedEntryReplacementError,
+ record_saved_entry_refusals,
+ saved_entry_registration,
+)
class _TestItem(Identifiable):
@@ -100,6 +107,61 @@ def test_register_can_explicitly_replace_existing(self, registry: DefaultInstanc
assert registry.get("name") == "updated"
+ def test_register_refuses_to_replace_a_saved_entry(self, registry: DefaultInstanceRegistry[_TestItem]):
+ registry.register(_item("saved"), name="name", metadata={SAVED_ENTRY_METADATA_KEY: "targets"})
+ revision = registry.revision
+
+ with pytest.raises(SavedEntryReplacementError, match="Saved target 'name'"):
+ registry.register(_item("updated"), name="name", replace=True)
+
+ assert registry.get("name") == "saved"
+ assert registry.revision == revision
+
+ def test_refused_replacements_are_recorded_even_when_caught(self, registry: DefaultInstanceRegistry[_TestItem]):
+ registry.register(_item("saved"), name="name", metadata={SAVED_ENTRY_METADATA_KEY: "converters"})
+
+ with record_saved_entry_refusals() as refusals:
+ with contextlib.suppress(SavedEntryReplacementError):
+ registry.register(_item("updated"), name="name", replace=True)
+ registry.register(_item("other"), name="other", replace=True)
+
+ assert refusals == [("converters", "name")]
+ assert registry.get("other") == "other"
+
+ def test_a_restore_scope_marks_only_the_entry_it_names(self, registry: DefaultInstanceRegistry[_TestItem]):
+ with saved_entry_registration(kind="targets", name="saved"):
+ registry.register(_item("saved"), name="saved", metadata={"owner": "admin"})
+ registry.register(_item("helper"), name="helper")
+ registry.register(_item("later"), name="later")
+
+ assert registry.get_entry("saved").metadata == {"owner": "admin", SAVED_ENTRY_METADATA_KEY: "targets"}
+ assert SAVED_ENTRY_METADATA_KEY not in registry.get_entry("helper").metadata
+ assert SAVED_ENTRY_METADATA_KEY not in registry.get_entry("later").metadata
+
+ def test_a_caught_duplicate_name_for_a_saved_entry_is_recorded(self, registry: DefaultInstanceRegistry[_TestItem]):
+ registry.register(_item("saved"), name="name", metadata={SAVED_ENTRY_METADATA_KEY: "targets"})
+ registry.register(_item("startup"), name="startup")
+
+ with record_saved_entry_refusals() as refusals:
+ for name in ("name", "startup"):
+ with pytest.raises(ValueError, match="already exists"):
+ registry.register(_item("updated"), name=name)
+
+ assert refusals == [("targets", "name")]
+ assert registry.get("name") == "saved"
+
+ def test_a_caught_name_check_against_a_saved_entry_is_recorded(self, registry: DefaultInstanceRegistry[_TestItem]):
+ registry.register(_item("saved"), name="name", metadata={SAVED_ENTRY_METADATA_KEY: "converters"})
+ registry.register(_item("startup"), name="startup")
+
+ with record_saved_entry_refusals() as refusals:
+ for name in ("name", "startup"):
+ with pytest.raises(ValueError, match="already exists"):
+ registry.validate_name_available(name)
+ registry.validate_name_available("free")
+
+ assert refusals == [("converters", "name")]
+
def test_register_rejects_reserved_name(self):
registry: DefaultInstanceRegistry[_TestItem] = DefaultInstanceRegistry(reserved_names={"types"})
diff --git a/tests/unit/registry/test_registry_recipe_restore.py b/tests/unit/registry/test_registry_recipe_restore.py
new file mode 100644
index 0000000000..6382471f0d
--- /dev/null
+++ b/tests/unit/registry/test_registry_recipe_restore.py
@@ -0,0 +1,870 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import dataclasses
+import json
+import sys
+import threading
+from collections.abc import Iterator
+from pathlib import Path
+from typing import Any
+from unittest.mock import patch
+
+import pytest
+from pydantic import ValidationError
+
+from pyrit.backend.models.techniques import CreateTechniqueRequest
+from pyrit.converter import Base64Converter, ColloquialWordswapConverter
+from pyrit.models import Parameter
+from pyrit.models.catalog import (
+ RegistryRecipeKind,
+ RegistryRestoreReport,
+ RestoreFailure,
+ RestoreFailureReason,
+ SavedEntryRef,
+ StoredRecipe,
+ TechniqueRecipe,
+)
+from pyrit.prompt_target import OpenAIChatTarget, RoundRobinTarget
+from pyrit.registry import AttackRegistry, AttackTechniqueRegistry, ConverterRegistry, ScorerRegistry, TargetRegistry
+from pyrit.registry.instance_registry import SAVED_ENTRY_METADATA_KEY
+from pyrit.registry.registry_recipe_restore import RecipeBuilder, RecipeRejectedError, RegistryRecipeRestorer
+from pyrit.registry.registry_recipe_storage import MAX_FAILURE_MESSAGE_LENGTH, RecipeListing, RegistryRecipeStorage
+from unit.mocks import MockPromptTarget
+
+_OPENAI_MODULE = "pyrit.prompt_target.openai.openai_chat_target"
+_IDENTITY_TARGET = {
+ "class_module": _OPENAI_MODULE,
+ "class_name": "OpenAIChatTarget",
+ "auth_mode": "identity",
+ "params": {"endpoint": "https://contoso.openai.azure.com/openai/v1", "model_name": "gpt-4o"},
+}
+_ROUND_ROBIN_MODULE = "pyrit.prompt_target.round_robin_target"
+_TECHNIQUE = {
+ "type": "RedTeamingAttack",
+ "params": {"max_turns": 5},
+ "description": "Red teaming with a formal tone",
+ "tags": ["custom"],
+ "adversarial_chat": "contoso_gpt4o",
+ "adversarial_system_prompt": "You are a red teaming assistant.",
+ "request_converters": ["tone_formal"],
+ "response_converters": [],
+}
+
+_REGISTRIES = (TargetRegistry, ConverterRegistry, ScorerRegistry, AttackTechniqueRegistry)
+
+
+@pytest.fixture(autouse=True)
+def isolated_registries() -> Iterator[None]:
+ for registry in _REGISTRIES:
+ registry.reset_registry_singleton()
+ yield
+ for registry in _REGISTRIES:
+ registry.reset_registry_singleton()
+
+
+def _save(root: Path, kind: RegistryRecipeKind, name: str, document: dict[str, Any]) -> None:
+ _save_text(root, kind, name, json.dumps(document))
+
+
+def _save_text(root: Path, kind: RegistryRecipeKind, name: str, text: str) -> None:
+ folder = root / kind.value
+ folder.mkdir(parents=True, exist_ok=True)
+ (folder / f"{name}.json").write_text(text)
+
+
+def _round_robin(*targets: str) -> dict[str, Any]:
+ return {"class_module": _ROUND_ROBIN_MODULE, "class_name": "RoundRobinTarget", "params": {"targets": list(targets)}}
+
+
+def _failures_by_name(failures: list[RestoreFailure]) -> dict[str | None, RestoreFailure]:
+ return {failure.name: failure for failure in failures}
+
+
+class _OptionalKeyTarget(MockPromptTarget):
+ def __init__(self, *, endpoint: str, api_key: str | None = None) -> None:
+ super().__init__()
+ self.endpoint = endpoint
+ self.api_key = api_key
+
+
+class _WorkspaceTarget(MockPromptTarget):
+ caller_path_parameters = frozenset({"working_directory"})
+
+ def __init__(self, *, working_directory: str | None = None) -> None:
+ super().__init__()
+ self.working_directory = working_directory
+
+
+_COMPONENT_REGISTRIES = {
+ RegistryRecipeKind.TARGETS: TargetRegistry,
+ RegistryRecipeKind.CONVERTERS: ConverterRegistry,
+ RegistryRecipeKind.SCORERS: ScorerRegistry,
+}
+
+
+async def _build_async(stored: StoredRecipe) -> None:
+ """Stand in for the backend's builder: build with the registry calls the API's create makes."""
+ recipe = stored.recipe
+ if isinstance(recipe, TechniqueRecipe):
+ techniques = AttackTechniqueRegistry.get_registry_singleton()
+ args = recipe.model_dump(exclude_unset=True, exclude={"schema_version", "type", "tags"})
+ factory = techniques.create_factory(
+ name=stored.ref.name, attack_type=recipe.type, technique_tags=recipe.tags, strict_json=True, **args
+ )
+ techniques.instances.register_runtime(factory)
+ return
+ registry = _COMPONENT_REGISTRIES[stored.ref.kind].get_registry_singleton()
+ cls = registry.get_class(recipe.class_name)
+ params = dict(recipe.params)
+ allowed_path_params: frozenset[str] = frozenset()
+ if stored.ref.kind is RegistryRecipeKind.TARGETS:
+ if recipe.auth_mode is not None:
+ params.update(cls.get_auth_mode_parameters(auth_mode=recipe.auth_mode))
+ allowed_path_params = cls.caller_path_parameters
+ registry.create_named_instance(
+ name=stored.ref.name,
+ type_name=recipe.class_name,
+ params=params,
+ external_input=True,
+ allowed_path_params=allowed_path_params,
+ )
+
+
+async def _restore(root: Path, *, build_async: RecipeBuilder = _build_async) -> RegistryRestoreReport:
+ return await RegistryRecipeRestorer(source=str(root)).restore_async(build_async=build_async)
+
+
+def _raising_builder(errors: dict[str, Exception]) -> RecipeBuilder:
+ """Return a builder that raises the given error for each named entry and builds the rest."""
+
+ async def build_async(stored: StoredRecipe) -> None:
+ if stored.ref.name in errors:
+ raise errors[stored.ref.name]
+ await _build_async(stored)
+
+ return build_async
+
+
+@pytest.mark.usefixtures("patch_central_database")
+class TestRestoreOrderAndRebuild:
+ async def test_restores_every_kind_after_the_entries_it_uses(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "formal_red_team", _TECHNIQUE)
+ _save(
+ tmp_path,
+ RegistryRecipeKind.SCORERS,
+ "refusal",
+ {
+ "class_module": "pyrit.score.true_false.self_ask_refusal_scorer",
+ "class_name": "SelfAskRefusalScorer",
+ "params": {"chat_target": "contoso_gpt4o"},
+ },
+ )
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "tone_formal",
+ {
+ "class_module": "pyrit.converter.tone_converter",
+ "class_name": "ToneConverter",
+ "params": {"converter_target": "contoso_gpt4o", "tone": "formal"},
+ },
+ )
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+
+ report = await _restore(tmp_path)
+
+ assert report.failures == []
+ assert [(ref.kind, ref.name) for ref in report.restored] == [
+ (RegistryRecipeKind.TARGETS, "contoso_gpt4o"),
+ (RegistryRecipeKind.CONVERTERS, "tone_formal"),
+ (RegistryRecipeKind.SCORERS, "refusal"),
+ (RegistryRecipeKind.TECHNIQUES, "formal_red_team"),
+ ]
+ target = TargetRegistry.get_registry_singleton().instances.get("contoso_gpt4o")
+ assert isinstance(target, OpenAIChatTarget)
+ converter = ConverterRegistry.get_registry_singleton().instances.get("tone_formal")
+ assert converter is not None
+ assert converter._converter_target is target
+ factory = AttackTechniqueRegistry.get_registry_singleton().instances.get("formal_red_team")
+ assert factory is not None
+ assert factory.technique_tags == ["custom"]
+
+ async def test_marks_each_restored_entry_with_its_kind(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "formal_red_team", _TECHNIQUE)
+ _save(
+ tmp_path,
+ RegistryRecipeKind.SCORERS,
+ "refusal",
+ {
+ "class_module": "pyrit.score.true_false.self_ask_refusal_scorer",
+ "class_name": "SelfAskRefusalScorer",
+ "params": {"chat_target": "contoso_gpt4o"},
+ },
+ )
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "tone_formal",
+ {
+ "class_module": "pyrit.converter.tone_converter",
+ "class_name": "ToneConverter",
+ "params": {"converter_target": "contoso_gpt4o", "tone": "formal"},
+ },
+ )
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ ConverterRegistry.get_registry_singleton().instances.register(Base64Converter(), name="startup_encoder")
+
+ report = await _restore(tmp_path)
+
+ assert report.failures == []
+ entries = {
+ "contoso_gpt4o": TargetRegistry.get_registry_singleton().instances.get_entry("contoso_gpt4o"),
+ "tone_formal": ConverterRegistry.get_registry_singleton().instances.get_entry("tone_formal"),
+ "refusal": ScorerRegistry.get_registry_singleton().instances.get_entry("refusal"),
+ "formal_red_team": AttackTechniqueRegistry.get_registry_singleton().instances.get_entry("formal_red_team"),
+ "startup_encoder": ConverterRegistry.get_registry_singleton().instances.get_entry("startup_encoder"),
+ }
+ assert {name: entry.metadata.get(SAVED_ENTRY_METADATA_KEY) for name, entry in entries.items() if entry} == {
+ "contoso_gpt4o": "targets",
+ "tone_formal": "converters",
+ "refusal": "scorers",
+ "formal_red_team": "techniques",
+ "startup_encoder": None,
+ }
+
+ async def test_finds_a_startup_entry_by_its_exact_name(self, tmp_path: Path) -> None:
+ registry = TargetRegistry.get_registry_singleton()
+ # Each name has a different normalized form, so a lookup that normalized names would miss them.
+ for name in ("cafe\u0301", "target\uff10"):
+ startup_target = OpenAIChatTarget(
+ endpoint="https://startup.openai.azure.com/openai/v1", model_name="gpt-4o", auth_mode="identity"
+ )
+ registry.instances.register(startup_target, name=name)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "a_pool", _round_robin("cafe\u0301", "target\uff10"))
+
+ report = await _restore(tmp_path)
+
+ assert report.failures == []
+ assert isinstance(registry.instances.get("a_pool"), RoundRobinTarget)
+
+ async def test_reads_without_building_and_restores_what_was_read(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ restorer = RegistryRecipeRestorer(source=str(tmp_path))
+
+ saved = restorer.read()
+ assert "contoso_gpt4o" not in TargetRegistry.get_registry_singleton().instances
+ with patch.object(RegistryRecipeStorage, "list_recipes", side_effect=AssertionError("read again")):
+ report = await restorer.restore_async(saved=saved, build_async=_build_async)
+
+ assert report.failures == []
+ assert [ref.name for ref in report.restored] == ["contoso_gpt4o"]
+
+ async def test_reads_off_the_event_loop_when_not_given_what_was_read(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ list_recipes = RegistryRecipeStorage.list_recipes
+ reading_threads: set[int] = set()
+
+ def record_thread(storage: RegistryRecipeStorage) -> RecipeListing:
+ reading_threads.add(threading.get_ident())
+ return list_recipes(storage)
+
+ with patch.object(RegistryRecipeStorage, "list_recipes", autospec=True, side_effect=record_thread):
+ report = await _restore(tmp_path)
+
+ assert [ref.name for ref in report.restored] == ["contoso_gpt4o"]
+ assert reading_threads
+ assert threading.get_ident() not in reading_threads
+
+ async def test_orders_entries_of_the_same_kind(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "a_pool", _round_robin("b_member", "c_member"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "b_member", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "c_member", _IDENTITY_TARGET)
+
+ report = await _restore(tmp_path)
+
+ assert report.failures == []
+ assert [ref.name for ref in report.restored] == ["b_member", "c_member", "a_pool"]
+ assert isinstance(TargetRegistry.get_registry_singleton().instances.get("a_pool"), RoundRobinTarget)
+
+ def test_technique_recipe_matches_the_create_request(self) -> None:
+ recipe_fields = set(TechniqueRecipe.model_fields) - {"schema_version"}
+ request_fields = set(CreateTechniqueRequest.model_fields) - {"name"}
+ request = CreateTechniqueRequest.model_validate({"name": "formal_red_team", **_TECHNIQUE})
+ recipe = TechniqueRecipe.model_validate(_TECHNIQUE)
+
+ assert recipe_fields == request_fields
+ assert recipe.model_dump(exclude_unset=True, exclude={"schema_version", "type", "tags"}) == (
+ request.model_dump(exclude_unset=True, exclude={"name", "type", "tags"})
+ )
+
+ def test_technique_recipe_defaults_match_the_create_request(self) -> None:
+ recipe = TechniqueRecipe.model_validate({"type": "RedTeamingAttack"})
+ request = CreateTechniqueRequest.model_validate({"name": "formal_red_team", "type": "RedTeamingAttack"})
+
+ assert recipe.model_dump(exclude={"schema_version"}) == request.model_dump(exclude={"name"})
+
+ @pytest.mark.parametrize(
+ "changes",
+ [
+ {"tags": ["all"]},
+ {"tags": ["Custom", "custom"]},
+ {"tags": ["bad-tag"]},
+ {"type": ""},
+ {"params": {"x" * 300: 1}},
+ {"request_converters": ["c"] * 101},
+ {"params": {"temperature": float("nan")}},
+ {"description": "x" * 100_001},
+ {"unexpected": True},
+ ],
+ )
+ def test_technique_recipe_rejects_what_the_create_request_rejects(self, changes: dict[str, Any]) -> None:
+ document = {**_TECHNIQUE, **changes}
+
+ with pytest.raises(ValidationError):
+ CreateTechniqueRequest.model_validate({"name": "formal_red_team", **document})
+ with pytest.raises(ValidationError):
+ TechniqueRecipe.model_validate(document)
+
+
+@pytest.mark.usefixtures("patch_central_database")
+class TestRestoreFailures:
+ async def test_a_technique_waits_for_its_saved_adversarial_target(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", {**_IDENTITY_TARGET, "class_name": "NoSuchTarget"})
+ without_converters = {key: value for key, value in _TECHNIQUE.items() if key != "request_converters"}
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "formal_red_team", without_converters)
+ startup_target = OpenAIChatTarget(
+ endpoint="https://startup.openai.azure.com/openai/v1", model_name="gpt-4o", auth_mode="identity"
+ )
+ TargetRegistry.get_registry_singleton().instances.register(startup_target, name="contoso_gpt4o")
+
+ report = await _restore(tmp_path)
+
+ failure = _failures_by_name(report.failures)["formal_red_team"]
+ assert failure.reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failure.dependency == SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name="contoso_gpt4o")
+
+ @pytest.mark.parametrize("field", ["request_converters", "response_converters"])
+ async def test_a_technique_waits_for_a_saved_converter_in_either_list(self, tmp_path: Path, field: str) -> None:
+ ConverterRegistry.get_registry_singleton().instances.register(Base64Converter(), name="shared_name")
+ _save_text(tmp_path, RegistryRecipeKind.CONVERTERS, "shared_name", "{not json")
+ _save(
+ tmp_path, RegistryRecipeKind.TECHNIQUES, "uses_shared", {"type": "RedTeamingAttack", field: ["shared_name"]}
+ )
+
+ report = await _restore(tmp_path)
+
+ failure = _failures_by_name(report.failures)["uses_shared"]
+ assert failure.reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failure.dependency == SavedEntryRef(kind=RegistryRecipeKind.CONVERTERS, name="shared_name")
+
+ async def test_reports_an_unknown_attack_type(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "unknown_attack", {"type": "NoSuchAttack"})
+
+ (failure,) = (await _restore(tmp_path)).failures
+
+ assert (failure.name, failure.reason) == ("unknown_attack", RestoreFailureReason.UNKNOWN_CLASS)
+
+ async def test_technique_parameters_are_checked_as_strict_json(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ without_converters = {key: value for key, value in _TECHNIQUE.items() if key != "request_converters"}
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "loose", {**without_converters, "params": {"max_turns": "5"}})
+
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("loose", RestoreFailureReason.BUILD_ERROR)
+
+ async def test_reports_dependency_loops_and_entries_waiting_on_them(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "loop_a", _round_robin("loop_b"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "loop_b", _round_robin("loop_a"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "waiting", _round_robin("loop_a"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "self_loop", _round_robin("self_loop"))
+
+ report = await _restore(tmp_path)
+
+ failures = _failures_by_name(report.failures)
+ assert report.restored == []
+ assert failures["loop_a"].reason is RestoreFailureReason.DEPENDENCY_LOOP
+ assert failures["loop_b"].reason is RestoreFailureReason.DEPENDENCY_LOOP
+ assert failures["self_loop"].reason is RestoreFailureReason.DEPENDENCY_LOOP
+ assert failures["waiting"].reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failures["waiting"].dependency == SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name="loop_a")
+
+ async def test_names_a_loop_member_as_the_reason_for_a_loop(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "a", _round_robin("c", "b"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "b", _round_robin("a"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "c", _round_robin("d"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "d", _round_robin("c"))
+
+ report = await _restore(tmp_path)
+
+ failures = _failures_by_name(report.failures)
+ assert failures["a"].reason is RestoreFailureReason.DEPENDENCY_LOOP
+ assert failures["a"].dependency == SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name="b")
+
+ async def test_reports_missing_and_failed_dependencies(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "broken", {**_IDENTITY_TARGET, "class_name": "NoSuchTarget"})
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "uses_broken", _round_robin("broken"))
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "uses_ghost", _round_robin("ghost"))
+ without_adversarial = {key: value for key, value in _TECHNIQUE.items() if key != "adversarial_chat"}
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TECHNIQUES,
+ "uses_unparsable",
+ {**without_adversarial, "request_converters": ["bad"]},
+ )
+ _save_text(tmp_path, RegistryRecipeKind.CONVERTERS, "bad", "{not json")
+
+ report = await _restore(tmp_path)
+
+ failures = _failures_by_name(report.failures)
+ assert failures["broken"].reason is RestoreFailureReason.UNKNOWN_CLASS
+ assert failures["uses_broken"].reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failures["uses_ghost"].reason is RestoreFailureReason.MISSING_DEPENDENCY
+ assert failures["uses_ghost"].dependency == SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name="ghost")
+ assert failures["bad"].reason is RestoreFailureReason.INVALID_RECIPE
+ assert failures["uses_unparsable"].reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert failures["uses_unparsable"].dependency == SavedEntryRef(kind=RegistryRecipeKind.CONVERTERS, name="bad")
+
+ async def test_restores_a_class_that_moved_within_pyrit(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TARGETS,
+ "contoso_gpt4o",
+ {**_IDENTITY_TARGET, "class_module": "pyrit.prompt_target.openai_chat_target"},
+ )
+
+ report = await _restore(tmp_path)
+
+ assert report.failures == []
+ assert [ref.name for ref in report.restored] == ["contoso_gpt4o"]
+
+ async def test_never_imports_a_module_named_by_a_file(self, tmp_path: Path) -> None:
+ module = "pyrit_restore_probe"
+ modules = tmp_path / "modules"
+ modules.mkdir()
+ (modules / f"{module}.py").write_text("class ProbeTarget:\n pass\n")
+ recipes = tmp_path / "recipes"
+ _save(recipes, RegistryRecipeKind.TARGETS, "moved", {**_IDENTITY_TARGET, "class_module": module})
+ _save(recipes, RegistryRecipeKind.TARGETS, "unknown", {"class_module": module, "class_name": "ProbeTarget"})
+
+ with patch.object(sys, "path", [str(modules), *sys.path]), patch.dict(sys.modules):
+ report = await _restore(recipes)
+ imported = module in sys.modules
+
+ failures = _failures_by_name(report.failures)
+ assert failures["moved"].reason is RestoreFailureReason.MODULE_MISMATCH
+ assert _OPENAI_MODULE in failures["moved"].message
+ assert failures["unknown"].reason is RestoreFailureReason.UNKNOWN_CLASS
+ assert not imported
+
+ async def test_refuses_a_stored_credential(self, tmp_path: Path) -> None:
+ stored_key = {**_IDENTITY_TARGET, "params": {**_IDENTITY_TARGET["params"], "api_key": "sk-secret-value"}}
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "stored_key", stored_key)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "good", _IDENTITY_TARGET)
+
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("stored_key", RestoreFailureReason.UNSUPPORTED_CREDENTIAL)
+ assert "sk-secret-value" not in failure.message
+ assert [ref.name for ref in report.restored] == ["good"]
+
+ async def test_restores_a_class_whose_optional_credentials_are_left_empty(self, tmp_path: Path) -> None:
+ TargetRegistry.get_registry_singleton().register_class(_OptionalKeyTarget)
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TARGETS,
+ "anonymous_agent",
+ {
+ "class_module": _OptionalKeyTarget.__module__,
+ "class_name": "_OptionalKeyTarget",
+ "params": {"endpoint": "https://agent.example.test"},
+ },
+ )
+
+ report = await _restore(tmp_path)
+
+ assert report.failures == []
+ target = TargetRegistry.get_registry_singleton().instances.get("anonymous_agent")
+ assert isinstance(target, _OptionalKeyTarget)
+ assert (target.endpoint, target.api_key) == ("https://agent.example.test", None)
+
+ async def test_a_planning_bug_fails_only_that_entry_as_a_logged_build_error(
+ self, tmp_path: Path, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "a_first", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "b_second", _IDENTITY_TARGET)
+ registry = TargetRegistry.get_registry_singleton()
+ get_metadata = registry.get_registered_class_metadata
+ errors = iter([RuntimeError("bug")])
+
+ def fail_once(name: str) -> Any:
+ if (error := next(errors, None)) is not None:
+ raise error
+ return get_metadata(name)
+
+ with (
+ patch.object(registry, "get_registered_class_metadata", side_effect=fail_once),
+ caplog.at_level("ERROR", logger="pyrit.registry.registry_recipe_restore"),
+ ):
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("a_first", RestoreFailureReason.BUILD_ERROR)
+ assert failure.message == "RuntimeError: bug"
+ assert [ref.name for ref in report.restored] == ["b_second"]
+ assert "Traceback (most recent call last)" in caplog.text
+
+ @pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("got https://user:pass@host/v1?sig=SECRET#frag tail", "ValueError: got https://host/v1 tail"),
+ (
+ "top_p must be at most 100% for gpt4o@2024-08",
+ "ValueError: top_p must be at most 100% for gpt4o@2024-08",
+ ),
+ ("bad key \n\x1b[31mFAKE", "ValueError: bad key \\n\\x1b[31mFAKE"),
+ ],
+ ids=["url", "plain", "control"],
+ )
+ async def test_a_build_error_is_reported_safely(self, tmp_path: Path, text: str, expected: str) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "broken", _IDENTITY_TARGET)
+
+ report = await _restore(tmp_path, build_async=_raising_builder({"broken": ValueError(text)}))
+
+ (failure,) = report.failures
+ assert (failure.reason, failure.message) == (RestoreFailureReason.BUILD_ERROR, expected)
+
+ async def test_a_long_build_error_is_shortened(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "broken", _IDENTITY_TARGET)
+
+ report = await _restore(tmp_path, build_async=_raising_builder({"broken": ValueError("x" * 5_000)}))
+
+ (failure,) = report.failures
+ assert len(failure.message) == MAX_FAILURE_MESSAGE_LENGTH
+ assert failure.message.endswith("...")
+
+ async def test_a_build_error_that_cannot_be_printed_is_reported_by_its_type(self, tmp_path: Path) -> None:
+ class UnprintableError(Exception):
+ def __str__(self) -> str:
+ raise RuntimeError("cannot print")
+
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "broken", _IDENTITY_TARGET)
+
+ report = await _restore(tmp_path, build_async=_raising_builder({"broken": UnprintableError()}))
+
+ (failure,) = report.failures
+ assert (failure.reason, failure.message) == (RestoreFailureReason.BUILD_ERROR, "UnprintableError")
+
+ async def test_logs_each_failure_once_with_its_traceback_and_without_url_credentials(
+ self, tmp_path: Path, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "leaky", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "pool", _round_robin("leaky"))
+ error = ValueError("could not reach https://host/v1?sig=SECRET")
+
+ with caplog.at_level("WARNING", logger="pyrit.registry.registry_recipe_restore"):
+ report = await _restore(tmp_path, build_async=_raising_builder({"leaky": error}))
+
+ assert {failure.name for failure in report.failures} == {"leaky", "pool"}
+ leaky_records = [record for record in caplog.records if "target 'leaky' (" in record.getMessage()]
+ assert len(leaky_records) == 1
+ assert "Traceback (most recent call last)" in leaky_records[0].getMessage()
+ assert len([record for record in caplog.records if "target 'pool' (" in record.getMessage()]) == 1
+ assert "SECRET" not in caplog.text
+
+ async def test_logs_every_problem_with_a_file_that_is_not_valid_in_full(
+ self, tmp_path: Path, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ many_keys = [f"unknown_{index:03d}_{'x' * 40}" for index in range(100)]
+ twins = ["p" * 70 + "_alpha", "p" * 70 + "_beta"]
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "many_keys", {**_IDENTITY_TARGET, **dict.fromkeys(many_keys, True)})
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "twins", {**_IDENTITY_TARGET, **dict.fromkeys(twins, True)})
+
+ with caplog.at_level("ERROR", logger="pyrit.registry.registry_recipe_restore"):
+ report = await _restore(tmp_path)
+
+ messages = {failure.name: failure.message for failure in report.failures}
+ assert len(messages["many_keys"]) == MAX_FAILURE_MESSAGE_LENGTH
+ for name, keys in (("many_keys", many_keys), ("twins", twins)):
+ (record,) = [record for record in caplog.records if f"target '{name}' (" in record.getMessage()]
+ lines = record.getMessage().splitlines()
+ assert all(f" {key}: Extra inputs are not permitted" in lines for key in keys)
+
+ async def test_logs_the_problems_of_a_file_escaped_and_without_stored_values_or_url_credentials(
+ self, tmp_path: Path, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ url_key = "https://user:pw-token@acct.blob.core.windows.net/c/x.json?sv=2024&sig=sas-token#frag-token"
+ long_url_key = f"https://user:{'longpw' * 20}@host/path?sig=sas-token"
+ document = {
+ **_IDENTITY_TARGET,
+ "schema_version": "STORED-VALUE",
+ url_key: True,
+ long_url_key: True,
+ "\n\x1b[31mFAKE": True,
+ }
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "unsafe", document)
+
+ with caplog.at_level("ERROR", logger="pyrit.registry.registry_recipe_restore"):
+ report = await _restore(tmp_path)
+
+ (record,) = [record for record in caplog.records if "target 'unsafe' (" in record.getMessage()]
+ lines = record.getMessage().splitlines()
+ assert " schema_version: Input should be a valid integer" in lines
+ assert " https://acct.blob.core.windows.net/c/x.json: Extra inputs are not permitted" in lines
+ assert " https://host/path: Extra inputs are not permitted" in lines
+ assert " \\n\\x1b[31mFAKE: Extra inputs are not permitted" in lines
+ assert all(line.isprintable() for line in lines)
+ for hidden in ("STORED-VALUE", "pw-token", "longpw", "sig=", "sas-token", "frag-token"):
+ assert hidden not in caplog.text
+ assert hidden not in report.model_dump_json()
+
+ async def test_reports_a_reference_name_that_is_not_valid_text_as_invalid(self, tmp_path: Path) -> None:
+ _save_text(
+ tmp_path,
+ RegistryRecipeKind.TARGETS,
+ "pool",
+ '{"class_module": "pyrit.prompt_target.round_robin_target", "class_name": "RoundRobinTarget", '
+ '"params": {"targets": ["ghost\\ud800"]}}',
+ )
+
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("pool", RestoreFailureReason.INVALID_RECIPE)
+ assert json.loads(report.model_dump_json())["failures"][0]["name"] == "pool"
+
+ async def test_refuses_a_stored_credential_in_attack_parameters(self, tmp_path: Path) -> None:
+ attack_registry = AttackRegistry.get_registry_singleton()
+ original_get_registered_class_metadata = attack_registry.get_registered_class_metadata
+
+ def with_credential(name: str) -> Any:
+ metadata = original_get_registered_class_metadata(name)
+ credential = Parameter(name="api_key", description="", sensitive=True)
+ return dataclasses.replace(metadata, parameters=(*metadata.parameters, credential))
+
+ _save(
+ tmp_path,
+ RegistryRecipeKind.TECHNIQUES,
+ "keyed",
+ {"type": "RedTeamingAttack", "params": {"api_key": "sk-x"}},
+ )
+
+ with patch.object(attack_registry, "get_registered_class_metadata", side_effect=with_credential):
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("keyed", RestoreFailureReason.UNSUPPORTED_CREDENTIAL)
+ assert "sk-x" not in failure.message
+
+ async def test_an_empty_reference_is_left_unset(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "puzzled",
+ {
+ "class_module": "pyrit.converter.puzzled.puzzled_converter",
+ "class_name": "PuzzledConverter",
+ "params": {"converter_target": ""},
+ },
+ )
+
+ report = await _restore(tmp_path)
+
+ assert report.failures == []
+ assert [ref.name for ref in report.restored] == ["puzzled"]
+
+ async def test_follows_converter_and_scorer_references(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "selective",
+ {
+ "class_module": "pyrit.converter.selective_text_converter",
+ "class_name": "SelectiveTextConverter",
+ "params": {"sub_converter": "ghost_converter"},
+ },
+ )
+ _save(
+ tmp_path,
+ RegistryRecipeKind.SCORERS,
+ "composite",
+ {
+ "class_module": "pyrit.score.true_false.true_false_composite_scorer",
+ "class_name": "TrueFalseCompositeScorer",
+ "params": {"scorers": ["ghost_scorer"]},
+ },
+ )
+
+ report = await _restore(tmp_path)
+
+ failures = _failures_by_name(report.failures)
+ assert failures["selective"].dependency == SavedEntryRef(
+ kind=RegistryRecipeKind.CONVERTERS, name="ghost_converter"
+ )
+ assert failures["composite"].dependency == SavedEntryRef(kind=RegistryRecipeKind.SCORERS, name="ghost_scorer")
+ assert {failure.reason for failure in report.failures} == {RestoreFailureReason.MISSING_DEPENDENCY}
+
+ async def test_rejects_server_paths_the_way_the_api_does(self, tmp_path: Path) -> None:
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "wordswap",
+ {
+ "class_module": "pyrit.converter.colloquial_wordswap_converter",
+ "class_name": "ColloquialWordswapConverter",
+ "params": {"wordswap_path": "/etc/passwd"},
+ },
+ )
+
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert failure.reason is RestoreFailureReason.UNSUPPORTED_CLASS
+ assert "wordswap_path" in failure.message
+ assert "wordswap" not in ConverterRegistry.get_registry_singleton().instances
+
+ async def test_lets_a_target_set_the_path_parameters_its_class_allows_callers(self, tmp_path: Path) -> None:
+ TargetRegistry.get_registry_singleton().register_class(_WorkspaceTarget)
+ workspace = tmp_path / "workspace"
+ workspace.mkdir()
+ recipes = tmp_path / "recipes"
+ _save(
+ recipes,
+ RegistryRecipeKind.TARGETS,
+ "workspace_agent",
+ {
+ "class_module": _WorkspaceTarget.__module__,
+ "class_name": "_WorkspaceTarget",
+ "params": {"working_directory": str(workspace)},
+ },
+ )
+
+ report = await _restore(recipes)
+
+ assert report.failures == []
+ target = TargetRegistry.get_registry_singleton().instances.get("workspace_agent")
+ assert isinstance(target, _WorkspaceTarget)
+ assert target.working_directory == str(workspace)
+
+ async def test_gives_only_targets_the_path_parameters_their_class_allows_callers(self, tmp_path: Path) -> None:
+ class CallerPathWordswap(ColloquialWordswapConverter):
+ caller_path_parameters = frozenset({"wordswap_path"})
+
+ ConverterRegistry.get_registry_singleton().register_class(CallerPathWordswap)
+ _save(
+ tmp_path,
+ RegistryRecipeKind.CONVERTERS,
+ "wordswap",
+ {
+ "class_module": CallerPathWordswap.__module__,
+ "class_name": "CallerPathWordswap",
+ "params": {"wordswap_path": "/etc/passwd"},
+ },
+ )
+
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert failure.reason is RestoreFailureReason.UNSUPPORTED_CLASS
+ assert "wordswap_path" in failure.message
+ assert "wordswap" not in ConverterRegistry.get_registry_singleton().instances
+
+ async def test_reports_a_name_taken_at_startup_and_fails_its_dependents(self, tmp_path: Path) -> None:
+ startup_target = OpenAIChatTarget(
+ endpoint="https://startup.openai.azure.com/openai/v1", model_name="gpt-4o", auth_mode="identity"
+ )
+ TargetRegistry.get_registry_singleton().instances.register(startup_target, name="contoso_gpt4o")
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "pool", _round_robin("contoso_gpt4o"))
+
+ report = await _restore(tmp_path)
+
+ failures = _failures_by_name(report.failures)
+ assert failures["contoso_gpt4o"].reason is RestoreFailureReason.NAME_CLASH
+ assert failures["pool"].reason is RestoreFailureReason.FAILED_DEPENDENCY
+ assert TargetRegistry.get_registry_singleton().instances.get("contoso_gpt4o") is startup_target
+
+ async def test_reports_a_technique_name_that_matches_a_tag_in_another_case(self, tmp_path: Path) -> None:
+ without_converters = {key: value for key, value in _TECHNIQUE.items() if key != "request_converters"}
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "formal_red_team", {**without_converters, "tags": ["Custom"]})
+ assert (await _restore(tmp_path)).failures == []
+ TargetRegistry.reset_registry_singleton()
+ (tmp_path / "techniques" / "formal_red_team.json").unlink()
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "custom", {**without_converters, "tags": []})
+
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("custom", RestoreFailureReason.NAME_CLASH)
+ assert "custom" in failure.message
+
+ async def test_builds_nothing_when_a_folder_cannot_be_listed(
+ self, tmp_path: Path, caplog: pytest.LogCaptureFixture
+ ) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "contoso_gpt4o", _IDENTITY_TARGET)
+ (tmp_path / "converters").write_text("a file where the folder should be")
+
+ with caplog.at_level("WARNING", logger="pyrit.registry.registry_recipe_restore"):
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert (failure.kind, failure.name, failure.reason) == (
+ RegistryRecipeKind.CONVERTERS,
+ None,
+ RestoreFailureReason.UNREADABLE_STORAGE,
+ )
+ assert str(tmp_path) not in failure.message
+ assert str(tmp_path / "converters") in caplog.text
+ assert report.restored == []
+ assert "contoso_gpt4o" not in TargetRegistry.get_registry_singleton().instances
+
+ async def test_raises_a_listing_bug_instead_of_blaming_storage(self, tmp_path: Path) -> None:
+ with patch.object(RegistryRecipeStorage, "list_recipes", side_effect=KeyError("bug")):
+ with pytest.raises(KeyError, match="bug"):
+ await _restore(tmp_path)
+
+ async def test_a_build_error_fails_only_that_entry(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "bad", _IDENTITY_TARGET)
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "good", _IDENTITY_TARGET)
+
+ report = await _restore(tmp_path, build_async=_raising_builder({"bad": ValueError("could not be coerced")}))
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("bad", RestoreFailureReason.BUILD_ERROR)
+ assert failure.version
+ assert [ref.name for ref in report.restored] == ["good"]
+ assert "bad" not in TargetRegistry.get_registry_singleton().instances
+
+ async def test_reports_the_reason_a_builder_refuses_an_entry_with(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TARGETS, "refused", _IDENTITY_TARGET)
+ refusal = RecipeRejectedError(reason=RestoreFailureReason.INVALID_RECIPE, message="Not a valid request.")
+
+ report = await _restore(tmp_path, build_async=_raising_builder({"refused": refusal}))
+
+ (failure,) = report.failures
+ assert (failure.reason, failure.message) == (RestoreFailureReason.INVALID_RECIPE, "Not a valid request.")
+
+ async def test_reports_a_reserved_name_as_invalid_and_a_taken_one_as_a_clash(self, tmp_path: Path) -> None:
+ ConverterRegistry.get_registry_singleton().instances.register(Base64Converter(), name="taken")
+ base64 = {"class_module": "pyrit.converter.base64_converter", "class_name": "Base64Converter", "params": {}}
+ _save(tmp_path, RegistryRecipeKind.CONVERTERS, "preview", base64)
+ _save(tmp_path, RegistryRecipeKind.CONVERTERS, "taken", base64)
+
+ report = await _restore(tmp_path)
+
+ failures = _failures_by_name(report.failures)
+ assert failures["preview"].reason is RestoreFailureReason.INVALID_RECIPE
+ assert failures["taken"].reason is RestoreFailureReason.NAME_CLASH
+
+ async def test_reports_a_technique_name_a_scenario_cant_use_as_invalid(self, tmp_path: Path) -> None:
+ _save(tmp_path, RegistryRecipeKind.TECHNIQUES, "value", {"type": "PromptSendingAttack"})
+
+ report = await _restore(tmp_path)
+
+ (failure,) = report.failures
+ assert (failure.name, failure.reason) == ("value", RestoreFailureReason.INVALID_RECIPE)
+ assert "ScenarioTechnique" in failure.message
diff --git a/tests/unit/registry/test_registry_recipe_storage.py b/tests/unit/registry/test_registry_recipe_storage.py
new file mode 100644
index 0000000000..28ce0142a9
--- /dev/null
+++ b/tests/unit/registry/test_registry_recipe_storage.py
@@ -0,0 +1,357 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+import hashlib
+import json
+import os
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+from azure.storage.blob import ContainerClient
+from pydantic import ValidationError
+
+from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
+from pyrit.models.catalog import (
+ ComponentRecipe,
+ RegistryRecipeKind,
+ RestoreFailureReason,
+ SavedEntryRef,
+ StoredRecipe,
+ TechniqueRecipe,
+)
+from pyrit.models.catalog.registry_recipe import RECIPE_SCHEMA_VERSION
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError, _read_regular_file
+from pyrit.registry.registry_recipe_storage import MAX_FAILURE_MESSAGE_LENGTH, RegistryRecipeStorage
+
+_TARGET = {
+ "schema_version": 1,
+ "class_module": "pyrit.prompt_target.openai.openai_chat_target",
+ "class_name": "OpenAIChatTarget",
+ "auth_mode": "identity",
+ "params": {"endpoint": "https://contoso.openai.azure.com/openai/v1"},
+}
+
+
+def _write(directory: Path, name: str, content: str | bytes) -> bytes:
+ directory.mkdir(parents=True, exist_ok=True)
+ data = content.encode("utf-8") if isinstance(content, str) else content
+ (directory / f"{name}.json").write_bytes(data)
+ return data
+
+
+class TestRegistryRecipeStorageSource:
+ def test_local_source_uses_a_folder_per_registry(self, tmp_path: Path) -> None:
+ storage = RegistryRecipeStorage(kind=RegistryRecipeKind.CONVERTERS, source=str(tmp_path))
+
+ assert storage.display_source == str(tmp_path / "converters")
+
+ def test_default_source_is_under_the_pyrit_configuration_directory(self) -> None:
+ storage = RegistryRecipeStorage(kind=RegistryRecipeKind.SCORERS)
+
+ assert storage.display_source == str(CONFIGURATION_DIRECTORY_PATH / "registry_recipes" / "scorers")
+
+ def test_blob_source_adds_the_folder_and_keeps_the_sas_token(self) -> None:
+ source = "https://account.blob.core.windows.net/recipes/team?sp=rl&sig=secret"
+
+ kind_source = RegistryRecipeStorage._get_kind_source(root=source, kind=RegistryRecipeKind.TARGETS)
+
+ assert kind_source == "https://account.blob.core.windows.net/recipes/team/targets?sp=rl&sig=secret"
+
+ def test_blob_container_source_uses_the_folder_as_prefix(self) -> None:
+ client = MagicMock(spec=ContainerClient)
+ client.__enter__.return_value = client
+ client.list_blobs.return_value = [SimpleNamespace(name="techniques/formal.json")]
+ client.download_blob.return_value.readall.return_value = json.dumps({"type": "RedTeamingAttack"}).encode()
+ storage = RegistryRecipeStorage(
+ kind=RegistryRecipeKind.TECHNIQUES, source="https://account.blob.core.windows.net/recipes?sig=secret"
+ )
+
+ with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
+ listing = storage.list_recipes()
+
+ client.list_blobs.assert_called_once_with(name_starts_with="techniques/")
+ assert list(listing.recipes) == ["formal"]
+
+
+class TestRecipeModels:
+ @pytest.mark.parametrize(
+ ("model", "document"),
+ [
+ (ComponentRecipe, {key: value for key, value in _TARGET.items() if key != "schema_version"}),
+ (TechniqueRecipe, {"type": "RedTeamingAttack"}),
+ ],
+ )
+ def test_schema_version_default_does_not_track_the_current_schema_version(
+ self, model: type[ComponentRecipe] | type[TechniqueRecipe], document: dict[str, object]
+ ) -> None:
+ """
+ A document saved without a version is a version 1 document. If the default tracked the constant, bumping it
+ would relabel such documents as the newest shape, and a migration keyed on the version would skip them.
+ """
+ assert model.model_fields["schema_version"].default == 1
+ assert model.model_validate(document).schema_version == 1
+
+ @pytest.mark.parametrize(
+ ("model", "document"), [(ComponentRecipe, _TARGET), (TechniqueRecipe, {"type": "RedTeamingAttack"})]
+ )
+ def test_rejects_a_schema_version_newer_than_this_pyrit_reads(
+ self, model: type[ComponentRecipe] | type[TechniqueRecipe], document: dict[str, object]
+ ) -> None:
+ with pytest.raises(ValidationError, match="less than or equal to"):
+ model.model_validate({**document, "schema_version": RECIPE_SCHEMA_VERSION + 1})
+
+ @pytest.mark.parametrize(
+ ("kind", "recipe"),
+ [
+ (RegistryRecipeKind.TECHNIQUES, ComponentRecipe.model_validate(_TARGET)),
+ (RegistryRecipeKind.TARGETS, TechniqueRecipe.model_validate({"type": "RedTeamingAttack"})),
+ ],
+ )
+ def test_a_stored_recipe_must_match_its_registry(
+ self, kind: RegistryRecipeKind, recipe: ComponentRecipe | TechniqueRecipe
+ ) -> None:
+ with pytest.raises(ValidationError, match="entry needs a"):
+ StoredRecipe(ref=SavedEntryRef(kind=kind, name="mismatched"), recipe=recipe, version="v1")
+
+ @pytest.mark.parametrize("kind", [RegistryRecipeKind.CONVERTERS, RegistryRecipeKind.SCORERS])
+ def test_only_a_target_has_an_auth_mode(self, kind: RegistryRecipeKind) -> None:
+ recipe = ComponentRecipe.model_validate(_TARGET)
+
+ StoredRecipe(ref=SavedEntryRef(kind=RegistryRecipeKind.TARGETS, name="chat"), recipe=recipe, version="v1")
+ with pytest.raises(ValidationError, match="Only a saved target has an auth_mode"):
+ StoredRecipe(ref=SavedEntryRef(kind=kind, name="chat"), recipe=recipe, version="v1")
+
+ def test_rejects_an_auth_mode_the_api_doesnt_offer(self) -> None:
+ with pytest.raises(ValidationError, match="auth_mode"):
+ ComponentRecipe.model_validate({**_TARGET, "auth_mode": "password"})
+
+
+class TestRegistryRecipeStorageListing:
+ def test_reads_component_recipes_with_their_versions(self, tmp_path: Path) -> None:
+ content = _write(tmp_path / "targets", "contoso_gpt4o", json.dumps(_TARGET))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ stored = listing.recipes["contoso_gpt4o"]
+ assert stored.ref.kind is RegistryRecipeKind.TARGETS
+ assert stored.ref.name == "contoso_gpt4o"
+ assert stored.recipe == ComponentRecipe.model_validate(_TARGET)
+ assert stored.version == hashlib.sha256(content).hexdigest()
+ assert listing.failures == ()
+
+ def test_reads_technique_recipes_as_technique_recipes(self, tmp_path: Path) -> None:
+ document = {"type": "RedTeamingAttack", "tags": ["custom"], "adversarial_chat": "contoso_gpt4o"}
+ _write(tmp_path / "techniques", "formal_red_team", json.dumps(document))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TECHNIQUES, source=str(tmp_path)).list_recipes()
+
+ assert listing.recipes["formal_red_team"].recipe == TechniqueRecipe.model_validate(document)
+
+ def test_reports_documents_that_are_not_recipes(self, tmp_path: Path) -> None:
+ folder = tmp_path / "targets"
+ _write(folder, "not_json", "{oops")
+ _write(folder, "not_utf8", b"\xff\xfe")
+ _write(folder, "not_object", "[1, 2]")
+ _write(folder, "extra_field", json.dumps({**_TARGET, "credentials": {"api_key": "sk-secret-value"}}))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ assert listing.recipes == {}
+ assert {failure.name for failure in listing.failures} == {"not_json", "not_utf8", "not_object", "extra_field"}
+ assert all(failure.reason is RestoreFailureReason.INVALID_RECIPE for failure in listing.failures)
+ assert all(failure.version for failure in listing.failures)
+ extra = next(failure for failure in listing.failures if failure.name == "extra_field")
+ assert "credentials" in extra.message
+ assert "sk-secret-value" not in extra.message
+
+ def test_reports_a_document_nested_too_deeply(self, tmp_path: Path) -> None:
+ nested: object = "x"
+ for _ in range(300):
+ nested = {"list": [nested]}
+ _write(tmp_path / "targets", "deep_params", json.dumps({**_TARGET, "params": {"x": nested}}))
+ _write(tmp_path / "targets", "contoso_gpt4o", json.dumps(_TARGET))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason, failure.message) == (
+ "deep_params",
+ RestoreFailureReason.INVALID_RECIPE,
+ "Nested too deeply to read.",
+ )
+ assert list(listing.recipes) == ["contoso_gpt4o"]
+
+ def test_reports_a_document_too_deep_for_the_json_parser(self, tmp_path: Path) -> None:
+ # How deep the parser can go depends on the stack size, so it's made to give up here.
+ _write(tmp_path / "targets", "deep", "[[[]]]")
+
+ with patch("pyrit.registry.registry_recipe_storage.json.loads", side_effect=RecursionError):
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason, failure.message) == (
+ "deep",
+ RestoreFailureReason.INVALID_RECIPE,
+ "Nested too deeply to read.",
+ )
+
+ def test_limits_a_technique_description_like_the_create_request(self, tmp_path: Path) -> None:
+ folder = tmp_path / "techniques"
+ _write(folder, "at_limit", json.dumps({"type": "RedTeamingAttack", "description": "x" * 100_000}))
+ _write(folder, "over_limit", json.dumps({"type": "RedTeamingAttack", "description": "x" * 100_001}))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TECHNIQUES, source=str(tmp_path)).list_recipes()
+
+ assert list(listing.recipes) == ["at_limit"]
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason) == ("over_limit", RestoreFailureReason.INVALID_RECIPE)
+ assert "description" in failure.message
+
+ def test_reports_a_newer_schema_version_before_checking_fields(self, tmp_path: Path) -> None:
+ _write(tmp_path / "targets", "future", json.dumps({"schema_version": 2, "unknown_field": True}))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert failure.reason is RestoreFailureReason.NEWER_SCHEMA_VERSION
+ assert "newer PyRIT" in failure.message
+
+ def test_reports_a_file_name_that_is_not_utf8(self, tmp_path: Path) -> None:
+ folder = tmp_path / "targets"
+ folder.mkdir()
+ try:
+ (folder / os.fsdecode(b"bad\xffname.json")).write_text("{}")
+ except (OSError, UnicodeError):
+ pytest.skip("This file system only allows UTF-8 file names")
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (ignored,) = listing.ignored
+ assert ignored.file_name == "bad\\udcffname.json"
+ assert "bad\\udcffname" in ignored.message
+ assert json.loads(ignored.model_dump_json())["file_name"] == "bad\\udcffname.json"
+
+ def test_reports_unreadable_and_ignored_files(self, tmp_path: Path) -> None:
+ folder = tmp_path / "scorers"
+ _write(folder, "locked", json.dumps(_TARGET))
+ _write(folder, "Bad-Name", "{}")
+
+ def read(path: str) -> bytes | None:
+ if Path(path).name == "locked.json":
+ raise PermissionError(13, "Permission denied", path)
+ return _read_regular_file(path)
+
+ with patch("pyrit.registry.file_document_storage._read_regular_file", side_effect=read):
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.SCORERS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason, failure.message) == (
+ "locked",
+ RestoreFailureReason.UNREADABLE_RECIPE,
+ "Permission denied",
+ )
+ assert failure.version is None
+ assert isinstance(listing.causes["locked"], PermissionError)
+ (ignored,) = listing.ignored
+ assert (ignored.kind, ignored.file_name) == (RegistryRecipeKind.SCORERS, "Bad-Name.json")
+ assert "Bad-Name" in ignored.message
+
+ def test_accepts_a_byte_order_mark(self, tmp_path: Path) -> None:
+ _write(tmp_path / "targets", "from_notepad", b"\xef\xbb\xbf" + json.dumps(_TARGET).encode("utf-8"))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ assert list(listing.recipes) == ["from_notepad"]
+
+ @pytest.mark.parametrize(
+ "document",
+ [
+ '{"class_module": "m", "class_name": "C", "params": {"text": "bad \\udcff"}}',
+ '{"class_module": "m", "class_name": "C", "params": {"bad \\ud800": 1}}',
+ ],
+ ids=["value", "key"],
+ )
+ def test_reports_an_unpaired_surrogate_as_invalid(self, tmp_path: Path, document: str) -> None:
+ _write(tmp_path / "targets", "odd_text", document)
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason) == ("odd_text", RestoreFailureReason.INVALID_RECIPE)
+ assert "unpaired surrogate" in failure.message
+
+ def test_keeps_text_that_is_valid_unicode(self, tmp_path: Path) -> None:
+ document = {**_TARGET, "params": {"greeting": "caf\u00e9 \U0001f600"}}
+ _write(tmp_path / "targets", "friendly", json.dumps(document))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ assert listing.recipes["friendly"].recipe.params["greeting"] == "caf\u00e9 \U0001f600"
+
+ def test_reports_a_newer_technique_format_like_any_other(self, tmp_path: Path) -> None:
+ newer = RECIPE_SCHEMA_VERSION + 1
+ _write(
+ tmp_path / "techniques", "next_format", json.dumps({"schema_version": newer, "type": "RedTeamingAttack"})
+ )
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TECHNIQUES, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert failure.reason is RestoreFailureReason.NEWER_SCHEMA_VERSION
+
+ def test_shortens_what_a_validation_error_repeats(self, tmp_path: Path) -> None:
+ long_key = "k" * 10_000
+ many_keys = {f"unknown_{index:03d}_{'x' * 40}": True for index in range(100)}
+ _write(tmp_path / "targets", "long_key", json.dumps({**_TARGET, long_key: True}))
+ _write(tmp_path / "targets", "many_keys", json.dumps({**_TARGET, **many_keys}))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ messages = {failure.name: failure.message for failure in listing.failures}
+ assert "k" * 61 + "..." in messages["long_key"]
+ assert "k" * 65 not in messages["long_key"]
+ assert len(messages["many_keys"]) == MAX_FAILURE_MESSAGE_LENGTH
+ assert messages["many_keys"].endswith("...")
+
+ def test_escapes_control_characters_that_a_failure_repeats(self, tmp_path: Path) -> None:
+ _write(tmp_path / "targets", "odd_key", json.dumps({**_TARGET, "\n\x1b[31mFAKE": True}))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert "\\n\\x1b[31mFAKE" in failure.message
+ assert failure.message.isprintable()
+
+ def test_caps_every_failure_message(self, tmp_path: Path) -> None:
+ _write(tmp_path / "targets", "huge_version", '{"schema_version": ' + "9" * 4_000 + "}")
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert failure.reason is RestoreFailureReason.NEWER_SCHEMA_VERSION
+ assert len(failure.message) == MAX_FAILURE_MESSAGE_LENGTH
+
+ def test_reports_a_converter_saved_with_an_auth_mode(self, tmp_path: Path) -> None:
+ _write(tmp_path / "converters", "signed_in", json.dumps(_TARGET))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.CONVERTERS, source=str(tmp_path)).list_recipes()
+
+ (failure,) = listing.failures
+ assert (failure.name, failure.reason) == ("signed_in", RestoreFailureReason.INVALID_RECIPE)
+ assert "Only a saved target has an auth_mode" in failure.message
+
+ def test_a_listing_cannot_be_changed_by_its_reader(self, tmp_path: Path) -> None:
+ _write(tmp_path / "targets", "contoso_gpt4o", json.dumps(_TARGET))
+
+ listing = RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
+
+ with pytest.raises(TypeError):
+ listing.recipes["other"] = listing.recipes["contoso_gpt4o"] # type: ignore[index]
+
+ def test_raises_when_the_folder_cannot_be_listed(self, tmp_path: Path) -> None:
+ (tmp_path / "targets").write_text("a file where the folder should be")
+
+ with pytest.raises(DocumentStorageUnavailableError):
+ RegistryRecipeStorage(kind=RegistryRecipeKind.TARGETS, source=str(tmp_path)).list_recipes()
diff --git a/tests/unit/registry/test_scenario_preset_storage.py b/tests/unit/registry/test_scenario_preset_storage.py
index 434fb66968..21c4761f27 100644
--- a/tests/unit/registry/test_scenario_preset_storage.py
+++ b/tests/unit/registry/test_scenario_preset_storage.py
@@ -15,7 +15,11 @@
from pyrit.models import DatasetSourceInput, ScenarioDatasetRequest, ScenarioPreset
from pyrit.models.catalog.scenario_preset import PRESET_SCHEMA_VERSION
from pyrit.registry import ScenarioPresetConflictError, ScenarioPresetStorage
-from pyrit.registry.file_document_storage import _release_exclusive_lock, _try_acquire_exclusive_lock
+from pyrit.registry.file_document_storage import (
+ DocumentStorageUnavailableError,
+ _release_exclusive_lock,
+ _try_acquire_exclusive_lock,
+)
def _make_preset(**overrides: object) -> ScenarioPreset:
@@ -354,7 +358,7 @@ def test_conditional_delete_checks_the_version_under_the_document_lock(tmp_path:
try:
with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
- with pytest.raises(TimeoutError, match="nightly.json.lock"):
+ with pytest.raises(DocumentStorageUnavailableError, match="nightly.json.lock"):
storage.delete_preset("nightly", expected_version=saved.version)
finally:
_release_exclusive_lock(descriptor)
@@ -465,6 +469,7 @@ def test_blob_storage_reads_missing_document_as_none() -> None:
client = MagicMock()
client.__enter__.return_value = client
client.download_blob.side_effect = ResourceNotFoundError("missing")
+ client.download_blob.side_effect.error_code = "BlobNotFound"
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
@@ -490,6 +495,7 @@ def test_blob_delete_of_a_missing_document_reports_absence_instead_of_raising()
client = MagicMock()
client.__enter__.return_value = client
client.delete_blob.side_effect = ResourceNotFoundError("missing")
+ client.delete_blob.side_effect.error_code = "BlobNotFound"
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
@@ -567,6 +573,7 @@ def test_blob_conditional_delete_of_a_missing_document_reports_absence() -> None
client = MagicMock()
client.__enter__.return_value = client
client.download_blob.side_effect = ResourceNotFoundError("missing")
+ client.download_blob.side_effect.error_code = "BlobNotFound"
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
@@ -582,10 +589,12 @@ def test_blob_conditional_delete_that_loses_to_another_delete_reports_absence(re
document = json.dumps({"scenario_name": "foundry.red_team_agent"}).encode("utf-8")
client = _blob_client_holding(document)
+ missing = ResourceNotFoundError("missing")
+ missing.error_code = "BlobNotFound"
if removed_during == "delete":
- client.delete_blob.side_effect = ResourceNotFoundError("missing")
+ client.delete_blob.side_effect = missing
else:
- client.download_blob.side_effect = [client.download_blob.return_value, ResourceNotFoundError("missing")]
+ client.download_blob.side_effect = [client.download_blob.return_value, missing]
client.delete_blob.side_effect = ResourceModifiedError("changed")
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
@@ -699,7 +708,7 @@ def fail(source: object, target: object) -> None:
raise OSError(28, "No space left on device")
with patch("os.replace", fail):
- with pytest.raises(OSError):
+ with pytest.raises(DocumentStorageUnavailableError):
storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
loaded = storage.load_preset("nightly")
@@ -774,7 +783,7 @@ def test_a_second_writer_cannot_enter_while_a_save_is_in_flight(tmp_path: Path)
real_replace_file = ScenarioPresetStorage._replace_file
def replace_once_a_competitor_has_tried(*, path: Path, content: bytes) -> None:
- with pytest.raises(TimeoutError) as error:
+ with pytest.raises(DocumentStorageUnavailableError) as error:
competitor.save_preset(preset=_make_preset(description="racer"), expected_version=first.version)
refusals.append(str(error.value))
real_replace_file(path=path, content=content)
@@ -798,7 +807,7 @@ def test_a_delete_cannot_enter_while_a_save_is_in_flight(tmp_path: Path) -> None
real_replace_file = ScenarioPresetStorage._replace_file
def replace_once_a_deleter_has_tried(*, path: Path, content: bytes) -> None:
- with pytest.raises(TimeoutError) as error:
+ with pytest.raises(DocumentStorageUnavailableError) as error:
competitor.delete_preset("nightly")
refusals.append(str(error.value))
real_replace_file(path=path, content=content)
@@ -846,7 +855,7 @@ def test_a_lock_held_by_a_live_writer_is_respected(tmp_path: Path) -> None:
try:
with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
- with pytest.raises(TimeoutError, match="nightly.json.lock"):
+ with pytest.raises(DocumentStorageUnavailableError, match="nightly.json.lock"):
storage.save_preset(preset=_make_preset(), expected_version=None)
finally:
_release_exclusive_lock(descriptor)
@@ -919,7 +928,7 @@ def test_blob_create_refuses_to_overwrite_a_document_another_writer_just_created
def test_blob_create_against_a_missing_container_is_not_reported_as_a_conflict() -> None:
"""Test that an absent container surfaces as a storage error instead of a phantom concurrent edit."""
- from azure.core.exceptions import AzureError, ResourceNotFoundError
+ from azure.core.exceptions import ResourceNotFoundError
client = MagicMock()
client.__enter__.return_value = client
@@ -927,7 +936,7 @@ def test_blob_create_against_a_missing_container_is_not_reported_as_a_conflict()
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/absent?sig=secret")
with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
- with pytest.raises(AzureError):
+ with pytest.raises(DocumentStorageUnavailableError):
storage.save_preset(preset=_make_preset(), expected_version=None)
@@ -969,6 +978,7 @@ def test_blob_update_of_a_deleted_document_is_a_conflict() -> None:
client = MagicMock()
client.__enter__.return_value = client
client.download_blob.side_effect = ResourceNotFoundError("missing")
+ client.download_blob.side_effect.error_code = "BlobNotFound"
storage = ScenarioPresetStorage(source="https://account.blob.core.windows.net/presets?sig=secret")
with patch("azure.storage.blob.ContainerClient.from_container_url", return_value=client):
diff --git a/tests/unit/registry/test_scenario_suite_storage.py b/tests/unit/registry/test_scenario_suite_storage.py
index 2ccb4cd1ae..4f830be0a8 100644
--- a/tests/unit/registry/test_scenario_suite_storage.py
+++ b/tests/unit/registry/test_scenario_suite_storage.py
@@ -15,6 +15,7 @@
from pyrit.models import ScenarioSuite, ScenarioSuiteEntry
from pyrit.models.catalog.scenario_suite import SUITE_SCHEMA_VERSION
from pyrit.registry import ScenarioSuiteConflictError, ScenarioSuiteStorage
+from pyrit.registry.file_document_storage import DocumentStorageUnavailableError
ENTRY_ONE = UUID("11111111-1111-4111-8111-111111111111")
ENTRY_TWO = UUID("22222222-2222-4222-8222-222222222222")
@@ -95,7 +96,7 @@ def test_failed_atomic_replace_preserves_previous_suite(tmp_path: Path) -> None:
created = storage.save_suite(suite=_suite(description="original"), expected_version=None)
with patch("os.replace", side_effect=OSError(28, "No space left on device")):
- with pytest.raises(OSError):
+ with pytest.raises(DocumentStorageUnavailableError):
storage.save_suite(suite=_suite(description="replacement"), expected_version=created.version)
loaded = storage.load_suite("weekly")
@@ -112,7 +113,7 @@ def test_local_lock_excludes_a_second_writer(tmp_path: Path) -> None:
real_replace = ScenarioSuiteStorage._replace_file
def replace_after_competitor_times_out(*, path: Path, content: bytes) -> None:
- with pytest.raises(TimeoutError):
+ with pytest.raises(DocumentStorageUnavailableError):
competitor.save_suite(suite=_suite(description="racer"), expected_version=created.version)
real_replace(path=path, content=content)
diff --git a/tests/unit/setup/test_configuration_loader.py b/tests/unit/setup/test_configuration_loader.py
index cebcc16eda..ccabb22c18 100644
--- a/tests/unit/setup/test_configuration_loader.py
+++ b/tests/unit/setup/test_configuration_loader.py
@@ -87,6 +87,25 @@ def test_scenario_suites_source_rejects_invalid_value(self, invalid_value: objec
with pytest.raises(ValueError, match="scenario_suites_source"):
ConfigurationLoader(scenario_suites_source=invalid_value) # type: ignore[arg-type]
+ def test_registry_recipes_source_loads_from_yaml(self, tmp_path: pathlib.Path) -> None:
+ """Test loading an Azure Blob container URI for saved registry entries."""
+ config_path = tmp_path / "config.yaml"
+ config_path.write_text(
+ "registry_recipes_source: https://account.blob.core.windows.net/pyrit-storage/registry-recipes\n",
+ encoding="utf-8",
+ )
+
+ config = ConfigurationLoader.from_yaml_file(config_path)
+
+ assert config.registry_recipes_source == "https://account.blob.core.windows.net/pyrit-storage/registry-recipes"
+ assert ConfigurationLoader().registry_recipes_source is None
+
+ @pytest.mark.parametrize("invalid_value", ["", " ", 42])
+ def test_registry_recipes_source_rejects_invalid_value(self, invalid_value: object) -> None:
+ """Test rejecting empty or non-string sources for saved registry entries."""
+ with pytest.raises(ValueError, match="registry_recipes_source"):
+ ConfigurationLoader(registry_recipes_source=invalid_value) # type: ignore[arg-type]
+
def test_target_upload_directory_loads_from_yaml(self, tmp_path: pathlib.Path) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text("target_upload_directory: /srv/uploads\n", encoding="utf-8")
diff --git a/tests/unit/setup/test_converter_initializer.py b/tests/unit/setup/test_converter_initializer.py
index 667624cfb1..9fb3138156 100644
--- a/tests/unit/setup/test_converter_initializer.py
+++ b/tests/unit/setup/test_converter_initializer.py
@@ -12,6 +12,7 @@
from pyrit.converter import Base64Converter, LeetspeakConverter, ROT13Converter, VariationConverter
from pyrit.registry import ConverterRegistry, InitializerRegistry, TargetRegistry
+from pyrit.registry.instance_registry import SAVED_ENTRY_METADATA_KEY, SavedEntryReplacementError
from pyrit.setup.initializers import ConverterInitializer
from pyrit.setup.initializers.converters import ConverterConfig
@@ -80,6 +81,20 @@ async def test_initialize_replaces_converter_with_current_target() -> None:
assert converter._converter_target is not first_target
+async def test_initialize_does_not_skip_a_saved_converter_it_would_replace() -> None:
+ registry = ConverterRegistry.get_registry_singleton()
+ saved = Base64Converter()
+ registry.instances.register(saved, name="rot13", metadata={SAVED_ENTRY_METADATA_KEY: "converters"})
+
+ with (
+ patch.object(ConverterInitializer, "CONFIGS", _get_configs("base64", "rot13")),
+ pytest.raises(SavedEntryReplacementError, match="Saved converter 'rot13'"),
+ ):
+ await ConverterInitializer().initialize_async()
+
+ assert registry.instances.get("rot13") is saved
+
+
async def test_initialize_skips_converter_without_declared_target() -> None:
registry = ConverterRegistry.get_registry_singleton()
diff --git a/tests/unit/setup/test_scorer_initializer.py b/tests/unit/setup/test_scorer_initializer.py
index af33ae7801..4c4984f0d7 100644
--- a/tests/unit/setup/test_scorer_initializer.py
+++ b/tests/unit/setup/test_scorer_initializer.py
@@ -7,9 +7,11 @@
from unittest.mock import MagicMock, patch
import pytest
+from unit.mocks import MockPromptTarget
from pyrit.prompt_target import LiteLLMDecisionTarget, OpenAIChatTarget
from pyrit.registry import ScorerRegistry, TargetRegistry
+from pyrit.registry.instance_registry import SAVED_ENTRY_METADATA_KEY, SavedEntryReplacementError
from pyrit.score import (
DecisionScorer,
FloatScaleThresholdScorer,
@@ -27,6 +29,7 @@
GPT4O_UNSAFE_TEMP9_TARGET,
GPT5_1_TARGET,
GPT5_4_TARGET,
+ REFUSAL_GPT5_4,
)
@@ -364,6 +367,37 @@ def _register_mock_target(self, *, name: str, underlying_model: str = "gpt-4o")
registry.instances.register(target, name=name)
return target
+ @patch("pyrit.setup.initializers.scorers.find_objective_metrics_by_eval_hash", return_value=None)
+ async def test_refuses_to_tag_a_saved_scorer_it_could_not_build(self, mock_find_metrics) -> None:
+ self._register_mock_target(name=GPT4O_TARGET)
+ saved = SubStringScorer(substring="saved")
+ registry = ScorerRegistry.get_registry_singleton()
+ registry.instances.register(saved, name=REFUSAL_GPT5_4, metadata={SAVED_ENTRY_METADATA_KEY: "scorers"})
+
+ with pytest.raises(SavedEntryReplacementError, match=f"Saved scorer '{REFUSAL_GPT5_4}'"):
+ await ScorerInitializer().initialize_async()
+
+ entry = registry.instances.get_entry(REFUSAL_GPT5_4)
+ assert entry is not None
+ assert entry.instance is saved
+ assert ScorerInitializerTags.BEST_REFUSAL not in entry.tags
+
+ @patch("pyrit.setup.initializers.scorers.find_objective_metrics_by_eval_hash", return_value=None)
+ async def test_refuses_to_make_a_saved_scorer_the_default_objective(self, mock_find_metrics) -> None:
+ TargetRegistry.get_registry_singleton().instances.register(MockPromptTarget(), name="unrelated_target")
+ saved = SubStringScorer(substring="saved")
+ registry = ScorerRegistry.get_registry_singleton()
+ name = ScorerInitializer.SCALE_AND_REFUSAL
+ registry.instances.register(saved, name=name, metadata={SAVED_ENTRY_METADATA_KEY: "scorers"})
+
+ with pytest.raises(SavedEntryReplacementError, match=f"Saved scorer '{name}'"):
+ await ScorerInitializer().initialize_async()
+
+ entry = registry.instances.get_entry(name)
+ assert entry is not None
+ assert entry.instance is saved
+ assert ScorerInitializerTags.BEST_OBJECTIVE not in entry.tags
+
@patch("pyrit.setup.initializers.scorers.find_objective_metrics_by_eval_hash")
async def test_best_objective_tags_best_scorer(self, mock_find_metrics: MagicMock) -> None:
"""Test that _tag_best_objective tags the scorer with highest F1."""
diff --git a/tests/unit/setup/test_targets_initializer.py b/tests/unit/setup/test_targets_initializer.py
index a88e43e259..7342ada631 100644
--- a/tests/unit/setup/test_targets_initializer.py
+++ b/tests/unit/setup/test_targets_initializer.py
@@ -9,6 +9,7 @@
from pyrit.prompt_target import LiteLLMDecisionTarget, OpenAIChatTarget
from pyrit.registry import TargetRegistry
+from pyrit.registry.instance_registry import SAVED_ENTRY_METADATA_KEY, SavedEntryReplacementError
from pyrit.setup.initializers import TargetInitializer
from pyrit.setup.initializers.targets import TARGET_CONFIGS, generate_rr_name, get_behavioral_key
@@ -932,6 +933,20 @@ async def test_individual_targets_still_accessible_after_auto_group(self) -> Non
assert registry.instances.get("azure_openai_gpt4o") is not None
assert registry.instances.get("azure_openai_gpt4o2") is not None
+ async def test_refuses_to_leave_a_saved_target_in_place_of_a_group(self) -> None:
+ os.environ.update(self.GPT4O_1_ENV)
+ os.environ.update(self.GPT4O_2_ENV)
+ registry = TargetRegistry.get_registry_singleton()
+ saved = OpenAIChatTarget(endpoint="https://saved.test/v1", model_name="gpt-4o", api_key="saved-key")
+ registry.instances.register(
+ saved, name="OpenAIChatTarget_gpt-4o_rr", metadata={SAVED_ENTRY_METADATA_KEY: "targets"}
+ )
+
+ with pytest.raises(SavedEntryReplacementError, match="Saved target 'OpenAIChatTarget_gpt-4o_rr'"):
+ await TargetInitializer().initialize_async()
+
+ assert registry.instances.get("OpenAIChatTarget_gpt-4o_rr") is saved
+
async def test_no_round_robin_when_single_target(self) -> None:
"""Test that no round-robin is created when only one target has a given model."""
os.environ.update(self.GPT4O_1_ENV)