Skip to content

Commit ebeee48

Browse files
committed
FEAT: add ScenarioPresetRegistry owning preset storage and validation
Prerequisite carved out from under #2931 so the durable-definition layer lands before the backend service and Registry tab UI stack on top of it. ScenarioPresetRegistry is the single owner of preset storage, validation, and version-checked resolution. It reads through to storage on every call and is deliberately not an instance cache: the version guarding every write is sha256 of the stored bytes, multi-worker and multi-replica are supported topologies, resolve_preset must compare a client-supplied expected_version against storage rather than against itself, and get_preset_version exists to reach documents that cannot be parsed at all. check_preset is the only validation path, replacing the service's ad-hoc _unknown_* helpers. It reads ScenarioMetadata off ScenarioRegistry directly rather than through the backend scenario service, so it stays synchronous and keeps pyrit.registry free of any pyrit.backend import. _delete_document reports whether the delete removed anything, taking the answer from the delete itself on both the local and blob paths, so delete reporting stays correct when two callers race to remove the same document. A parse-only startup pass reports unusable documents in the boot log without building scenario metadata, which would instantiate every registered scenario class and would raise at that point in startup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 3f729ac commit ebeee48

19 files changed

Lines changed: 3033 additions & 135 deletions

‎.pyrit_conf_example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ allow_custom_initializers: false
140140
# ~/.pyrit/custom_initializers.
141141
# custom_initializers_source: https://account.blob.core.windows.net/container/custom_initializers
142142

143+
# Optional storage for scenario presets, the saved scenario configurations the
144+
# scanner UI launches from. This may be a local directory or an Azure Blob
145+
# container URI with an optional blob prefix. Container URIs may include a SAS;
146+
# otherwise DefaultAzureCredential is used. Defaults to ~/.pyrit/scenario_presets.
147+
# scenario_presets_source: https://account.blob.core.windows.net/container/scenario_presets
148+
143149
# Local Backend Server
144150
# --------------------
145151
# Client settings used by pyrit_scan when connecting to or launching a backend.

‎doc/getting_started/pyrit_conf.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,16 @@ custom_initializers_source: https://account.blob.core.windows.net/pyrit-storage/
164164

165165
With this configuration, PyRIT reads and writes scripts directly under the `custom-initializers/` prefix in the `pyrit-storage` container. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/custom_initializers`.
166166

167+
### `scenario_presets_source`
168+
169+
Stores scenario presets in a local directory or Azure Blob container. An Azure URI may include a blob-name prefix, which behaves like a folder:
170+
171+
```yaml
172+
scenario_presets_source: https://account.blob.core.windows.net/pyrit-storage/scenario-presets
173+
```
174+
175+
Point several deployments at the same container to share presets between them. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/scenario_presets`.
176+
167177
### `initialization_scripts`
168178

169179
Local paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory.

‎pyrit/backend/services/runtime_lifecycle.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from pyrit.common.path import CONFIGURATION_DIRECTORY_PATH
2424
from pyrit.memory import CentralMemory
25-
from pyrit.registry import InitializerRegistry
25+
from pyrit.registry import InitializerRegistry, ScenarioPresetRegistry
2626
from pyrit.setup.configuration_loader import ConfigurationLoader
2727
from pyrit.setup.environment_loading import resolve_environment_async
2828
from pyrit.setup.initialization import validate_reinitialization_memory
@@ -103,6 +103,12 @@ async def _management_async(self, config: ConfigurationLoader) -> None:
103103
self.app.state.allow_custom_initializers = config.allow_custom_initializers
104104
registry = await asyncio.to_thread(InitializerRegistry.get_registry_singleton)
105105
registry.configure_custom_scripts_source(config.custom_initializers_source)
106+
preset_registry = ScenarioPresetRegistry.get_registry_singleton()
107+
# Constructing the storage creates the default preset directory when no source is
108+
# configured, and the validation pass reads every stored document, so both stay
109+
# off the event loop.
110+
await asyncio.to_thread(preset_registry.configure_source, config.scenario_presets_source)
111+
await asyncio.to_thread(preset_registry.validate_stored_presets)
106112

