Skip to content

Commit 9f39ff7

Browse files
committed
Merge branch 'main' into users/rlundeen/model_dict
2 parents b001e02 + d0c8833 commit 9f39ff7

356 files changed

Lines changed: 15217 additions & 5298 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
applyTo: "pyrit/output/**"
3+
---
4+
5+
# PyRIT Output Module — Coding & Review Guidelines
6+
7+
For full architecture documentation, usage examples, and extension guides, see [doc/code/output/0_output.py](../../../doc/code/output/0_output.py).
8+
9+
This file covers the rules for **writing and reviewing** code in `pyrit/output/`.
10+
11+
## Critical Rules
12+
13+
### Output goes through the sink — never call `print()` directly
14+
15+
All rendering methods return `str`. The inherited `write_async` calls `render_async` then `_write_async(content)`. No bare `print()` calls anywhere in the output module except inside `StdoutSink`.
16+
17+
When reviewing: reject any `print()` call outside `StdoutSink`.
18+
19+
### Data fetching belongs in leaf classes only
20+
21+
Format classes (`PrettyAttackResultPrinter`, `MarkdownAttackResultPrinter`) must not import or reference `CentralMemory`. Only `*MemoryPrinter` leaf classes do data I/O.
22+
23+
When reviewing: reject any `CentralMemory` import in a non-leaf file (`pretty.py`, `markdown.py`, `json.py`).
24+
25+
### Sinks must use async I/O
26+
27+
Sink implementations must not block the event loop. Use `asyncio.to_thread()` or native async libraries for I/O operations. `FileSink` uses an `asyncio.Lock` to prevent concurrent write races.
28+
29+
When reviewing: reject synchronous `open()`, `write()`, or network calls inside a sink's `write_async`.
30+
31+
## Three-Layer Hierarchy
32+
33+
Every domain follows this structure. Do not mix responsibilities across layers.
34+
35+
| Layer | File | Responsibility | May import CentralMemory? |
36+
|-------|------|---------------|---------------------------|
37+
| **Base** | `base.py` | Abstract data-fetching methods + abstract `render_async` | No |
38+
| **Format** | `pretty.py`, `markdown.py`, `json.py` | Implements `render_async`, returns `str` | No |
39+
| **Leaf** | Same file as format (e.g., `PrettyAttackResultMemoryPrinter`) | Implements data methods via CentralMemory; forwarding `render_async` | Yes |
40+
41+
### File names = output format
42+
43+
- `pretty.py` — ANSI-colored human-readable
44+
- `markdown.py` — Markdown
45+
- `json.py` — structured JSON
46+
47+
### Memory leaf classes must work with zero args
48+
49+
```python
50+
printer = PrettyAttackResultMemoryPrinter() # defaults: StdoutSink, matching sub-printers
51+
await printer.write_async(result)
52+
```
53+
54+
Pass `sink=` to redirect output. Pass sub-printers only to override defaults.
55+
56+
### Convenience functions live in `helpers.py`
57+
58+
Every new domain printer **must** have a corresponding convenience function added to `helpers.py`. This is the primary entry point most callers use.
59+
60+
```python
61+
from pyrit.output.helpers import output_attack_async
62+
await output_attack_async(result, format="pretty")
63+
```
64+
65+
`helpers.py` resolves `format` → printer class, `sink` → Sink, and calls `write_async`.
66+
67+
When reviewing: if a new domain printer is added without a helper function, request one.

.github/instructions/scenarios.instructions.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ 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 three abstract methods:**
14+
2. **Optionally declare `BASELINE_POLICY`** (defaults to `BaselinePolicy.Enabled` — a baseline `PromptSendingAttack` is prepended and callers can opt out per run via `initialize_async(include_baseline=False)`):
15+
- `BaselinePolicy.Disabled` — baseline supported but off by default (e.g. `Jailbreak`, where templates dominate the run).
16+
- `BaselinePolicy.Forbidden` — baseline is meaningless for this scenario's comparison axis (e.g. `AdversarialBenchmark`, which compares against gold-standard answers). Explicit `include_baseline=True` raises `ValueError`.
17+
3. **Implement three abstract methods:**
1518

1619
```python
1720
class MyScenario(Scenario):
1821
VERSION: int = 1
22+
BASELINE_POLICY: ClassVar[BaselinePolicy] = BaselinePolicy.Enabled
1923

2024
@classmethod
2125
def get_strategy_class(cls) -> type[ScenarioStrategy]:
@@ -30,7 +34,7 @@ class MyScenario(Scenario):
3034
return DatasetConfiguration(dataset_names=["my_dataset"])
3135
```
3236

