Skip to content

Commit 0f8011b

Browse files
FEAT: Adding Scenarios (microsoft#1135)
Co-authored-by: hannahwestra25 <hannahwestra@microsoft.com>
1 parent 9fc1092 commit 0f8011b

12 files changed

Lines changed: 4781 additions & 0 deletions

File tree

.github/instructions/style-guide.instructions.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,34 @@ from pyrit.models import AttackResult, PromptResponse
234234
from pyrit.prompt_target import PromptTarget
235235
```
236236

237+
### Import paths
238+
239+
Often, pyrit has specific files that can be imported. However IF you are importing from a different module than your namespace,
240+
import from the root pyrit module if it's exposed from init.
241+
242+
In the same module, importing from the specific path is usually necessary to prevent circular imports.
243+
244+
```python
245+
# Correct
246+
from pyrit.prompt_target import PromptChatTarget, OpenAIChatTarget
247+
248+
# Correct
249+
from pyrit.score import (
250+
AzureContentFilterScorer,
251+
FloatScaleThresholdScorer,
252+
SelfAskRefusalScorer,
253+
TrueFalseCompositeScorer,
254+
TrueFalseInverterScorer,
255+
TrueFalseScoreAggregator,
256+
TrueFalseScorer,
257+
)
258+
259+
# Incorrect (if importing from a non-target module)
260+
from pyrit.prompt_target.common.prompt_chat_target import PromptChatTarget
261+
from pyrit.prompt_target.openai.openai_chat_target import OpenAIChatTarget
262+
263+
```
264+
237265
## Error Handling
238266

239267
### Specific Exceptions

doc/_toc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ chapters:
118118
- file: code/auxiliary_attacks/0_auxiliary_attacks
119119
sections:
120120
- file: code/auxiliary_attacks/1_gcg_azure_ml
121+
- file: code/scenarios/scenarios
121122
- file: code/scanner/0_scanner
122123
sections:
123124
- file: code/scanner/1_configuration

doc/code/scenarios/scenarios.ipynb

Lines changed: 2432 additions & 0 deletions
Large diffs are not rendered by default.

doc/code/scenarios/scenarios.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# ---
2+
# jupyter:
3+
# jupytext:
4+
# text_representation:
5+
# extension: .py
6+
# format_name: percent
7+
# format_version: '1.3'
8+
# jupytext_version: 1.17.3
9+
# kernelspec:
10+
# display_name: pyrit-dev2
11+
# language: python
12+
# name: python3
13+
# ---
14+
15+
# %% [markdown]
16+
# # Scenarios
17+
#
18+
# A `Scenario` is a higher-level construct that groups multiple Attack Configurations together. This allows you to execute a comprehensive testing campaign with multiple attack methods sequentially. Scenarios are meant to be configured and written to test for specific workflows. As such, it is okay to hard code some values.
19+
#
20+
# ## What is a Scenario?
21+
#
22+
# A `Scenario` represents a comprehensive testing campaign composed of multiple atomic attack tests. It orchestrates the execution of multiple `AttackRun`instances sequentially and aggregates the results into a single `ScenarioResult`.
23+
#
24+
# ### Key Components
25+
#
26+
# - **Scenario**: The top-level orchestrator that groups and executes multiple attack runs
27+
# - **AttackRun**: An atomic test unit combining an attack strategy, objectives, and execution parameters
28+
# - **ScenarioResult**: Contains the aggregated results from all attack runs and scenario metadata
29+
#
30+
# ## Use Cases
31+
#
32+
# Some examples of scenarios you might create:
33+
#
34+
# - **VibeCheckScenario**: Randomly selects a few prompts from HarmBench to quickly assess model behavior
35+
# - **QuickViolence**: Checks how resilient a model is to violent objectives using multiple attack techniques
36+
# - **ComprehensiveFoundry**: Tests a target with all available attack converters and strategies.
37+
# - **CustomCompliance**: Tests against specific compliance requirements with curated datasets and attacks
38+
#
39+
# These Scenarios can be updated and added to as you refine what you are testing for.
40+
#
41+
# ## How It Works
42+
#
43+
# Each `Scenario` contains a collection of `AttackRun` objects. When executed:
44+
#
45+
# 1. Each `AttackRun` is executed sequentially
46+
# 2. Every `AttackRun` tests its configured attack against all specified objectives and datasets
47+
# 3. Results are aggregated into a single `ScenarioResult` with all attack outcomes
48+
# 4. Optional memory labels help track and categorize the scenario execution
49+
#
50+
# ## Creating Custom Scenarios
51+
#
52+
# To create a custom scenario, extend the `Scenario` base class. See [`FoundryScenario`](../../../pyrit/scenarios/config/foundry_scenario.py) for an example.
53+
#
54+
# ## Using Scenarios
55+
#
56+
# Scenarios will be exposed for simple runs (e.g. the cli). Below is an example of how to execute them in code.
57+
#
58+
59+
# %%
60+
from pyrit.common import IN_MEMORY, initialize_pyrit
61+
from pyrit.executor.attack import ConsoleAttackResultPrinter
62+
from pyrit.prompt_target import OpenAIChatTarget
63+
from pyrit.scenarios import FoundryAttackStrategy, FoundryScenario
64+
65+
initialize_pyrit(
66+
memory_db_type=IN_MEMORY,
67+
)
68+
69+
objective_target = OpenAIChatTarget()
70+
printer = ConsoleAttackResultPrinter()
71+
72+
# Create a scenario from the pre-configured Foundry scenario
73+
foundry_scenario = FoundryScenario(objective_target=objective_target, attack_strategies={FoundryAttackStrategy.EASY})
74+
75+
print(f"Created scenario: {foundry_scenario.name}")
76+
print(f"Number of attack runs: {foundry_scenario.attack_run_count}")
77+
78+
# Execute the entire scenario
79+
results = await foundry_scenario.run_async(max_concurrency=5) # type: ignore
80+
81+
print(f"\nScenario completed with {len(results.attack_results)} total results")
82+
print(f"Success rate: {results.objective_achieved_rate}%\n")
83+
84+
# Print summary for each result
85+
for result in results.attack_results:
86+
await printer.print_summary_async(result=result) # type: ignore

pyrit/models/seed_prompt_dataset.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
import logging
7+
import random
78
import uuid
89
from collections import defaultdict
910
from datetime import datetime
@@ -114,6 +115,19 @@ def get_values(self, first: Optional[PositiveInt] = None, last: Optional[Positiv
114115

115116
return first_part + last_part
116117

118+
def get_random_values(self, number: PositiveInt) -> Sequence[str]:
119+
"""
120+
Extracts and returns a list of random prompt values from the dataset.
121+
122+
Args:
123+
number (int): The number of random prompt values to return.
124+
125+
Returns:
126+
Sequence[str]: A list of prompt values.
127+
"""
128+
prompts = self.get_values()
129+
return random.sample(prompts, min(len(prompts), number))
130+
117131
@classmethod
118132
def from_dict(cls, data: Dict[str, Any]) -> "SeedPromptDataset":
119133
"""

pyrit/scenarios/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""High-level scenario classes for running attack configurations."""
5+
6+
from pyrit.scenarios.attack_run import AttackRun
7+
from pyrit.scenarios.config.foundry_scenario import FoundryAttackStrategy, FoundryScenario
8+
from pyrit.scenarios.scenario import Scenario
9+
10+
__all__ = ["AttackRun", "FoundryAttackStrategy", "FoundryScenario", "Scenario"]

pyrit/scenarios/attack_run.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""
5+
AttackRun class for executing single attack configurations against datasets.
6+
7+
This module provides the AttackRun class that represents an atomic test combining
8+
an attack, a dataset, and execution parameters. Multiple AttackRuns can be grouped
9+
together into larger test scenarios for comprehensive security testing.
10+
"""
11+
12+
import logging
13+
from typing import Any, Dict, List, Optional
14+
15+
from pyrit.executor.attack import AttackExecutor, AttackStrategy
16+
from pyrit.models import AttackResult, PromptRequestResponse
17+
18+
logger = logging.getLogger(__name__)
19+
20+
21+
class AttackRun:
22+
"""
23+
Represents a single atomic attack test combining an attack strategy and dataset.
24+
25+
An AttackRun is an executable unit that executes a configured attack against
26+
all objectives in a dataset. Multiple AttackRuns can be grouped together into
27+
larger test scenarios for comprehensive security testing and evaluation.
28+
29+
Example:
30+
>>> from pyrit.scenarios import AttackRun
31+
>>> from pyrit.attacks import PromptAttack
32+
>>> from pyrit.prompt_target import OpenAIChatTarget
33+
>>>
34+
>>> target = OpenAIChatTarget()
35+
>>> attack = PromptAttack(objective_target=target)
36+
>>> objectives = ["how to make a bomb", "how to hack a system"]
37+
>>>
38+
>>> attack_run = AttackRun(
39+
... attack=attack,
40+
... objectives=objectives,
41+
... memory_labels={"test": "run1"}
42+
... )
43+
>>> results = await attack_run.run_async(max_concurrency=5)
44+
>>>
45+
>>> # With prepended conversation
46+
>>> from pyrit.models import PromptRequestResponse
47+
>>> conversation = [PromptRequestResponse(...)]
48+
>>> attack_run = AttackRun(
49+
... attack=attack,
50+
... objectives=objectives,
51+
... prepended_conversation=conversation
52+
... )
53+
>>> results = await attack_run.run_async(max_concurrency=5)
54+
"""
55+
56+
def __init__(
57+
self,
58+
*,
59+
attack: AttackStrategy,
60+
objectives: List[str],
61+
prepended_conversation: Optional[List[PromptRequestResponse]] = None,
62+
memory_labels: Optional[Dict[str, str]] = None,
63+
**attack_execute_params: Any,
64+
) -> None:
65+
"""
66+
Initialize an attack run with an attack strategy and dataset parameters.
67+
68+
Args:
69+
attack (AttackStrategy): The configured attack strategy to execute.
70+
objectives (List[str]): List of attack objectives to test against.
71+
prepended_conversation (Optional[List[PromptRequestResponse]]): Optional
72+
conversation history to prepend to each attack execution.
73+
memory_labels (Optional[Dict[str, str]]): Additional labels to apply to prompts.
74+
These labels help track and categorize the attack run in memory.
75+
**attack_execute_params (Any): Additional parameters to pass to the attack
76+
execution method (e.g., custom_prompts, batch_size).
77+
78+
Raises:
79+
ValueError: If objectives list is empty.
80+
"""
81+
if not objectives:
82+
raise ValueError("objectives list cannot be empty")
83+
84+
self._attack = attack
85+
self._objectives = objectives
86+
self._prepended_conversation = prepended_conversation
87+
self._memory_labels = memory_labels or {}
88+
self._attack_execute_params = attack_execute_params
89+
90+
logger.info(
91+
f"Initialized attack run with {len(self._objectives)} objectives "
92+
f"and attack type: {type(attack).__name__}"
93+
)
94+
95+
async def run_async(self, *, max_concurrency: int = 1) -> List[AttackResult]:
96+
"""
97+
Execute the attack run against all objectives in the dataset.
98+
99+
This method uses AttackExecutor to run the configured attack against
100+
all objectives from the dataset.
101+
102+
Args:
103+
max_concurrency (int): Maximum number of concurrent attack executions.
104+
Defaults to 1 for sequential execution.
105+
106+
Returns:
107+
List[AttackResult]: List of attack results, one for each objective.
108+
109+
Raises:
110+
ValueError: If the attack execution fails.
111+
"""
112+
# Create the executor with the specified concurrency
113+
executor = AttackExecutor(max_concurrency=max_concurrency)
114+
115+
# Merge memory labels from initialization and execution parameters
116+
merged_memory_labels = {**self._memory_labels}
117+
118+
# Build the parameters for execute_multi_objective_attack_async
119+
execute_params = {
120+
"objectives": self._objectives,
121+
"prepended_conversation": self._prepended_conversation,
122+
"memory_labels": merged_memory_labels,
123+
**self._attack_execute_params,
124+
}
125+
126+
logger.info(
127+
f"Starting attack run execution with {len(self._objectives)} objectives "
128+
f"and max_concurrency={max_concurrency}"
129+
)
130+
131+
try:
132+
# Execute the attack using the executor
133+
results = await executor.execute_multi_objective_attack_async(
134+
attack=self._attack,
135+
**execute_params,
136+
)
137+
138+
logger.info(f"Attack run execution completed successfully with {len(results)} results")
139+
return results
140+
141+
except Exception as e:
142+
logger.error(f"Attack run execution failed: {str(e)}")
143+
raise ValueError(f"Failed to execute attack run: {str(e)}") from e

0 commit comments

Comments
 (0)