107113
def _publish(self, config: ConfigurationLoader) -> None:
108114
self.app.state.configured_initializers = [

‎pyrit/models/__init__.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@
4646
AttackStats,
4747
)
4848
from pyrit.models.catalog import (
49+
PresetIssue,
4950
ScenarioDatasetSizeCap,
5051
ScenarioDatasetSummary,
5152
ScenarioDefaultRunSizeEstimate,
53+
ScenarioPreset,
5254
ScenarioRunListItem,
5355
ScenarioRunSizeComponent,
5456
ScenarioRunSizeEstimate,
@@ -57,6 +59,7 @@
5759
ScenarioRunSizeEstimateStatus,
5860
ScenarioRunSizeFactor,
5961
ScenarioTechniqueSummary,
62+
StoredPreset,
6063
)
6164
from pyrit.models.conversation_stats import ConversationStats
6265
from pyrit.models.embeddings import EmbeddingData, EmbeddingResponse, EmbeddingSupport, EmbeddingUsageInformation
@@ -367,6 +370,7 @@
367370
"ObjectiveTargetEvaluationIdentifier": "pyrit.models.identifiers",
368371
"Parameter": "pyrit.models.parameter",
369372
"ParameterDestination": "pyrit.models.parameter",
373+
"PresetIssue": "pyrit.models.catalog",
370374
"PromptDataType": "pyrit.models.literals",
371375
"PromptResponseError": "pyrit.models.literals",
372376
"QuestionAnsweringDataset": "pyrit.models.question_answering",
@@ -398,6 +402,7 @@
398402
"ScenarioDatasetSizeCap": "pyrit.models.catalog",
399403
"ScenarioDatasetSummary": "pyrit.models.catalog",
400404
"ScenarioDefaultRunSizeEstimate": "pyrit.models.catalog",
405+
"ScenarioPreset": "pyrit.models.catalog",
401406
"ScenarioRunListItem": "pyrit.models.catalog",
402407
"ScenarioRunSizeComponent": "pyrit.models.catalog",
403408
"ScenarioRunSizeEstimate": "pyrit.models.catalog",
@@ -457,6 +462,7 @@
457462
"warn_prompt_path_deprecated": "pyrit.models.seeds",
458463
"snake_case_to_class_name": "pyrit.models.identifiers",
459464
"sort_message_pieces": "pyrit.models.messages.message_piece",
465+
"StoredPreset": "pyrit.models.catalog",
460466
"StrategyResult": "pyrit.models.results.strategy_result",
461467
"StrategyResultT": "pyrit.models.results.strategy_result",
462468
"StructuredParameterValue": "pyrit.models.parameter",

‎pyrit/models/catalog/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,22 @@
3838
ScenarioRunSummary,
3939
ScenarioTechniqueSummary,
4040
)
41+
from pyrit.models.catalog.scenario_preset import PresetIssue, ScenarioPreset, StoredPreset
4142
from pyrit.models.catalog.scorer import ScorerInstance
4243
from pyrit.models.catalog.target import TargetInstance
4344