33-
3. **Optionally override `_get_atomic_attacks_async()`** — the base class provides a default
37+
4. **Optionally override `_get_atomic_attacks_async()`** — the base class provides a default
3438
that uses the factory/registry pattern (see "AtomicAttack Construction" below).
3539
Only override if your scenario needs custom attack construction logic.
3640

@@ -154,6 +158,8 @@ The default implementation:
154158
Only override when the scenario **cannot** use the factory/registry pattern — e.g., scenarios
155159
with custom composite logic, per-strategy converter stacks, or non-standard attack construction.
156160

161+
Overrides that want baseline support must emit it themselves by calling `self._build_baseline_atomic_attack(seed_groups=...)` with the same seeds used for the strategy attacks and prepending the result. The base implementation emits baseline automatically; passing freshly resolved seeds reintroduces ADO 9012 (baseline-vs-strategy population divergence under `max_dataset_size`).
162+
157163
### Manual AtomicAttack construction (for overrides):
158164

159165
```python

.github/workflows/build_and_test.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ jobs:
3333
contents: read
3434
steps:
3535
- uses: actions/checkout@v5
36+
with:
37+
# Full history is required so pre-commit hooks (notably
38+
# enforce_alembic_revision_immutability) can compute merge-bases and
39+
# diff ranges against origin/main.
40+
fetch-depth: 0
3641

3742
- uses: actions/setup-python@v6
3843
with:

.github/workflows/docs.yml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,24 @@ jobs:
5555
- name: Install PyRIT with uv
5656
run: uv sync --extra all
5757

58-
# Build the book
58+
# LaTeX toolchain needed for the PDF export (jupyter-book --pdf via xelatex).
59+
# Mirrors the ReadTheDocs build so CI catches PDF-only issues (e.g. broken
60+
# image paths) instead of letting them silently break the RTD build.
61+
- name: Install LaTeX (for PDF export)
62+
run: |
63+
sudo apt-get update
64+
sudo apt-get install -y --no-install-recommends \
65+
texlive-xetex \
66+
texlive-fonts-recommended \
67+
texlive-fonts-extra \
68+
texlive-plain-generic \
69+
texlive-latex-extra \
70+
latexmk
71+
72+
# Build the book (HTML site + PDF export)
5973
- name: Build the book
6074
run: |
61-
make docs-build
75+
make docs-build-all
6276
# Upload the book's HTML as an artifact
6377
- name: Upload artifact
6478
uses: actions/upload-pages-artifact@v3

.pyrit_conf_example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,20 @@ operation: op_trash_panda
117117
# Applies only to the pyrit_backend server.
118118
max_concurrent_scenario_runs: 3
119119

120+
# Custom Initializer Registration (REST API)
121+
# -------------------------------------------
122+
# When true, the REST API accepts POST /api/initializers to register custom
123+
# initializer scripts and DELETE /api/initializers/{name} to remove custom
124+
# initializers.
125+
#
126+
# ⚠️ WARNING: Enabling this allows arbitrary Python code execution on the
127+
# server via the REST API. Only enable on trusted networks.
128+
# The pyrit_backend default host is localhost, which limits exposure.
129+
# If you bind to 0.0.0.0, ensure you are on a trusted network.
130+
#
131+
# Default: false
132+
allow_custom_initializers: false
133+
120134
# Silent Mode
121135
# -----------
122136
# If true, suppresses print statements during initialization.

Makefile

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,25 @@ ty:
2020
# Build the full documentation site:
2121
# 1. Generate API reference JSON from Python source (griffe)
2222
# 2. Convert API JSON to MyST markdown pages
23-
# 3. Build the Jupyter Book site
23+
# 3. Build the Jupyter Book site (HTML only — fast, no LaTeX needed)
2424
# 4. Generate RSS feed
2525
docs-build:
2626
uv run python build_scripts/pydoc2json.py pyrit --submodules -o doc/_api/pyrit_all.json
2727
uv run python build_scripts/gen_api_md.py
2828
cd doc && uv run jupyter-book build --all --html
2929
uv run ./build_scripts/generate_rss.py
3030

31+
# Build the full documentation site including the PDF export.
32+
# Mirrors the ReadTheDocs build (.readthedocs.yaml) so CI catches PDF-only issues
33+
# such as missing images that the HTML-only build silently ignores.
34+
# Requires xelatex / latexmk on PATH (texlive-xetex + texlive-fonts-recommended +
35+
# texlive-plain-generic + latexmk on Ubuntu).
36+
docs-build-all:
37+
uv run python build_scripts/pydoc2json.py pyrit --submodules -o doc/_api/pyrit_all.json
38+
uv run python build_scripts/gen_api_md.py
39+
cd doc && uv run jupyter-book build --all --html --pdf
40+
uv run ./build_scripts/generate_rss.py
41+
3142
# Regenerate only the API reference pages (without building the full site)
3243
docs-api:
3344
uv run python build_scripts/pydoc2json.py pyrit --submodules -o doc/_api/pyrit_all.json

build_scripts/enforce_alembic_revision_immutability.py

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,86 @@
44
"""
55
Migration history must be immutable. This hook enforces that by preventing deletion or updates to migration scripts.
66
7-
Checks both staged changes (local pre-commit) and the full branch diff against origin/main (CI).
7+
Checks staged changes (local pre-commit), the full branch diff against origin/main (CI PRs),
8+
and the previous commit (CI merge-queue / push-to-main).
89
"""
910

11+
import os
1012
import subprocess
1113
import sys
1214

1315
_VERSIONS_PATH = "pyrit/memory/alembic/versions/"
1416

1517

16-
def _git(*args: str) -> str:
17-
result = subprocess.run(["git", *args], capture_output=True, text=True)
18-
return result.stdout.strip()
18+
def _git(*args: str) -> subprocess.CompletedProcess[str]:
19+
return subprocess.run(["git", *args], capture_output=True, text=True)
1920

2021

21-
def _has_non_add_changes(diff_spec: list[str]) -> bool:
22-
output = _git("diff", "--name-status", *diff_spec, "--", _VERSIONS_PATH)
23-
return any(line and not line.startswith("A") for line in output.splitlines())
22+
def _git_stdout(*args: str) -> str:
23+
return _git(*args).stdout.strip()
24+
25+
26+
def _get_violations(diff_spec: list[str]) -> list[str]:
27+
"""Return lines from ``git diff --name-status`` that are not pure additions."""
28+
output = _git_stdout("diff", "--name-status", *diff_spec, "--", _VERSIONS_PATH)
29+
return [line for line in output.splitlines() if line and not line.startswith("A")]
30+
31+
32+
def _in_ci() -> bool:
33+
return os.environ.get("CI", "").lower() in {"1", "true"} or "GITHUB_ACTIONS" in os.environ
34+
35+
36+
def _fail_ci(reason: str) -> bool:
37+
"""Fail closed in CI when we can't perform the check, pass through locally."""
38+
if _in_ci():
39+
print(f"[ERROR] Cannot verify alembic revision immutability: {reason}")
40+
print(" Ensure the CI checkout has full history (fetch-depth: 0).")
41+
return True
42+
return False
2443

2544

2645
def has_revision_violations() -> bool:
2746
# Local pre-commit: check staged changes
28-
if _has_non_add_changes(["--cached"]):
47+
violations = _get_violations(["--cached"])
48+
if violations:
49+
_report(violations)
2950
return True
3051

31-
# CI: check full branch diff against origin/main
32-
merge_base = _git("merge-base", "origin/main", "HEAD")
33-
return bool(merge_base and _has_non_add_changes([f"{merge_base}...HEAD"]))
52+
# CI (PR): diff branch against its merge-base with origin/main.
53+
# The three-dot syntax (A...B) resolves to ``git diff $(merge-base A B) B``
54+
# automatically, so we don't need a separate merge-base call. When
55+
# origin/main is missing (shallow clone) git exits non-zero.
56+
pr_diff = _git("diff", "--name-status", "origin/main...HEAD", "--", _VERSIONS_PATH)
57+
if pr_diff.returncode == 0:
58+
violations = [line for line in pr_diff.stdout.strip().splitlines() if line and not line.startswith("A")]
59+
if violations:
60+
_report(violations)
61+
return True
62+
elif _fail_ci("origin/main is not available (shallow clone?)"):
63+
return True
64+
65+
# CI (merge-queue / push-to-main): on main the branch *is* origin/main, so
66+
# the diff above is empty. Compare HEAD against its first parent to catch
67+
# deletions or modifications introduced by the merge commit itself.
68+
head_parent = _git("rev-parse", "--verify", "HEAD~1")
69+
if head_parent.returncode == 0:
70+
violations = _get_violations(["HEAD~1..HEAD"])
71+
if violations:
72+
_report(violations)
73+
return True
74+
elif _fail_ci("HEAD~1 is not available (shallow clone?)"):
75+
return True
76+
77+
return False
78+
79+
80+
def _report(violations: list[str]) -> None:
81+
print("[ERROR] Migration scripts can only be added, not modified or deleted.")
82+
print("The following disallowed changes were detected:")
83+
for v in violations:
84+
print(f" {v}")
3485

3586

3687
if __name__ == "__main__":
3788
if has_revision_violations():
38-
print("[ERROR] Migration scripts can only be added, not modified or deleted.")
3989
sys.exit(1)

doc/bibliography.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ All academic papers, research blogs, and technical reports referenced throughout
55
:::{dropdown} Citation Keys
66
:class: hidden-citations
77

8-
[@aakanksha2024multilingual; @adversaai2023universal; @andriushchenko2024tense; @anthropic2024manyshot; @aqrawi2024singleturncrescendo; @bethany2024mathprompt; @bryan2025agentictaxonomy; @bullwinkel2024airtlessons; @bullwinkel2025repeng; @bullwinkel2026trigger; @chao2023pair; @chao2024jailbreakbench; @chu2023harmfulqa; @cui2024orbench; @darkbench2025; @derczynski2024garak; @ding2023wolf; @embracethered2024unicode; @embracethered2025sneakybits; @ghosh2025aegis; @haider2024phi3safety; @han2024medsafetybench; @hines2024spotlighting; @ji2023beavertails; @ji2024pkusaferlhf; @jiang2025sosbench; @jones2025computeruse; @kingma2014adam; @li2024flipattack; @li2024saladbench; @li2024wmdp; @lin2023toxicchat; @lopez2024pyrit; @lv2024codechameleon; @mazeika2023tdc; @mazeika2024harmbench; @mckee2024transparency; @mehrotra2023tap; @microsoft2024skeletonkey; @palaskar2025vlsu; @pfohl2024equitymedqa; @promptfoo2025ccp; @robustintelligence2024bypass; @roccia2024promptintel; @rottger2023xstest; @russinovich2024crescendo; @russinovich2025price; @scheuerman2025transphobia; @shayegani2025computeruse; @shen2023donotanything; @sheshadri2024lat; @stok2023ansi; @tang2025multilingual; @tedeschi2024alert; @vantaylor2024socialbias; @vidgen2023simplesafetytests; @vidgen2024ailuminate; @wang2023decodingtrust; @wang2023donotanswer; @wei2023jailbroken; @xie2024sorrybench; @yu2023gptfuzzer; @yuan2023cipherchat; @zeng2024persuasion; @zhang2024cbtbench; @zou2023gcg]
8+
[@aakanksha2024multilingual; @adversaai2023universal; @andriushchenko2024tense; @anthropic2024manyshot; @aqrawi2024singleturncrescendo; @bethany2024mathprompt; @bhardwaj2023harmfulqa; @bryan2025agentictaxonomy; @bullwinkel2025airtlessons; @bullwinkel2025repeng; @bullwinkel2026trigger; @chao2023pair; @chao2024jailbreakbench; @cui2024orbench; @darkbench2025; @derczynski2024garak; @ding2023wolf; @embracethered2024unicode; @embracethered2025sneakybits; @ghosh2025aegis; @haider2024phi3safety; @han2024medsafetybench; @hines2024spotlighting; @ji2023beavertails; @ji2024pkusaferlhf; @jiang2025sosbench; @jones2025computeruse; @kingma2014adam; @li2024saladbench; @li2024wmdp; @lin2023toxicchat; @liu2024flipattack; @lopez2024pyrit; @lv2024codechameleon; @mazeika2023tdc; @mazeika2024harmbench; @mckee2024transparency; @mehrotra2023tap; @microsoft2024skeletonkey; @palaskar2025vlsu; @pfohl2024equitymedqa; @promptfoo2025ccp; @robustintelligence2024bypass; @roccia2024promptintel; @rottger2023xstest; @russinovich2024crescendo; @russinovich2025price; @scheuerman2025transphobia; @shayegani2025computeruse; @shen2023donotanything; @sheshadri2024lat; @stok2023ansi; @tan2026comicjailbreak; @tang2025multilingual; @tedeschi2024alert; @vantaylor2024socialbias; @vidgen2023simplesafetytests; @vidgen2024ailuminate; @wang2023decodingtrust; @wang2023donotanswer; @wei2023jailbroken; @xie2024sorrybench; @yu2023gptfuzzer; @yuan2023cipherchat; @zeng2024persuasion; @zhang2024cbtbench; @zou2023gcg]
99

1010
:::

doc/code/auxiliary_attacks/0_auxiliary_attacks.ipynb

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
"source": [
6565
"from pyrit.executor.attack import (\n",
6666
" AttackScoringConfig,\n",
67-
" ConsoleAttackResultPrinter,\n",
6867
" PromptSendingAttack,\n",
6968
")\n",
7069
"from pyrit.prompt_target import OpenAIChatTarget\n",
@@ -83,8 +82,7 @@
8382
"attack = PromptSendingAttack(objective_target=target, attack_scoring_config=scoring_config)\n",
8483
"result = await attack.execute_async(objective=objective) # type: ignore\n",
8584
"\n",
86-
"printer = ConsoleAttackResultPrinter()\n",
87-
"await printer.print_conversation_async(result=result) # type: ignore"
85+
"await output_attack_async(result)"
8886
]
8987
},
9088
{
@@ -180,7 +178,7 @@
180178
")\n",
181179
"\n",
182180
"result = await attack.execute_async(objective=objective) # type: ignore\n",
183-
"await printer.print_result_async(result=result) # type: ignore"
181+
"await output_attack_async(result)"
184182
]
185183
}
186184
],

doc/code/auxiliary_attacks/0_auxiliary_attacks.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,24 @@
88
# format_version: '1.3'
99
# jupytext_version: 1.17.3
1010
# ---
11-
1211
# %% [markdown]
1312
# # Auxiliary Attacks
14-
1513
# %% [markdown]
1614
# Auxiliary attacks cover a variety of techniques that do not fit into the core PyRIT functionality.
1715
#
1816
# These attack pipelines may be useful to run before orchestrating other attacks. For example, we provide an Azure Machine Learning (AML) pipeline for generating suffixes using the greedy coordinate gradient (GCG) [@zou2023gcg] algorithm.
19-
2017
# %% [markdown]
2118
# ## GCG Suffixes
22-
2319
# %% [markdown]
2420
# The [GCG demo notebook](1_gcg_azure_ml.ipynb) shows how to create an AML environment and submit a job that generates GCG suffixes, which can be appended to a base prompt to jailbreak a language model. In the example below, we compare the response generated by Phi-3-mini with and without a GCG suffix trained on that model.
2521
#
2622
# First, we send a harmful prompt to Phi-3-mini without a GCG suffix. If the environment variables `PHI3_MINI_ENDPOINT` and `PHI3_MINI_KEY` are not set in your .env file, the target will default to the model with `AZURE_ML_MANAGED_ENDPOINT` and `AZURE_ML_MANAGED_KEY`.
27-
2823
# %%
2924
from pyrit.executor.attack import (
3025
AttackScoringConfig,
31-
ConsoleAttackResultPrinter,
3226
PromptSendingAttack,
3327
)
28+
from pyrit.output import output_attack_async
3429
from pyrit.prompt_target import OpenAIChatTarget
3530
from pyrit.score import SelfAskRefusalScorer, TrueFalseInverterScorer
3631
from pyrit.setup import IN_MEMORY, initialize_pyrit_async
@@ -47,8 +42,7 @@
4742
attack = PromptSendingAttack(objective_target=target, attack_scoring_config=scoring_config)
4843
result = await attack.execute_async(objective=objective) # type: ignore
4944

50-
printer = ConsoleAttackResultPrinter()
51-
await printer.print_conversation_async(result=result) # type: ignore
45+
await output_attack_async(result)
5246

5347
# %% [markdown]
5448
# Next, let's apply a GCG suffix trained on Phi-3-mini to the base prompt using the `SuffixAppendConverter`.
@@ -73,4 +67,4 @@
7367
)
7468

7569
result = await attack.execute_async(objective=objective) # type: ignore
76-
await printer.print_result_async(result=result) # type: ignore
70+
await output_attack_async(result)

0 commit comments

Comments
 (0)