|
| 1 | +# --- |
| 2 | +# jupyter: |
| 3 | +# jupytext: |
| 4 | +# text_representation: |
| 5 | +# extension: .py |
| 6 | +# format_name: percent |
| 7 | +# format_version: '1.3' |
| 8 | +# jupytext_version: 1.18.1 |
| 9 | +# --- |
| 10 | + |
| 11 | +# %% [markdown] |
| 12 | +# # Adaptive Scenarios |
| 13 | +# |
| 14 | +# An **adaptive scenario** doesn't run every attack technique against every objective. |
| 15 | +# Instead, it picks which technique to try next per-objective, learns from what worked, |
| 16 | +# and stops as soon as one technique succeeds. This concentrates spend on techniques |
| 17 | +# that actually work on your target. |
| 18 | +# |
| 19 | +# ## How it works (high level) |
| 20 | +# |
| 21 | +# For each objective, the scenario tries up to `max_attempts_per_objective` techniques: |
| 22 | +# |
| 23 | +# - With probability `epsilon`, it **explores** — picks a random technique. |
| 24 | +# - Otherwise it **exploits** — picks the technique with the highest observed success |
| 25 | +# rate so far. |
| 26 | +# - It records the outcome and stops early on success. |
| 27 | +# |
| 28 | +# Unseen techniques are tried first, so the first few objectives effectively round-robin |
| 29 | +# through every technique before the scenario settles on the best performers. |
| 30 | +# |
| 31 | +# ## Adaptive vs. static scenarios |
| 32 | +# |
| 33 | +# | Feature | Static scenarios | Adaptive scenarios | |
| 34 | +# |---------------------|-----------------------------------|------------------------------------| |
| 35 | +# | Technique selection | Run every selected technique | Pick per-objective from outcomes | |
| 36 | +# | Early stopping | No | Yes — stops on first success | |
| 37 | +# | Cost | O(techniques × objectives) | O(max_attempts × objectives) | |
| 38 | +# |
| 39 | +# `AdaptiveScenario` is the modality-agnostic base class. |
| 40 | +# [`TextAdaptive`](../../../pyrit/scenario/scenarios/adaptive/text_adaptive.py) is the |
| 41 | +# text subclass used in the examples below. |
| 42 | + |
| 43 | +# %% [markdown] |
| 44 | +# ## Setup |
| 45 | + |
| 46 | +# %% |
| 47 | +from pathlib import Path |
| 48 | + |
| 49 | +from pyrit.registry import TargetRegistry |
| 50 | +from pyrit.scenario import DatasetConfiguration |
| 51 | +from pyrit.scenario.printer.console_printer import ConsoleScenarioResultPrinter |
| 52 | +from pyrit.scenario.scenarios.adaptive import TextAdaptive |
| 53 | +from pyrit.setup import initialize_from_config_async |
| 54 | + |
| 55 | +await initialize_from_config_async(config_path=Path("../../scanner/pyrit_conf.yaml")) # type: ignore |
| 56 | + |
| 57 | +objective_target = TargetRegistry.get_registry_singleton().get_instance_by_name("openai_chat") |
| 58 | +printer = ConsoleScenarioResultPrinter() |
| 59 | + |
| 60 | +# %% [markdown] |
| 61 | +# ## Basic usage |
| 62 | +# |
| 63 | +# Defaults: `max_attempts_per_objective=3`, epsilon-greedy selector with `epsilon=0.2`, |
| 64 | +# the subclass's default datasets. |
| 65 | + |
| 66 | +# %% |
| 67 | +scenario = TextAdaptive() |
| 68 | + |
| 69 | +await scenario.initialize_async( # type: ignore |
| 70 | + objective_target=objective_target, |
| 71 | +) |
| 72 | +result = await scenario.run_async() # type: ignore |
| 73 | +await printer.write_async(result) # type: ignore |
| 74 | + |
| 75 | +# %% [markdown] |
| 76 | +# ## Configuring a run |
| 77 | +# |
| 78 | +# - **`max_attempts_per_objective`** — caps techniques tried per objective. Higher means |
| 79 | +# more chances to succeed and more API calls. Set via `set_params_from_args`. |
| 80 | +# - **`selector`** — a pre-built `TechniqueSelector` instance. Pass an |
| 81 | +# `EpsilonGreedyTechniqueSelector(epsilon=..., random_seed=...)` |
| 82 | +# to tune the selection algorithm. Defaults to an epsilon-greedy selector with |
| 83 | +# `epsilon=0.2`. |
| 84 | +# - **`scenario_strategies`** (on `initialize_async`) — restricts which techniques the |
| 85 | +# selector can pick from. Use `TextAdaptive.get_strategy_class()` to access the enum. |
| 86 | +# |
| 87 | +# The cell below exercises all of them at once. |
| 88 | + |
| 89 | +# %% |
| 90 | +from pyrit.scenario.scenarios.adaptive import EpsilonGreedyTechniqueSelector |
| 91 | + |
| 92 | +strategy_class = TextAdaptive.get_strategy_class() |
| 93 | + |
| 94 | +configured_scenario = TextAdaptive( |
| 95 | + selector=EpsilonGreedyTechniqueSelector( |
| 96 | + epsilon=0.3, |
| 97 | + random_seed=42, |
| 98 | + ), |
| 99 | +) |
| 100 | +configured_scenario.set_params_from_args(args={"max_attempts_per_objective": 5}) |
| 101 | + |
| 102 | +await configured_scenario.initialize_async( # type: ignore |
| 103 | + objective_target=objective_target, |
| 104 | + scenario_strategies=[strategy_class("single_turn")], |
| 105 | + dataset_config=DatasetConfiguration( |
| 106 | + dataset_names=["airt_hate", "airt_violence"], |
| 107 | + max_dataset_size=4, |
| 108 | + ), |
| 109 | +) |
| 110 | +configured_result = await configured_scenario.run_async() # type: ignore |
| 111 | +await printer.write_async(configured_result) # type: ignore |
| 112 | + |
| 113 | +# %% [markdown] |
| 114 | +# ## Resuming a run |
| 115 | +# |
| 116 | +# Adaptive scenarios are resumable — pass `scenario_result_id=...` to the `TextAdaptive` |
| 117 | +# constructor and the run picks up where it left off. Resume must use the same |
| 118 | +# configuration as the original run. |
| 119 | + |
| 120 | +# %% |
| 121 | +resumed_scenario = TextAdaptive( |
| 122 | + selector=EpsilonGreedyTechniqueSelector( |
| 123 | + epsilon=0.3, |
| 124 | + random_seed=42, |
| 125 | + ), |
| 126 | + scenario_result_id=str(configured_result.id), |
| 127 | +) |
| 128 | +resumed_scenario.set_params_from_args(args={"max_attempts_per_objective": 5}) |
| 129 | + |
| 130 | +await resumed_scenario.initialize_async( # type: ignore |
| 131 | + objective_target=objective_target, |
| 132 | + scenario_strategies=[strategy_class("single_turn")], |
| 133 | + dataset_config=DatasetConfiguration( |
| 134 | + dataset_names=["airt_hate", "airt_violence"], |
| 135 | + max_dataset_size=4, |
| 136 | + ), |
| 137 | +) |
| 138 | +resumed_result = await resumed_scenario.run_async() # type: ignore |
| 139 | +await printer.write_async(resumed_result) # type: ignore |
| 140 | + |
| 141 | +# %% [markdown] |
| 142 | +# ## Inspecting which techniques were tried |
| 143 | +# |
| 144 | +# Every adaptive run persists both the per-objective envelope (a |
| 145 | +# `SequentialAttackResult`) AND its per-attempt child rows. Each child row |
| 146 | +# carries its own `atomic_attack_identifier`, so the persisted data alone is |
| 147 | +# enough to reconstruct the per-attempt trail — no envelope-side metadata, no |
| 148 | +# scenario-side lookup tables needed. |
| 149 | +# |
| 150 | +# Walk the children via the envelope's `child_attack_result_ids` (joined |
| 151 | +# against the flat results list), then read each child's attack strategy |
| 152 | +# identifier with `child.get_attack_strategy_identifier()`. The returned |
| 153 | +# `ComponentIdentifier` exposes `class_name` (e.g. `"CrescendoAttack"`) for a |
| 154 | +# human-readable label, and `unique_name` (e.g. `"CrescendoAttack::a1b2c3d4"`) |
| 155 | +# when you need to distinguish two factories that wrap the same attack class |
| 156 | +# with different configurations. |
| 157 | +# |
| 158 | +# Use `result.get_display_groups()` to aggregate `attack_results` by the |
| 159 | +# per-dataset display label set by the scenario. |
| 160 | +# |
| 161 | +# If the trail of attacks attempted is shorter than `max_attempts_per_objective`, |
| 162 | +# the compatible-technique pool for that seed group was smaller than the cap — |
| 163 | +# the run exhausted the pool. |
| 164 | + |
| 165 | +# %% |
| 166 | +from collections import Counter |
| 167 | + |
| 168 | +# Per-group: one line per objective (the envelope) showing the per-attempt |
| 169 | +# trail, plus a per-technique success-rate table within the group. The child |
| 170 | +# rows that compose each envelope are filtered out of the per-objective list so |
| 171 | +# it stays one line per objective. Aggregate across groups for a grand-total. |
| 172 | +display_groups = resumed_result.get_display_groups() |
| 173 | + |
| 174 | +# Flatten every persisted row across every group so we can look up a child |
| 175 | +# AttackResult by its attack_result_id when reconstructing per-envelope trails. |
| 176 | +results_by_id = {r.attack_result_id: r for results in display_groups.values() for r in results} |
| 177 | + |
| 178 | + |
| 179 | +def _technique_label(result) -> str: |
| 180 | + """Display name for the attack strategy that produced ``result``.""" |
| 181 | + attack_id = result.get_attack_strategy_identifier() |
| 182 | + return attack_id.class_name if attack_id else "<unknown>" |
| 183 | + |
| 184 | + |
| 185 | +total_picks: Counter[str] = Counter() |
| 186 | +total_wins: Counter[str] = Counter() |
| 187 | + |
| 188 | +for group_name, results in display_groups.items(): |
| 189 | + print(f"\n=== Group: {group_name} ===") |
| 190 | + |
| 191 | + # Collect every child id referenced by any envelope in this group so we |
| 192 | + # can skip the per-attempt child rows when printing per-objective lines. |
| 193 | + # Baseline rows have no envelope and pass through untouched. |
| 194 | + child_ids: set[str] = set() |
| 195 | + for r in results: |
| 196 | + child_ids.update(r.metadata.get("child_attack_result_ids", []) or []) |
| 197 | + |
| 198 | + for r in results: |
| 199 | + if r.attack_result_id in child_ids: |
| 200 | + continue |
| 201 | + child_id_list = r.metadata.get("child_attack_result_ids", []) or [] |
| 202 | + trail_parts: list[str] = [] |
| 203 | + for child_id in child_id_list: |
| 204 | + child = results_by_id.get(child_id) |
| 205 | + if child is None: |
| 206 | + continue |
| 207 | + trail_parts.append(f"{_technique_label(child)}({child.outcome.value})") |
| 208 | + trail = " → ".join(trail_parts) |
| 209 | + print(f" [{r.outcome.value:7s}] {r.objective!r}: {trail}") |
| 210 | + |
| 211 | + picks: Counter[str] = Counter() |
| 212 | + wins: Counter[str] = Counter() |
| 213 | + for r in results: |
| 214 | + if r.attack_result_id not in child_ids: |
| 215 | + continue |
| 216 | + technique = _technique_label(r) |
| 217 | + picks[technique] += 1 |
| 218 | + total_picks[technique] += 1 |
| 219 | + if r.outcome.value == "success": |
| 220 | + wins[technique] += 1 |
| 221 | + total_wins[technique] += 1 |
| 222 | + |
| 223 | + print("\n Technique wins / picks rate") |
| 224 | + for technique, n in picks.most_common(): |
| 225 | + print(f" {technique:40s} {wins[technique]:>4} / {n:<4} {wins[technique] / n:.0%}") |
| 226 | + |
| 227 | +print("\n=== Overall ===") |
| 228 | +print("Technique wins / picks rate") |
| 229 | +for technique, n in total_picks.most_common(): |
| 230 | + print(f"{technique:40s} {total_wins[technique]:>4} / {n:<4} {total_wins[technique] / n:.0%}") |
| 231 | + |
| 232 | +# %% [markdown] |
| 233 | +# ## Running from the scanner CLI |
| 234 | +# |
| 235 | +# You can run `TextAdaptive` directly from the `pyrit_scan` CLI without writing Python: |
| 236 | +# |
| 237 | +# ```bash |
| 238 | +# # Basic run with defaults |
| 239 | +# pyrit_scan --scenario TextAdaptive --target openai_chat |
| 240 | +# |
| 241 | +# # Tune max attempts and restrict strategies |
| 242 | +# pyrit_scan --scenario TextAdaptive --target openai_chat \ |
| 243 | +# --params max_attempts_per_objective=5 \ |
| 244 | +# --strategies single_turn |
| 245 | +# |
| 246 | +# # Use specific datasets and limit size |
| 247 | +# pyrit_scan --scenario TextAdaptive --target openai_chat \ |
| 248 | +# --datasets airt_hate airt_violence \ |
| 249 | +# --max-dataset-size 10 |
| 250 | +# ``` |
0 commit comments