Skip to content

Commit 6be3a8a

Browse files
rlundeen2Copilot
andauthored
MAINT: Rapid response Scenario (microsoft#1622)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5ab0bbc commit 6be3a8a

46 files changed

Lines changed: 2845 additions & 32918 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env_example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ AZURE_OPENAI_GPT4O_UNSAFE_CHAT_KEY2="xxxxx"
7474
AZURE_OPENAI_GPT4O_UNSAFE_CHAT_MODEL2="deployment-name"
7575
AZURE_OPENAI_GPT4O_UNSAFE_CHAT_UNDERLYING_MODEL2=""
7676

77+
# Adversarial chat target (used by scenario attack techniques, e.g. role-play, TAP)
78+
ADVERSARIAL_CHAT_ENDPOINT="https://xxxxx.openai.azure.com/openai/v1"
79+
ADVERSARIAL_CHAT_KEY="xxxxx"
80+
ADVERSARIAL_CHAT_MODEL="deployment-name"
81+
7782
AZURE_FOUNDRY_DEEPSEEK_ENDPOINT="https://xxxxx.eastus2.models.ai.azure.com"
7883
AZURE_FOUNDRY_DEEPSEEK_KEY="xxxxx"
7984
AZURE_FOUNDRY_DEEPSEEK_MODEL=""

.github/instructions/scenarios.instructions.md

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Scenarios orchestrate multi-attack security testing campaigns. Each scenario gro
1111
All scenarios inherit from `Scenario` (ABC) and must:
1212

1313
1. **Define `VERSION`** as a class constant (increment on breaking changes)
14-
2. **Implement four abstract methods:**
14+
2. **Implement three abstract methods:**
1515

1616
```python
1717
class MyScenario(Scenario):
@@ -28,11 +28,12 @@ class MyScenario(Scenario):
2828
@classmethod
2929
def default_dataset_config(cls) -> DatasetConfiguration:
3030
return DatasetConfiguration(dataset_names=["my_dataset"])
31-
32-
async def _get_atomic_attacks_async(self) -> list[AtomicAttack]:
33-
...
3431
```
3532

33+
3. **Optionally override `_get_atomic_attacks_async()`** — the base class provides a default
34+
that uses the factory/registry pattern (see "AtomicAttack Construction" below).
35+
Only override if your scenario needs custom attack construction logic.
36+
3637
## Constructor Pattern
3738

3839
```python
@@ -82,7 +83,7 @@ DatasetConfiguration(
8283
class MyDatasetConfiguration(DatasetConfiguration):
8384
def get_seed_groups(self) -> dict[str, list[SeedGroup]]:
8485
result = super().get_seed_groups()
85-
# Filter by selected strategies via self._scenario_composites
86+
# Filter by selected strategies via self._scenario_strategies
8687
return filtered_result
8788
```
8889

@@ -94,26 +95,66 @@ Options:
9495

9596
## Strategy Enum
9697

97-
Strategies should be selectable by an axis. E.g. it could be harm category or and attack type, but likely not both or it gets confusing.
98+
Strategy members should represent **attack techniques** — the *how* of an attack (e.g., prompt sending, role play, TAP). Datasets control *what* is tested (e.g., harm categories, compliance topics). Avoid mixing dataset/category selection into the strategy enum; use `DatasetConfiguration` and `--dataset-names` for that axis.
9899

99100
```python
100101
class MyStrategy(ScenarioStrategy):
101-
ALL = ("all", {"all"}) # Required aggregate
102-
EASY = ("easy", {"easy"})
102+
ALL = ("all", {"all"}) # Required aggregate
103+
DEFAULT = ("default", {"default"}) # Recommended default aggregate
104+
SINGLE_TURN = ("single_turn", {"single_turn"}) # Category aggregate
103105

104-
Base64 = ("base64", {"easy", "converter"})
105-
Crescendo = ("crescendo", {"difficult", "multi_turn"})
106+
PromptSending = ("prompt_sending", {"single_turn", "default"})
107+
RolePlay = ("role_play", {"single_turn"})
108+
ManyShot = ("many_shot", {"multi_turn", "default"})
106109

107110
@classmethod
108111
def get_aggregate_tags(cls) -> set[str]:
109-
return {"all", "easy", "difficult"}
112+
return {"all", "default", "single_turn", "multi_turn"}
110113
```
111114

112115
- `ALL` aggregate is always required
113116
- Each member: `NAME = ("string_value", {tag_set})`
114117
- Aggregates expand to all strategies matching their tag
115118

116-
## AtomicAttack Construction
119+
### `_build_display_group()` — Result Grouping
120+
121+
Override `_build_display_group()` on the `Scenario` base class to control how attack results are grouped for display:
122+
123+
```python
124+
def _build_display_group(self, *, technique_name: str, seed_group_name: str) -> str:
125+
# Default: group by technique name (most common)
126+
return technique_name
127+
128+
# Override examples:
129+
# Group by dataset/harm category: return seed_group_name
130+
# Cross-product: return f"{technique_name}_{seed_group_name}"
131+
```
132+
133+
Note: `atomic_attack_name` must remain unique per `AtomicAttack` for correct resume behaviour.
134+
`display_group` controls user-facing aggregation only.
135+
136+
## AtomicAttack Construction — Default Base Class Behaviour
137+
138+
The `Scenario` base class provides a default `_get_atomic_attacks_async()` that uses the
139+
factory/registry pattern. Scenarios that register their techniques via `_get_attack_technique_factories()`
140+
get atomic-attack construction **for free** — no override needed.
141+
142+
The default implementation:
143+
1. Calls `self._get_attack_technique_factories()` to get name→factory mapping
144+
2. Iterates over every (technique × dataset) pair from `self._dataset_config`
145+
3. Calls `factory.create()` with `objective_target` and conditional scorer override
146+
4. Uses `self._build_display_group()` for user-facing grouping
147+
5. Builds `AtomicAttack` with unique `atomic_attack_name` = `"{technique}_{dataset}"`
148+
149+
### Customization hooks (no need to override `_get_atomic_attacks_async`):
150+
- **`_get_attack_technique_factories()`** — override to add/remove/replace factories
151+
- **`_build_display_group()`** — override to change grouping (default: by technique)
152+
153+
### When to override `_get_atomic_attacks_async`:
154+
Only override when the scenario **cannot** use the factory/registry pattern — e.g., scenarios
155+
with custom composite logic, per-strategy converter stacks, or non-standard attack construction.
156+
157+
### Manual AtomicAttack construction (for overrides):
117158

118159
```python
119160
AtomicAttack(
@@ -134,7 +175,7 @@ New scenarios must be registered in `pyrit/scenario/__init__.py` as virtual pack
134175

135176
## Common Review Issues
136177

137-
- Accessing `self._objective_target` or `self._scenario_composites` before `initialize_async()`
178+
- Accessing `self._objective_target` or `self._scenario_strategies` before `initialize_async()`
138179
- Forgetting `@apply_defaults` on `__init__`
139180
- Empty `seed_groups` passed to `AtomicAttack`
140181
- Missing `VERSION` class constant

doc/code/scenarios/0_scenarios.ipynb

Lines changed: 43 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -53,32 +53,42 @@
5353
"\n",
5454
"### Required Components\n",
5555
"\n",
56-
"1. **Strategy Enum**: Create a `ScenarioStrategy` enum that defines the available strategies for your scenario.\n",
57-
" - Each enum member is defined as `(value, tags)` where value is a string and tags is a set of strings\n",
56+
"1. **Strategy Enum**: Create a `ScenarioStrategy` enum that defines the available attack techniques for your scenario.\n",
57+
" - Each enum member represents an **attack technique** (the *how* of an attack)\n",
58+
" - Each member is defined as `(value, tags)` where value is a string and tags is a set of strings\n",
5859
" - Include an `ALL` aggregate strategy that expands to all available strategies\n",
5960
" - Optionally override `_prepare_strategies()` for custom composition logic (see `FoundryComposite`)\n",
6061
"\n",
6162
"2. **Scenario Class**: Extend `Scenario` and implement these abstract methods:\n",
6263
" - `get_strategy_class()`: Return your strategy enum class\n",
6364
" - `get_default_strategy()`: Return the default strategy (typically `YourStrategy.ALL`)\n",
64-
" - `_get_atomic_attacks_async()`: Build and return a list of `AtomicAttack` instances\n",
65+
" - The base class provides a default `_get_atomic_attacks_async()` that uses the factory/registry\n",
66+
" pattern. Override it only if your scenario needs custom attack construction logic.\n",
6567
"\n",
66-
"3. **Constructor**: Use `@apply_defaults` decorator and call `super().__init__()` with scenario metadata:\n",
68+
"3. **Default Dataset**: Implement `default_dataset_config()` to specify the datasets your scenario uses out of the box.\n",
69+
" - Returns a `DatasetConfiguration` with one or more named datasets (e.g., `DatasetConfiguration(dataset_names=[\"my_dataset\"])`)\n",
70+
" - Users can override this at runtime via `--dataset-names` in the CLI or by passing a custom `dataset_config` programmatically\n",
71+
"\n",
72+
"4. **Constructor**: Use `@apply_defaults` decorator and call `super().__init__()` with scenario metadata:\n",
6773
" - `name`: Descriptive name for your scenario\n",
6874
" - `version`: Integer version number\n",
6975
" - `strategy_class`: The strategy enum class for this scenario\n",
7076
" - `objective_scorer_identifier`: Identifier dict for the scoring mechanism (optional)\n",
7177
" - `include_default_baseline`: Whether to include a baseline attack (default: True)\n",
7278
" - `scenario_result_id`: Optional ID to resume an existing scenario (optional)\n",
7379
"\n",
74-
"4. **Initialization**: Call `await scenario.initialize_async()` to populate atomic attacks:\n",
80+
"5. **Initialization**: Call `await scenario.initialize_async()` to populate atomic attacks:\n",
7581
" - `objective_target`: The target system being tested (required)\n",
7682
" - `scenario_strategies`: List of strategies to execute (optional, defaults to ALL)\n",
7783
" - `max_concurrency`: Number of concurrent operations (default: 1)\n",
7884
" - `max_retries`: Number of retry attempts on failure (default: 0)\n",
7985
" - `memory_labels`: Optional labels for tracking (optional)\n",
8086
"\n",
81-
"### Example Structure"
87+
"### Example Structure\n",
88+
"\n",
89+
"The simplest approach uses the **factory/registry pattern**: define your strategy,\n",
90+
"dataset config, and constructor — the base class handles building atomic attacks\n",
91+
"automatically from registered attack techniques."
8292
]
8393
},
8494
{
@@ -98,12 +108,8 @@
98108
}
99109
],
100110
"source": [
101-
"from typing import Optional\n",
102-
"\n",
103111
"from pyrit.common import apply_defaults\n",
104-
"from pyrit.executor.attack import AttackScoringConfig, PromptSendingAttack\n",
105112
"from pyrit.scenario import (\n",
106-
" AtomicAttack,\n",
107113
" DatasetConfiguration,\n",
108114
" Scenario,\n",
109115
" ScenarioStrategy,\n",
@@ -116,76 +122,56 @@
116122
"\n",
117123
"class MyStrategy(ScenarioStrategy):\n",
118124
" ALL = (\"all\", {\"all\"})\n",
119-
" StrategyA = (\"strategy_a\", {\"tag1\", \"tag2\"})\n",
120-
" StrategyB = (\"strategy_b\", {\"tag1\"})\n",
125+
" DEFAULT = (\"default\", {\"default\"})\n",
126+
" SINGLE_TURN = (\"single_turn\", {\"single_turn\"})\n",
127+
" # Strategy members represent attack techniques\n",
128+
" PromptSending = (\"prompt_sending\", {\"single_turn\", \"default\"})\n",
129+
" RolePlay = (\"role_play\", {\"single_turn\"})\n",
121130
"\n",
122131
"\n",
123132
"class MyScenario(Scenario):\n",
124-
" version: int = 1\n",
133+
" \"\"\"Quick-check scenario for testing model behavior across harm categories.\"\"\"\n",
134+
"\n",
135+
" VERSION: int = 1\n",
125136
"\n",
126-
" # A strategy definition helps callers define how to run your scenario (e.g. from the scanner CLI)\n",
127137
" @classmethod\n",
128138
" def get_strategy_class(cls) -> type[ScenarioStrategy]:\n",
129139
" return MyStrategy\n",
130140
"\n",
131141
" @classmethod\n",
132142
" def get_default_strategy(cls) -> ScenarioStrategy:\n",
133-
" return MyStrategy.ALL\n",
143+
" return MyStrategy.DEFAULT\n",
134144
"\n",
135-
" # This is the default dataset configuration for this scenario (e.g. prompts to send)\n",
136145
" @classmethod\n",
137146
" def default_dataset_config(cls) -> DatasetConfiguration:\n",
138-
" return DatasetConfiguration(dataset_names=[\"dataset_name\"])\n",
147+
" return DatasetConfiguration(dataset_names=[\"dataset_name\"], max_dataset_size=4)\n",
139148
"\n",
140149
" @apply_defaults\n",
141150
" def __init__(\n",
142151
" self,\n",
143152
" *,\n",
144-
" objective_scorer: Optional[TrueFalseScorer] = None,\n",
145-
" scenario_result_id: Optional[str] = None,\n",
146-
" ):\n",
147-
" self._objective_scorer = objective_scorer\n",
148-
" self._scorer_config = AttackScoringConfig(objective_scorer=objective_scorer)\n",
153+
" objective_scorer: TrueFalseScorer | None = None,\n",
154+
" scenario_result_id: str | None = None,\n",
155+
" ) -> None:\n",
156+
" self._objective_scorer: TrueFalseScorer = (\n",
157+
" objective_scorer if objective_scorer else self._get_default_objective_scorer()\n",
158+
" )\n",
149159
"\n",
150-
" # Call parent constructor - note: objective_target is NOT passed here\n",
151160
" super().__init__(\n",
152-
" name=\"My Custom Scenario\",\n",
153-
" version=self.version,\n",
154-
" strategy_class=MyStrategy,\n",
155-
" objective_scorer=objective_scorer,\n",
161+
" version=self.VERSION,\n",
162+
" objective_scorer=self._objective_scorer,\n",
163+
" strategy_class=self.get_strategy_class(),\n",
156164
" scenario_result_id=scenario_result_id,\n",
157165
" )\n",
158166
"\n",
159-
" async def _get_atomic_attacks_async(self) -> list[AtomicAttack]:\n",
160-
" \"\"\"\n",
161-
" Build atomic attacks based on selected strategies.\n",
162-
"\n",
163-
" This method is called by initialize_async() after strategies are prepared.\n",
164-
" Use self._scenario_strategies to access the resolved strategy list.\n",
165-
" \"\"\"\n",
166-
" atomic_attacks = []\n",
167-
"\n",
168-
" # objective_target is guaranteed to be non-None by parent class validation\n",
169-
" assert self._objective_target is not None\n",
170-
"\n",
171-
" for strategy in self._scenario_strategies:\n",
172-
" # self._dataset_config is set by the parent class\n",
173-
" seed_groups = self._dataset_config.get_all_seed_groups()\n",
174-
"\n",
175-
" # Create attack instances based on strategy\n",
176-
" attack = PromptSendingAttack(\n",
177-
" objective_target=self._objective_target,\n",
178-
" attack_scoring_config=self._scorer_config,\n",
179-
" )\n",
180-
" atomic_attacks.append(\n",
181-
" AtomicAttack(\n",
182-
" atomic_attack_name=strategy.value,\n",
183-
" attack=attack,\n",
184-
" seed_groups=seed_groups, # type: ignore[arg-type]\n",
185-
" memory_labels=self._memory_labels,\n",
186-
" )\n",
187-
" )\n",
188-
" return atomic_attacks"
167+
" # Optional: override _build_display_group to customize result grouping.\n",
168+
" # Default groups by technique name; override to group by dataset instead:\n",
169+
" def _build_display_group(self, *, technique_name: str, seed_group_name: str) -> str:\n",
170+
" return seed_group_name\n",
171+
"\n",
172+
" # No _get_atomic_attacks_async override needed!\n",
173+
" # The base class builds attacks from the (technique x dataset) cross-product\n",
174+
" # using the factory/registry pattern automatically."
189175
]
190176
},
191177
{

0 commit comments

Comments
 (0)