4445
_LAZY_EXPORTS: dict[str, str] = {
4546
"AttackErrorSummary": "pyrit.models.catalog.scenario",
4647
"AttackRetrySummary": "pyrit.models.catalog.scenario",
48+
"PresetIssue": "pyrit.models.catalog.scenario_preset",
4749
"RegisteredInitializer": "pyrit.models.catalog.initializer",
4850
"ScorerInstance": "pyrit.models.catalog.scorer",
4951
"RegisteredScenario": "pyrit.models.catalog.scenario",
5052
"RunScenarioRequest": "pyrit.models.catalog.scenario",
5153
"ScenarioDatasetSizeCap": "pyrit.models.catalog.scenario",
5254
"ScenarioDatasetSummary": "pyrit.models.catalog.scenario",
5355
"ScenarioDefaultRunSizeEstimate": "pyrit.models.catalog.scenario",
56+
"ScenarioPreset": "pyrit.models.catalog.scenario_preset",
5457
"ScenarioRunListItem": "pyrit.models.catalog.scenario",
5558
"ScenarioRunSizeComponent": "pyrit.models.catalog.scenario",
5659
"ScenarioRunSizeEstimate": "pyrit.models.catalog.scenario",
@@ -60,6 +63,7 @@
6063
"ScenarioRunSizeFactor": "pyrit.models.catalog.scenario",
6164
"ScenarioRunSummary": "pyrit.models.catalog.scenario",
6265
"ScenarioTechniqueSummary": "pyrit.models.catalog.scenario",
66+
"StoredPreset": "pyrit.models.catalog.scenario_preset",
6367
"TargetInstance": "pyrit.models.catalog.target",
6468
}
6569

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
Scenario preset model.
6+
7+
A scenario preset is a named, reusable, target-agnostic answer to *what to test*:
8+
a scenario plus the scenario-owned configuration fields. It deliberately excludes
9+
everything environment-specific — target, concurrency, retries, and labels — which
10+
belongs to a launch rather than to the preset. That split is what lets one preset
11+
run unchanged against dev, staging, and production.
12+
13+
Every configurable field is tri-state. ``None`` means "not set by this preset, use
14+
the scenario's own default", which is distinct from an explicit value that happens
15+
to equal that default. Collapsing the two would silently pin a scenario default at
16+
the moment a preset was saved and stop it tracking upstream changes.
17+
18+
``PresetIssue`` lives here rather than with the REST envelopes because checking a
19+
preset against the live registry is the registry's job, not the wire layer's, and
20+
both the registry and its callers need to name the result type.
21+
"""
22+
23+
from pydantic import BaseModel, ConfigDict, Field, field_validator
24+
25+
from pyrit.models.catalog.scenario import (
26+
_RequestFilters,
27+
_RequestName,
28+
_RequestNames,
29+
_RequestParams,
30+
_RequestTechniques,
31+
_validate_dataset_filter_mapping,
32+
)
33+
from pyrit.models.identifiers.class_name_utils import validate_registry_name
34+
35+
36+
class ScenarioPreset(BaseModel):
37+
"""
38+
A named, reusable, target-agnostic scenario configuration.
39+
40+
Presets own *what to test*. A launch owns *how and where* — the target,
41+
concurrency, retries, and labels — so those fields are absent here by design
42+
and the two sets are combined by union rather than by precedence.
43+
44+
Unknown keys are rejected rather than ignored. These documents are hand-edited, and
45+
because an absent field is a meaningful state, a misspelled key would otherwise parse
46+
cleanly and leave the preset silently testing the scenario default instead of the
47+
value the file plainly states.
48+
49+
``description`` and ``author`` describe the preset rather than configure the run, so
50+
they are not tri-state: an absent value means unknown, not "use the scenario default".
51+
``author`` is filled in from the signed-in user when a preset is created through the
52+
API and is informational only — these documents are hand-editable, so it records
53+
provenance rather than proving it.
54+
"""
55+
56+
model_config = ConfigDict(extra="forbid")
57+
58+
name: str = Field(..., description="Unique preset name, used as the storage key and as the reference from scans")
59+
scenario_name: _RequestName = Field(..., min_length=1, description="Registered scenario this preset configures")
60+
description: str | None = Field(None, description="Human-readable summary of what this preset tests")
61+
author: str | None = Field(
62+
None,
63+
description="Who created this preset; descriptive only, never used to authorize a read or a write",
64+
)
65+
techniques: _RequestTechniques | None = Field(None, description="Technique names; None uses the scenario default")
66+
dataset_names: _RequestNames | None = Field(None, description="Dataset names; None uses the scenario default")
67+
max_dataset_size: int | None = Field(None, ge=1, description="Maximum selected logical seed groups")
68+
dataset_filters: _RequestFilters | None = Field(
69+
None,
70+
description="Dataset seed filters keyed by field. Accepted keys: harm_categories, data_types.",
71+
)
72+
include_baseline: bool | None = Field(None, description="Override the scenario baseline default")
73+
scenario_params: _RequestParams | None = Field(
74+
None, description="Scenario-declared parameters such as template names and attempt counts"
75+
)
76+
77+
@field_validator("name")
78+
@classmethod
79+
def _validate_name(cls, value: str) -> str:
80+
"""
81+
Validate that the preset name is a legal registry name.
82+
83+
Returns:
84+
str: The validated name.
85+
86+
Raises:
87+
ValueError: If the name is not a legal registry name.
88+
"""
89+
validate_registry_name(value)
90+
return value
91+
92+
@field_validator("dataset_filters")
93+
@classmethod
94+
def _validate_dataset_filters(cls, value: dict[str, list[str]] | None) -> dict[str, list[str]] | None:
95+
"""
96+
Validate dataset filters against the shared allow-list.
97+
98+
Returns:
99+
dict[str, list[str]] | None: Validated filters.
100+
"""
101+
return _validate_dataset_filter_mapping(value)
102+
103+
104+
class StoredPreset(BaseModel):
105+
"""
106+
A preset together with the version of the document it was read from.
107+
108+
The version describes the *stored document*, not the preset, so it is paired with
109+
the preset rather than carried as a field on it. Storing the token inside the file
110+
it guards would let a hand-edit rewrite the very value used to detect that edit.
111+
112+
The token is opaque. Callers round-trip it from a read back into a write and must
113+
not parse, compare, or order it.
114+
"""
115+
116+
preset: ScenarioPreset = Field(..., description="The stored preset")
117+
version: str = Field(..., description="Opaque version of the document this preset was read from")
118+
119+
120+
class PresetIssue(BaseModel):
121+
"""
122+
One advisory problem found while checking a preset against the live registry.
123+
124+
Issues are reported, never enforced. A preset that names a scenario this
125+
deployment has not registered is still a valid preset; it simply cannot run
126+
here yet. Blocking the save would make presets un-portable between
127+
deployments, which is the one property they exist to have.
128+
"""
129+
130+
field: str = Field(..., description="Preset field the issue applies to, e.g. 'scenario_name' or 'techniques'")
131+
message: str = Field(..., description="Human-readable description of why the reference does not resolve")

‎pyrit/registry/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
)
3434
from pyrit.registry.registry import InstanceHoldingRegistry, ParamBagRegistry, Registry
3535
from pyrit.registry.registry_metadata import RegistryMetadata
36+
from pyrit.registry.scenario_preset_registry import ScenarioPresetRegistry
37+
from pyrit.registry.scenario_preset_storage import ScenarioPresetConflictError, ScenarioPresetStorage
3638
from pyrit.registry.tag_query import TagQuery
3739

3840
_LAZY_EXPORTS: dict[str, str | tuple[str, str | None]] = {
@@ -53,6 +55,9 @@
5355
"InitializerRegistry": "pyrit.registry.components",
5456
"RegistryEntry": "pyrit.registry.instance_registry",
5557
"ScenarioMetadata": "pyrit.registry.components",
58+
"ScenarioPresetConflictError": "pyrit.registry.scenario_preset_storage",
59+
"ScenarioPresetRegistry": "pyrit.registry.scenario_preset_registry",
60+
"ScenarioPresetStorage": "pyrit.registry.scenario_preset_storage",
5661
"ScenarioRegistry": "pyrit.registry.components",
5762
"ScorerRegistry": "pyrit.registry.components",
5863
"ScorerMetadata": "pyrit.registry.components",

0 commit comments

Comments
 (0)