{selectableTechniques.map((technique) => {
const selected = isTechniqueSelected(technique)
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 55626a9ddf..9c523de4d9 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -600,7 +600,11 @@ export const scenarioPresetsApi = {
await apiClient.delete(`/scenario-presets/${encodeURIComponent(name)}`)
},
- /** Combines a stored preset with the launch-owned fields it omits into a runnable request. */
+ /**
+ * Combines a stored preset with the launch-owned fields it omits into a runnable request.
+ * Sending `expected_version` fails with 409 when the preset changed after it was read,
+ * so a launch cannot silently run a configuration the operator never saw.
+ */
resolve: async (
name: string,
request: ResolveScenarioPresetRequest,
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 5119b6e6cf..a6bb2887a5 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -830,6 +830,7 @@ export interface UpdateScenarioPresetRequest {
/** The launch-owned fields a preset deliberately omits. */
export interface ResolveScenarioPresetRequest {
+ expected_version?: string | null
target_name: string
adversarial_target_name?: string | null
initializers?: string[] | null
diff --git a/pyrit/backend/models/scenario_presets.py b/pyrit/backend/models/scenario_presets.py
index 1aa1127b86..690f384567 100644
--- a/pyrit/backend/models/scenario_presets.py
+++ b/pyrit/backend/models/scenario_presets.py
@@ -78,8 +78,16 @@ class ResolveScenarioPresetRequest(BaseModel):
A preset answers *what to test*; these answer *how and where*. Resolution is
a union of the two, which is why nothing here overlaps a preset field.
+
+ ``expected_version`` is the exception: it is a precondition rather than a
+ launch field. A client that previewed a resolution sends the version it
+ previewed so an edit landing in between is reported instead of silently
+ launching a configuration the operator never saw.
"""
+ expected_version: str | None = Field(
+ None, description="Version the caller resolved against; omit to resolve whatever is stored now"
+ )
target_name: str = Field(..., description="Name of a registered target from the TargetRegistry")
adversarial_target_name: str | None = Field(
None, description="Name of a registered adversarial target, when the scenario uses one"
diff --git a/pyrit/backend/routes/scenario_presets.py b/pyrit/backend/routes/scenario_presets.py
index 0266cc1813..bfd1098a20 100644
--- a/pyrit/backend/routes/scenario_presets.py
+++ b/pyrit/backend/routes/scenario_presets.py
@@ -233,7 +233,10 @@ async def delete_scenario_preset(name: str) -> None: # pyrit-async-suffix-exemp
@router.post(
"/{name}/resolve",
response_model=RunScenarioRequest,
- responses={404: {"model": ProblemDetail, "description": "Preset not found"}},
+ responses={
+ 404: {"model": ProblemDetail, "description": "Preset not found"},
+ 409: {"model": ProblemDetail, "description": "The preset changed after it was read"},
+ },
)
async def resolve_scenario_preset( # pyrit-async-suffix-exempt
name: str,
@@ -247,12 +250,24 @@ async def resolve_scenario_preset( # pyrit-async-suffix-exempt
the merge, so the distinction between "unset" and "set to the default" cannot
drift between callers.
+ When the caller supplies ``expected_version`` the stored version must still match,
+ so an edit landing between the preview and the launch is reported rather than
+ quietly running a configuration the operator never confirmed.
+
Args:
name: The preset name.
body: The target and execution fields for this launch.
Returns:
RunScenarioRequest: The request to post to the scenario run endpoint.
+
+ Raises:
+ HTTPException: 404 if no preset is stored, 409 if the stored version moved.
"""
stored = await _load_preset_or_404_async(name)
+ if body.expected_version is not None and body.expected_version != stored.version:
+ raise HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail=f"Scenario preset '{name}' changed since it was read; re-read it and retry",
+ )
return get_scenario_preset_service().resolve_run_request(preset=stored.preset, launch=body)
diff --git a/pyrit/backend/services/scenario_configuration_resolver.py b/pyrit/backend/services/scenario_configuration_resolver.py
index 9b679c6fd2..30d19968cd 100644
--- a/pyrit/backend/services/scenario_configuration_resolver.py
+++ b/pyrit/backend/services/scenario_configuration_resolver.py
@@ -8,6 +8,11 @@
from typing import TYPE_CHECKING, Any
from pyrit.registry import ConverterRegistry, ScenarioRegistry, TargetRegistry
+from pyrit.scenario.core import (
+ CONVERTER_MODIFIER_PREFIX,
+ converter_name_from_modifier,
+ parse_technique_token,
+)
from pyrit.scenario.core.scenario_target_defaults import validate_default_adversarial_target
if TYPE_CHECKING:
@@ -15,8 +20,6 @@
from pyrit.prompt_target import PromptTarget
from pyrit.scenario import Scenario
-_CONVERTER_MODIFIER_PREFIX = "converter."
-
class ScenarioConfigurationResolver:
"""Resolve registry-backed scenario inputs for launch and estimation."""
@@ -188,8 +191,7 @@ def resolve_techniques_and_converters(
technique_enums: list[Any] = []
technique_converters: dict[str, list[Converter]] = {}
for token in tokens:
- base_name, _, remainder = token.partition(":")
- modifiers = [modifier for modifier in remainder.split(":") if modifier] if remainder else []
+ base_name, modifiers = parse_technique_token(token)
try:
technique_enum = technique_class(base_name)
except ValueError:
@@ -207,7 +209,7 @@ def resolve_techniques_and_converters(
return technique_enums, technique_converters
@staticmethod
- def _resolve_converter_modifiers(*, modifiers: list[str], token: str) -> list[Converter]:
+ def _resolve_converter_modifiers(*, modifiers: tuple[str, ...], token: str) -> list[Converter]:
"""
Resolve converter modifiers from one technique token.
@@ -223,13 +225,13 @@ def _resolve_converter_modifiers(*, modifiers: list[str], token: str) -> list[Co
instances = ConverterRegistry.get_registry_singleton().instances
converters: list[Converter] = []
for modifier in modifiers:
- if not modifier.startswith(_CONVERTER_MODIFIER_PREFIX):
+ converter_name = converter_name_from_modifier(modifier)
+ if converter_name is None:
raise ValueError(
f"Unknown technique modifier '{modifier}' in '{token}'. "
- f"Supported modifiers must use the '{_CONVERTER_MODIFIER_PREFIX}' prefix "
- f"(e.g. '{_CONVERTER_MODIFIER_PREFIX}translation_spanish')."
+ f"Supported modifiers must use the '{CONVERTER_MODIFIER_PREFIX}' prefix "
+ f"(e.g. '{CONVERTER_MODIFIER_PREFIX}translation_spanish')."
)
- converter_name = modifier[len(_CONVERTER_MODIFIER_PREFIX) :]
converter = instances.get(converter_name)
if converter is None:
available = instances.get_names()
diff --git a/pyrit/backend/services/scenario_preset_service.py b/pyrit/backend/services/scenario_preset_service.py
index 5772af93da..b4659c64eb 100644
--- a/pyrit/backend/services/scenario_preset_service.py
+++ b/pyrit/backend/services/scenario_preset_service.py
@@ -18,7 +18,12 @@
)
from pyrit.backend.services.scenario_service import get_scenario_service
from pyrit.models.catalog import RegisteredScenario, RunScenarioRequest, ScenarioPreset, StoredPreset
-from pyrit.registry import ScenarioPresetStorage
+from pyrit.registry import ConverterRegistry, ScenarioPresetStorage
+from pyrit.scenario.core import (
+ CONVERTER_MODIFIER_PREFIX,
+ converter_name_from_modifier,
+ parse_technique_token,
+)
logger = logging.getLogger(__name__)
@@ -228,30 +233,68 @@ def _by_name(item: tuple[str, StoredPreset]) -> str:
return item[0]
+def _technique_issues(*, names: list[str], description: str) -> list[PresetIssue]:
+ """
+ Build the at-most-one issue describing a kind of unresolvable technique reference.
+
+ Args:
+ names (list[str]): The offending names, possibly with repeats.
+ description (str): What is wrong with them, phrased to read before a name list.
+
+ Returns:
+ list[PresetIssue]: A single issue, or an empty list when nothing was offending.
+ """
+ if not names:
+ return []
+ return [PresetIssue(field="techniques", message=f"{description}: {', '.join(sorted(set(names)))}.")]
+
+
def _unknown_technique_issues(*, preset: ScenarioPreset, scenario: RegisteredScenario) -> list[PresetIssue]:
"""
- Report techniques the scenario does not expose.
+ Report techniques and converter modifiers this deployment cannot resolve.
+
+ Tokens are parsed with the same grammar the launch path uses, because comparing a
+ whole token against the scenario's technique names would report a runnable preset
+ such as ``role_play:converter.translation_spanish`` as broken.
Args:
preset (ScenarioPreset): The preset to check.
scenario (RegisteredScenario): The registered scenario it names.
Returns:
- list[PresetIssue]: One issue naming every unknown technique, or an empty list.
+ list[PresetIssue]: One issue per kind of unresolvable reference, or an empty list.
"""
if not preset.techniques:
return []
known = set(scenario.all_techniques) | set(scenario.aggregate_techniques)
- unknown = [technique for technique in preset.techniques if technique not in known]
- if not unknown:
- return []
+ registered_converters = ConverterRegistry.get_registry_singleton().instances
+ unknown_techniques: list[str] = []
+ unknown_converters: list[str] = []
+ malformed_modifiers: list[str] = []
+
+ for token in preset.techniques:
+ base_name, modifiers = parse_technique_token(token)
+ if base_name not in known:
+ unknown_techniques.append(base_name)
+ for modifier in modifiers:
+ converter_name = converter_name_from_modifier(modifier)
+ if converter_name is None:
+ malformed_modifiers.append(modifier)
+ elif registered_converters.get(converter_name) is None:
+ unknown_converters.append(converter_name)
return [
- PresetIssue(
- field="techniques",
- message=f"Scenario '{scenario.scenario_name}' does not define: {', '.join(sorted(unknown))}.",
- )
+ *_technique_issues(
+ names=unknown_techniques, description=f"Scenario '{scenario.scenario_name}' does not define"
+ ),
+ *_technique_issues(
+ names=unknown_converters, description="This deployment has no registered converter named"
+ ),
+ *_technique_issues(
+ names=malformed_modifiers,
+ description=f"Technique modifiers must use the '{CONVERTER_MODIFIER_PREFIX}' prefix; got",
+ ),
]
diff --git a/pyrit/registry/file_document_storage.py b/pyrit/registry/file_document_storage.py
index b400a253c5..f4f192a58f 100644
--- a/pyrit/registry/file_document_storage.py
+++ b/pyrit/registry/file_document_storage.py
@@ -8,6 +8,7 @@
import hashlib
import logging
import os
+import sys
import tempfile
import time
from contextlib import contextmanager, suppress
@@ -25,6 +26,53 @@
logger = logging.getLogger(__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.
+if sys.platform == "win32":
+ import msvcrt
+
+ def _try_acquire_exclusive_lock(descriptor: int) -> bool:
+ """
+ Try to take the exclusive lock without waiting.
+
+ Returns:
+ bool: Whether the lock was taken.
+ """
+ os.lseek(descriptor, 0, os.SEEK_SET)
+ try:
+ msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1)
+ except OSError:
+ return False
+ return True
+
+ def _release_exclusive_lock(descriptor: int) -> None:
+ """Release the exclusive lock held on an open descriptor."""
+ os.lseek(descriptor, 0, os.SEEK_SET)
+ with suppress(OSError):
+ msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
+
+else:
+ import fcntl
+
+ def _try_acquire_exclusive_lock(descriptor: int) -> bool:
+ """
+ Try to take the exclusive lock without waiting.
+
+ Returns:
+ bool: Whether the lock was taken.
+ """
+ try:
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError:
+ return False
+ return True
+
+ def _release_exclusive_lock(descriptor: int) -> None:
+ """Release the exclusive lock held on an open descriptor."""
+ with suppress(OSError):
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
+
class DocumentConflictError(ValueError):
"""A stored document changed after the caller read it."""
@@ -93,9 +141,10 @@ class FileDocumentStorage:
- Blob creates upload with ``overwrite=False`` and updates send the ETag read moments
earlier as an ``If-Match`` precondition, both evaluated by the service.
- - Local writes hold an exclusive lock file for the whole read-compare-replace
- sequence, which serializes every writer that goes through this class, including
- ones in other processes.
+ - Local writes hold an OS advisory lock on a sibling file for the whole
+ read-compare-replace sequence, which serializes every writer that goes through this
+ class, including ones in other processes. The kernel owns the lock, so a writer that
+ dies releases it rather than stranding the document.
The local guarantee is cooperative: it binds writers using this class, not someone
editing the file directly. That case is covered instead by the version itself, which
@@ -106,9 +155,7 @@ class FileDocumentStorage:
"""
LOCK_SUFFIX: str = ".lock"
- LOCK_BREAKER_SUFFIX: str = ".breaking"
LOCK_TIMEOUT_SECONDS: float = 10.0
- LOCK_STALE_SECONDS: float = 60.0
LOCK_POLL_SECONDS: float = 0.05
def __init__(self, *, source: str, extension: str, source_label: str) -> None:
@@ -401,9 +448,13 @@ def _local_document_lock(self, path: Path) -> Generator[None, None, None]:
"""
Hold an exclusive lock covering one document for the duration of the block.
- The lock is a sibling file created exclusively, so it excludes writers in other
- processes as well as other threads. It does not carry the document extension, so
- listing never sees it.
+ The lock is an OS advisory lock taken on a sibling file, so it excludes writers in
+ other processes as well as other threads, and the kernel releases it if the holder
+ exits without cleaning up. Crash recovery therefore needs no timeout heuristic: a
+ lock is held only while its owner is alive. The file itself stays in place because
+ it carries no state and deleting it would let two writers hold what they each
+ believe is the same lock while the path pointed at different files. It does not
+ carry the document extension, so listing never sees it.
Yields:
None: Control while the lock is held.
@@ -412,106 +463,32 @@ def _local_document_lock(self, path: Path) -> Generator[None, None, None]:
TimeoutError: If the lock could not be acquired.
"""
lock_path = path.with_name(f".{path.name}{self.LOCK_SUFFIX}")
- self._acquire_document_lock(lock_path)
+ descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR)
try:
- yield
+ self._acquire_document_lock(descriptor=descriptor, lock_path=lock_path)
+ try:
+ yield
+ finally:
+ _release_exclusive_lock(descriptor)
finally:
- with suppress(OSError):
- lock_path.unlink(missing_ok=True)
+ os.close(descriptor)
- def _acquire_document_lock(self, lock_path: Path) -> None:
+ def _acquire_document_lock(self, *, descriptor: int, lock_path: Path) -> None:
"""
- Create the lock file, waiting for whoever holds it to release it.
+ Wait for the exclusive lock on an open lock file until the wait budget runs out.
+
+ Args:
+ descriptor (int): Open descriptor for the lock file.
+ lock_path (Path): Path of the lock file, named in the timeout message.
Raises:
TimeoutError: If the lock is still held when the wait budget runs out.
"""
deadline = time.monotonic() + self.LOCK_TIMEOUT_SECONDS
- while True:
- try:
- descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
- except FileExistsError:
- if self._clear_stale_lock(lock_path):
- continue
- if time.monotonic() >= deadline:
- raise TimeoutError(
- f"Timed out waiting to write '{lock_path.name}'; another writer still holds it."
- ) from None
- time.sleep(self.LOCK_POLL_SECONDS)
- continue
- with os.fdopen(descriptor, "w") as lock_file:
- lock_file.write(str(os.getpid()))
- return
-
- def _clear_stale_lock(self, lock_path: Path) -> bool:
- """
- Remove a lock left behind by a writer that died before releasing it.
-
- The age check and the removal run under a second exclusive file, so only one writer
- can break a given lock. Without that, two writers seeing the same stale lock would
- both unlink: the first would take a fresh lock and the second would delete it, and
- both would proceed to write believing they held it.
-
- Returns:
- bool: Whether a stale lock was removed.
- """
- if self._stale_lock_age(lock_path) is None:
- return False
- breaker_path = lock_path.with_name(f"{lock_path.name}{self.LOCK_BREAKER_SUFFIX}")
- try:
- descriptor = os.open(breaker_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
- except FileExistsError:
- self._discard_abandoned_breaker(breaker_path)
- return False
- except OSError:
- return False
- os.close(descriptor)
- try:
- # Re-checked while holding the breaker, because the lock seen above may since
- # have been released and retaken by a writer that is still running.
- held_seconds = self._stale_lock_age(lock_path)
- if held_seconds is None:
- return False
- try:
- lock_path.unlink()
- except OSError:
- return False
- logger.warning(
- f"Removed stale lock {lock_path.name} held for {held_seconds:.0f}s by a writer that stopped."
- )
- return True
- finally:
- with suppress(OSError):
- breaker_path.unlink(missing_ok=True)
-
- def _stale_lock_age(self, lock_path: Path) -> float | None:
- """
- Report how long a lock has been held once it is past the stale threshold.
-
- Returns:
- float | None: Seconds the lock has been held, or None if it is gone or still fresh.
- """
- try:
- held_seconds = time.time() - lock_path.stat().st_mtime
- except OSError:
- return None
- return held_seconds if held_seconds >= self.LOCK_STALE_SECONDS else None
-
- def _discard_abandoned_breaker(self, breaker_path: Path) -> None:
- """
- Remove a breaker file left behind by a writer that died while breaking a lock.
-
- Breaking a lock spans a stat and an unlink, so a breaker older than the stale
- threshold can only be an orphan. Two writers discarding it at once is harmless,
- since the breaker grants no access on its own and is taken exclusively.
- """
- try:
- if time.time() - breaker_path.stat().st_mtime < self.LOCK_STALE_SECONDS:
- return
- except OSError:
- return
- with suppress(OSError):
- breaker_path.unlink()
+ 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.")
+ time.sleep(self.LOCK_POLL_SECONDS)
@staticmethod
def _replace_file(*, path: Path, content: bytes) -> None:
diff --git a/pyrit/scenario/core/__init__.py b/pyrit/scenario/core/__init__.py
index 992393a906..5538465016 100644
--- a/pyrit/scenario/core/__init__.py
+++ b/pyrit/scenario/core/__init__.py
@@ -15,6 +15,12 @@
resolve_technique_factories,
resolve_technique_factories_for_techniques,
)
+ from pyrit.scenario.core._technique_tokens import (
+ CONVERTER_MODIFIER_PREFIX,
+ TechniqueToken,
+ converter_name_from_modifier,
+ parse_technique_token,
+ )
from pyrit.scenario.core.atomic_attack import AtomicAttack
from pyrit.scenario.core.attack_technique import AttackTechnique
from pyrit.scenario.core.attack_technique_factory import AttackTechniqueFactory, ScorerOverridePolicy
@@ -41,6 +47,7 @@
"AttackTechnique": "pyrit.scenario.core.attack_technique",
"AttackTechniqueFactory": "pyrit.scenario.core.attack_technique_factory",
"BaselineAttackPolicy": "pyrit.scenario.core.scenario",
+ "CONVERTER_MODIFIER_PREFIX": "pyrit.scenario.core._technique_tokens",
"CompoundDatasetAttackConfiguration": "pyrit.scenario.core.dataset_configuration",
"DatasetAttackConfiguration": "pyrit.scenario.core.dataset_configuration",
"DatasetConfiguration": "pyrit.scenario.core.dataset_configuration",
@@ -54,9 +61,12 @@
"ScenarioTechnique": "pyrit.scenario.core.scenario_technique",
"ScorerOverridePolicy": "pyrit.scenario.core.attack_technique_factory",
"TechniqueResolutionError": "pyrit.scenario.core._technique_resolution",
+ "TechniqueToken": "pyrit.scenario.core._technique_tokens",
+ "converter_name_from_modifier": "pyrit.scenario.core._technique_tokens",
"get_default_scorer_target": "pyrit.scenario.core.scenario_target_defaults",
"get_default_adversarial_target": "pyrit.scenario.core.scenario_target_defaults",
"override_default_adversarial_target": "pyrit.scenario.core.scenario_target_defaults",
+ "parse_technique_token": "pyrit.scenario.core._technique_tokens",
"resolve_technique_factories": "pyrit.scenario.core._technique_resolution",
"resolve_technique_factories_for_techniques": "pyrit.scenario.core._technique_resolution",
}
diff --git a/pyrit/scenario/core/_technique_tokens.py b/pyrit/scenario/core/_technique_tokens.py
new file mode 100644
index 0000000000..14be3d4881
--- /dev/null
+++ b/pyrit/scenario/core/_technique_tokens.py
@@ -0,0 +1,59 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT license.
+
+"""
+Grammar for the technique tokens that scenario runs and presets accept.
+
+A token is a technique name optionally followed by colon-separated modifiers, as in
+``role_play:converter.translation_spanish``. The grammar is shared rather than owned by
+the launch path because every caller that reads a stored token has to agree on where the
+technique name ends: a validator that compares a whole token against the scenario's
+technique names rejects a token the launch path resolves successfully, and the operator
+is told the preset is broken when it is not.
+
+Only the split lives here. Turning a converter name into a converter instance needs the
+``ConverterRegistry``, which scenarios do not depend on.
+"""
+
+from __future__ import annotations
+
+from typing import NamedTuple
+
+CONVERTER_MODIFIER_PREFIX = "converter."
+
+
+class TechniqueToken(NamedTuple):
+ """One parsed technique token."""
+
+ base_name: str
+ modifiers: tuple[str, ...]
+
+
+def parse_technique_token(token: str) -> TechniqueToken:
+ """
+ Split one technique token into its technique name and its modifiers.
+
+ Args:
+ token (str): The token as written in a run request or a stored preset.
+
+ Returns:
+ TechniqueToken: The technique name and the modifiers that follow it, in token order.
+ """
+ base_name, _, remainder = token.partition(":")
+ modifiers = tuple(modifier for modifier in remainder.split(":") if modifier)
+ return TechniqueToken(base_name=base_name, modifiers=modifiers)
+
+
+def converter_name_from_modifier(modifier: str) -> str | None:
+ """
+ Read the converter name out of a modifier.
+
+ Args:
+ modifier (str): One modifier from a technique token.
+
+ Returns:
+ str | None: The converter name, or None when the modifier is not a converter modifier.
+ """
+ if not modifier.startswith(CONVERTER_MODIFIER_PREFIX):
+ return None
+ return modifier[len(CONVERTER_MODIFIER_PREFIX) :]
diff --git a/tests/unit/backend/test_scenario_preset_routes.py b/tests/unit/backend/test_scenario_preset_routes.py
index 4196b29003..b2a0a52934 100644
--- a/tests/unit/backend/test_scenario_preset_routes.py
+++ b/tests/unit/backend/test_scenario_preset_routes.py
@@ -286,3 +286,34 @@ def test_resolve_requires_a_target(self, client: TestClient, service: MagicMock)
response = client.post(f"/api/scenario-presets/{PRESET_NAME}/resolve", json={})
assert response.status_code == 422
+
+ def test_resolve_accepts_the_version_the_caller_read(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response(version="v1"))
+ service.resolve_run_request = ScenarioPresetService.resolve_run_request
+
+ response = client.post(
+ f"/api/scenario-presets/{PRESET_NAME}/resolve",
+ json={"target_name": "gpt4", "expected_version": "v1"},
+ )
+
+ assert response.status_code == 200
+
+ def test_a_preset_edited_after_it_was_read_is_a_conflict(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response(version="v2"))
+ service.resolve_run_request = ScenarioPresetService.resolve_run_request
+
+ response = client.post(
+ f"/api/scenario-presets/{PRESET_NAME}/resolve",
+ json={"target_name": "gpt4", "expected_version": "v1"},
+ )
+
+ assert response.status_code == 409
+ assert PRESET_NAME in response.json()["detail"]
+
+ def test_an_omitted_version_resolves_whatever_is_stored(self, client: TestClient, service: MagicMock) -> None:
+ service.get_preset_async = AsyncMock(return_value=_response(version="v9"))
+ service.resolve_run_request = ScenarioPresetService.resolve_run_request
+
+ response = client.post(f"/api/scenario-presets/{PRESET_NAME}/resolve", json={"target_name": "gpt4"})
+
+ assert response.status_code == 200
diff --git a/tests/unit/backend/test_scenario_preset_service.py b/tests/unit/backend/test_scenario_preset_service.py
index a20ead70f1..756c0b989a 100644
--- a/tests/unit/backend/test_scenario_preset_service.py
+++ b/tests/unit/backend/test_scenario_preset_service.py
@@ -4,7 +4,7 @@
"""Tests for the scenario preset service."""
from pathlib import Path
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -71,6 +71,15 @@ def _preset(name: str = "quick_scan", **overrides: object) -> ScenarioPreset:
return ScenarioPreset(**fields) # type: ignore[arg-type]
+def _converter_registry(*registered_names: str) -> MagicMock:
+ """Patch target standing in for the converter instances this deployment registered."""
+ registry = MagicMock()
+ registry.get_registry_singleton.return_value.instances.get.side_effect = (
+ lambda name: MagicMock() if name in registered_names else None
+ )
+ return registry
+
+
class TestPresetCrud:
"""CRUD behavior over the configured storage source."""
@@ -203,6 +212,41 @@ async def test_an_aggregate_technique_is_not_reported_as_unknown(self, service:
assert saved.issues == []
+ async def test_a_converter_modifier_does_not_make_a_known_technique_look_unknown(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ with patch(
+ "pyrit.backend.services.scenario_preset_service.ConverterRegistry",
+ _converter_registry("translation_spanish"),
+ ):
+ saved = await service.save_preset_async(
+ preset=_preset(techniques=["crescendo:converter.translation_spanish"]), expected_version=None
+ )
+
+ assert saved.issues == []
+
+ async def test_a_converter_this_deployment_has_not_registered_is_reported(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ with patch("pyrit.backend.services.scenario_preset_service.ConverterRegistry", _converter_registry()):
+ saved = await service.save_preset_async(
+ preset=_preset(techniques=["crescendo:converter.translation_spanish"]), expected_version=None
+ )
+
+ assert [issue.field for issue in saved.issues] == ["techniques"]
+ assert "translation_spanish" in saved.issues[0].message
+
+ async def test_a_modifier_the_launch_path_cannot_parse_is_reported(
+ self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
+ ) -> None:
+ with patch("pyrit.backend.services.scenario_preset_service.ConverterRegistry", _converter_registry()):
+ saved = await service.save_preset_async(
+ preset=_preset(techniques=["crescendo:scorer.refusal"]), expected_version=None
+ )
+
+ assert [issue.field for issue in saved.issues] == ["techniques"]
+ assert "scorer.refusal" in saved.issues[0].message
+
async def test_undeclared_scenario_parameters_are_reported(
self, service: ScenarioPresetService, registered_scenario: RegisteredScenario
) -> None:
diff --git a/tests/unit/registry/test_scenario_preset_storage.py b/tests/unit/registry/test_scenario_preset_storage.py
index 4945408802..c87c7fcbbb 100644
--- a/tests/unit/registry/test_scenario_preset_storage.py
+++ b/tests/unit/registry/test_scenario_preset_storage.py
@@ -6,7 +6,6 @@
import hashlib
import json
import os
-import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -14,6 +13,7 @@
import pytest
from pyrit.models.catalog.scenario_preset import ScenarioPreset
+from pyrit.registry.file_document_storage import _release_exclusive_lock, _try_acquire_exclusive_lock
from pyrit.registry.scenario_preset_storage import ScenarioPresetConflictError, ScenarioPresetStorage
@@ -468,7 +468,7 @@ def fail(source: object, target: object) -> None:
assert loaded is not None
assert loaded.preset.description == "first"
assert loaded.version == first.version
- assert sorted(path.name for path in tmp_path.iterdir()) == ["nightly.json"]
+ assert list(tmp_path.glob("*.tmp")) == []
def test_a_reader_sees_the_previous_document_until_the_write_completes(tmp_path: Path) -> None:
@@ -554,23 +554,29 @@ def replace_once_a_competitor_has_tried(*, path: Path, content: bytes) -> None:
def test_a_completed_save_releases_its_lock(tmp_path: Path) -> None:
"""Test that a save leaves nothing behind that would block the next writer."""
storage = ScenarioPresetStorage(source=str(tmp_path))
+ first = storage.save_preset(preset=_make_preset(description="first"), expected_version=None)
- storage.save_preset(preset=_make_preset(), expected_version=None)
+ with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
+ saved = storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
- assert sorted(path.name for path in tmp_path.iterdir()) == ["nightly.json"]
+ assert saved.preset.description == "second"
def test_a_lock_held_by_a_live_writer_is_respected(tmp_path: Path) -> None:
- """Test that a lock younger than the stale window is waited for rather than stolen."""
+ """Test that a writer still holding the lock is waited for rather than overridden."""
storage = ScenarioPresetStorage(source=str(tmp_path))
held_lock = tmp_path / ".nightly.json.lock"
- held_lock.write_text("4242", encoding="utf-8")
+ descriptor = os.open(held_lock, os.O_CREAT | os.O_RDWR)
+ assert _try_acquire_exclusive_lock(descriptor)
- with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
- with pytest.raises(TimeoutError, match="nightly.json.lock"):
- storage.save_preset(preset=_make_preset(), expected_version=None)
+ try:
+ with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
+ with pytest.raises(TimeoutError, match="nightly.json.lock"):
+ storage.save_preset(preset=_make_preset(), expected_version=None)
+ finally:
+ _release_exclusive_lock(descriptor)
+ os.close(descriptor)
- assert held_lock.read_text(encoding="utf-8") == "4242"
assert storage.load_preset("nightly") is None
@@ -578,51 +584,13 @@ def test_a_lock_left_by_a_dead_writer_is_reclaimed(tmp_path: Path) -> None:
"""Test that a writer that died holding the lock does not make a preset permanently unwritable."""
storage = ScenarioPresetStorage(source=str(tmp_path))
first = storage.save_preset(preset=_make_preset(description="first"), expected_version=None)
- abandoned_lock = tmp_path / ".nightly.json.lock"
- abandoned_lock.write_text("4242", encoding="utf-8")
- abandoned = time.time() - (ScenarioPresetStorage.LOCK_STALE_SECONDS + 60)
- os.utime(abandoned_lock, (abandoned, abandoned))
-
- saved = storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
-
- assert saved.preset.description == "second"
- assert sorted(path.name for path in tmp_path.iterdir()) == ["nightly.json"]
-
-
-def test_a_stale_lock_is_left_alone_while_another_writer_breaks_it(tmp_path: Path) -> None:
- """Test that only one writer may break a given stale lock, so two cannot both enter the write."""
- storage = ScenarioPresetStorage(source=str(tmp_path))
- abandoned_lock = tmp_path / ".nightly.json.lock"
- abandoned_lock.write_text("4242", encoding="utf-8")
- abandoned = time.time() - (ScenarioPresetStorage.LOCK_STALE_SECONDS + 60)
- os.utime(abandoned_lock, (abandoned, abandoned))
- breaker = tmp_path / ".nightly.json.lock.breaking"
- breaker.write_text("5353", encoding="utf-8")
+ # A writer that died leaves the file behind but not the lock, which the kernel released.
+ (tmp_path / ".nightly.json.lock").write_text("4242", encoding="utf-8")
with patch.object(ScenarioPresetStorage, "LOCK_TIMEOUT_SECONDS", 0.1):
- with pytest.raises(TimeoutError, match="nightly.json.lock"):
- storage.save_preset(preset=_make_preset(), expected_version=None)
-
- assert abandoned_lock.read_text(encoding="utf-8") == "4242"
- assert breaker.read_text(encoding="utf-8") == "5353"
- assert storage.load_preset("nightly") is None
+ saved = storage.save_preset(preset=_make_preset(description="second"), expected_version=first.version)
-
-def test_a_breaker_left_by_a_dead_writer_does_not_block_reclamation(tmp_path: Path) -> None:
- """Test that a writer that died while breaking a lock does not make a preset permanently unwritable."""
- storage = ScenarioPresetStorage(source=str(tmp_path))
- abandoned_lock = tmp_path / ".nightly.json.lock"
- abandoned_lock.write_text("4242", encoding="utf-8")
- breaker = tmp_path / ".nightly.json.lock.breaking"
- breaker.write_text("5353", encoding="utf-8")
- abandoned = time.time() - (ScenarioPresetStorage.LOCK_STALE_SECONDS + 60)
- for stranded in (abandoned_lock, breaker):
- os.utime(stranded, (abandoned, abandoned))
-
- saved = storage.save_preset(preset=_make_preset(description="recovered"), expected_version=None)
-
- assert saved.preset.description == "recovered"
- assert sorted(path.name for path in tmp_path.iterdir()) == ["nightly.json"]
+ assert saved.preset.description == "second"
def test_lock_files_are_not_listed_as_presets(tmp_path: Path) -> None:
From 8959c7b2d0ff84846c02a833059b372255a8280e Mon Sep 17 00:00:00 2001
From: Copilot <223556219+Copilot@users.noreply.github.com>
Date: Thu, 8 Oct 2026 15:50:37 -0400
Subject: [PATCH 15/16] Move scenario presets into the registry, with author
and run size
A preset is a stored, reusable configuration, so it belongs alongside
targets and converters rather than under the scanner. Adds a third
registry tab, replaces the card list with a table, and gives each row
the two facts an operator wants before launching: how big the run is
and who saved it.
- Routes move to /registry/scenario-presets. /scanner/presets and its
editor URLs redirect so existing bookmarks keep working.
- The library paints unsized rows first, then layers in run sizes,
mirroring ScenarioCatalog. A failed sizing pass degrades to a warning
rather than an empty table.
- POST stamps an optional author from the signed-in user, but only when
the body omits one, so importing a preset authored elsewhere keeps its
provenance. The editor round-trips it on PUT. The field is descriptive
and never used for authorization.
- GET /scenario_presets takes include_estimates. Run sizes are computed
from each preset's own techniques, datasets and limits.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
frontend/src/App.test.tsx | 66 +++++
frontend/src/App.tsx | 35 ++-
.../Registry/RegistryLayout.test.tsx | 22 +-
.../components/Registry/RegistryLayout.tsx | 17 +-
.../ScenarioPresetEditor.test.tsx | 16 +-
.../ScenarioPresetLibrary.styles.ts | 21 +-
.../ScenarioPresetLibrary.test.tsx | 103 +++++++-
.../ScenarioPresets/ScenarioPresetLibrary.tsx | 232 ++++++++++++------
.../ScenarioPresets/presetRoutes.test.ts | 17 +-
.../ScenarioPresets/presetRoutes.ts | 15 +-
.../scenarioPresetForm.test.ts | 21 ++
.../ScenarioPresets/scenarioPresetForm.ts | 5 +
.../components/Scenarios/ScenarioCatalog.tsx | 14 +-
frontend/src/services/api.ts | 9 +-
frontend/src/types/index.ts | 8 +
pyrit/backend/middleware/auth.py | 19 ++
pyrit/backend/models/scenario_presets.py | 9 +
pyrit/backend/routes/scenario_presets.py | 28 ++-
.../services/scenario_preset_service.py | 52 +++-
pyrit/models/catalog/scenario_preset.py | 10 +
.../backend/test_scenario_preset_routes.py | 82 ++++++-
.../backend/test_scenario_preset_service.py | 118 ++++++++-
22 files changed, 752 insertions(+), 167 deletions(-)
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index f86bddcfbf..6756d0deed 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -395,6 +395,32 @@ jest.mock("./components/Scenarios/ScenarioCatalog", () => {
};
});
+jest.mock("./components/ScenarioPresets/ScenarioPresetLibrary", () => {
+ const MockScenarioPresetLibrary = () =>
;
+ MockScenarioPresetLibrary.displayName = "MockScenarioPresetLibrary";
+ return {
+ __esModule: true,
+ default: MockScenarioPresetLibrary,
+ };
+});
+
+jest.mock("./components/ScenarioPresets/ScenarioPresetEditor", () => {
+ const { useParams } = jest.requireActual("react-router");
+ const MockScenarioPresetEditor = ({ mode }: { mode: string }) => {
+ const { presetName } = useParams();
+ return (
+
+ {presetName ?? ""}
+
+ );
+ };
+ MockScenarioPresetEditor.displayName = "MockScenarioPresetEditor";
+ return {
+ __esModule: true,
+ default: MockScenarioPresetEditor,
+ };
+});
+
jest.mock("./components/Scenarios/ScenarioDetail", () => {
const MockScenarioDetail = ({
defaultObjectiveTarget,
@@ -604,6 +630,46 @@ describe("App", () => {
expect(screen.getByLabelText("Current URL")).toHaveTextContent(/^\/chat$/);
});
+ it("renders the preset library as a registry section", () => {
+ renderApp("/registry/scenario-presets");
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "registry"
+ );
+ expect(screen.getByTestId("scenario-preset-library")).toBeInTheDocument();
+ });
+
+ it("redirects a bookmarked /scanner/presets to the registry section", async () => {
+ render(
+
+
+
+
+ );
+
+ expect(await screen.findByTestId("scenario-preset-library")).toBeInTheDocument();
+ expect(screen.getByLabelText("Current URL")).toHaveTextContent(
+ /^\/registry\/scenario-presets$/
+ );
+ });
+
+ it("redirects a bookmarked preset editor URL, keeping the preset it named", async () => {
+ render(
+
+
+
+
+ );
+
+ const editor = await screen.findByTestId("scenario-preset-editor");
+ expect(editor).toHaveAttribute("data-mode", "edit");
+ expect(editor).toHaveTextContent("nightly_probe");
+ expect(screen.getByLabelText("Current URL")).toHaveTextContent(
+ /^\/registry\/scenario-presets\/nightly_probe\/edit$/
+ );
+ });
+
it("renders the converter registry from its direct URL", async () => {
renderApp("/registry/converters");
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 769f915c89..54dfe849fa 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -23,6 +23,7 @@ import ScenarioDetail from './components/Scenarios/ScenarioDetail'
import ScenarioRunPage from './components/Scenarios/ScenarioRunPage'
import ScenarioPresetEditor from './components/ScenarioPresets/ScenarioPresetEditor'
import ScenarioPresetLibrary from './components/ScenarioPresets/ScenarioPresetLibrary'
+import { presetEditorRoutePath } from './components/ScenarioPresets/presetRoutes'
import FeedbackDialog from './components/Feedback/FeedbackDialog'
import type { HistoryFilters } from './components/History/historyFilters'
import { ConnectionBanner } from './components/ConnectionBanner'
@@ -104,6 +105,11 @@ function LegacyScenarioRunRedirect() {
return
}
+function LegacyPresetEditorRedirect() {
+ const { presetName } = useParams<{ presetName: string }>()
+ return
+}
+
function LegacyScenarioHistoryRedirect() {
const location = useLocation()
return
@@ -732,22 +738,25 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) {
}
/>
} />
+
+ }
+ />
+
} />
+
} />
} />
} />
-
- }
- />
-
} />
-
} />
+
} />
+
} />
+
} />
}>
Target registry content } />