From da7672c4aef493c7ad207467ff2da554780e7f3c Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 19 Aug 2026 15:54:25 -0400 Subject: [PATCH 01/12] DEV: add adversarial benchmark pipeline prototype Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .azuredevops/adversarial-benchmark.yml | 231 +++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 .azuredevops/adversarial-benchmark.yml diff --git a/.azuredevops/adversarial-benchmark.yml b/.azuredevops/adversarial-benchmark.yml new file mode 100644 index 0000000000..dc0aac5684 --- /dev/null +++ b/.azuredevops/adversarial-benchmark.yml @@ -0,0 +1,231 @@ +# Manually triggered prototype for comparing adversarial models and technique sets. +# Completed results are retained with the Azure DevOps pipeline run as an artifact. + +trigger: none +pr: none + +parameters: + - name: objectiveTarget + displayName: Objective target registry name + type: string + default: openai_chat + - name: adversarialTargets + displayName: Space-separated adversarial target registry names + type: string + default: azure_openai_gpt4o azure_gpt4o_unsafe_chat + - name: techniques + displayName: Space-separated technique names + type: string + default: >- + role_play_movie_script role_play_video_game role_play_trivia_game + role_play_persuasion role_play_persuasion_written red_teaming + context_compliance tap crescendo_simulated + - name: datasetName + displayName: Dataset name + type: string + default: harmbench + - name: maxDatasetSize + displayName: Maximum objectives + type: number + default: 1 + - name: maxConcurrency + displayName: Maximum concurrency + type: number + default: 1 + +jobs: + - job: AdversarialBenchmark + displayName: Run adversarial model benchmark + timeoutInMinutes: 360 + pool: + vmImage: ubuntu-latest + + steps: + - checkout: self + fetchDepth: 1 + + - task: UsePythonVersion@0 + displayName: Use Python 3.12 + inputs: + versionSpec: "3.12" + addToPath: true + + - bash: | + mkdir -p ~/.pyrit + mkdir -p "$(Build.ArtifactStagingDirectory)/adversarial-benchmark" + displayName: Create configuration and artifact directories + + - task: AzureKeyVault@2 + displayName: Retrieve PyRIT test environment + inputs: + azureSubscription: integration-test-service-connection + KeyVaultName: pyrit-environment + SecretsFilter: env-global + RunAsPreJob: false + + - bash: | + set -euo pipefail + python -c " + import os + from pathlib import Path + + secret = os.environ.get('PYRIT_TEST_SECRET') + if not secret: + raise ValueError('PYRIT_TEST_SECRET is not set') + config_dir = Path.home() / '.pyrit' + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / '.env').write_text(secret, encoding='utf-8') + " + cp build_scripts/env_local_integration_test ~/.pyrit/.env.local + displayName: Configure PyRIT environment + env: + PYRIT_TEST_SECRET: $(env-global) + + - bash: | + set -euo pipefail + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "##vso[task.prependpath]$HOME/.local/bin" + displayName: Install uv + + - bash: uv sync + displayName: Install PyRIT + + - task: AzureCLI@2 + displayName: Run benchmark and capture result snapshot + inputs: + azureSubscription: integration-test-service-connection + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + set -euo pipefail + + artifact_dir="$BUILD_ARTIFACTSTAGINGDIRECTORY/adversarial-benchmark" + config_file="$artifact_dir/benchmark.pyrit_conf" + scenario_log="$artifact_dir/scenario.log" + mkdir -p "$artifact_dir" + + cat > "$config_file" <<'EOF' + memory_db_type: sqlite + silent: false + initializers: + - name: target + args: + tags: + - default + - scorer + - name: scorer + - name: technique + EOF + + az account get-access-token \ + --scope https://cognitiveservices.azure.com/.default \ + --output none + az account get-access-token \ + --scope https://ml.azure.com/.default \ + --output none + + uv run pyrit_scan \ + --start-server \ + --config-file "$config_file" \ + --startup-timeout 180 + + stop_server() { + uv run pyrit_scan --stop-server >/dev/null 2>&1 || true + } + trap stop_server EXIT + + read -r -a adversarial_targets <<< "$ADVERSARIAL_TARGETS_INPUT" + read -r -a techniques <<< "$TECHNIQUES_INPUT" + + set +e + uv run pyrit_scan benchmark.adversarial \ + --target "$OBJECTIVE_TARGET_INPUT" \ + --adversarial-targets "${adversarial_targets[@]}" \ + --techniques "${techniques[@]}" \ + --dataset-names "$DATASET_NAME_INPUT" \ + --max-dataset-size "$MAX_DATASET_SIZE_INPUT" \ + --max-concurrency "$MAX_CONCURRENCY_INPUT" \ + 2>&1 | tee "$scenario_log" + benchmark_exit_code=${PIPESTATUS[0]} + set -e + + result_id="$( + sed -n 's/^[[:space:]]*Result ID:[[:space:]]*//p' "$scenario_log" | + tail -n 1 | + tr -d '\r' + )" + run_status="$( + sed -n 's/^[[:space:]]*Status:[[:space:]]*//p' "$scenario_log" | + tail -n 1 | + tr -d '\r' + )" + + export BENCHMARK_EXIT_CODE="$benchmark_exit_code" + export RESULT_ID="$result_id" + export RUN_STATUS="$run_status" + export ARTIFACT_DIR="$artifact_dir" + + python - <<'PY' + import json + import os + from datetime import datetime, timezone + from pathlib import Path + + manifest = { + "pipeline_build_id": os.environ.get("BUILD_BUILDID"), + "source_branch": os.environ.get("BUILD_SOURCEBRANCH"), + "source_commit": os.environ.get("BUILD_SOURCEVERSION"), + "created_at": datetime.now(timezone.utc).isoformat(), + "scenario": "benchmark.adversarial", + "scenario_result_id": os.environ.get("RESULT_ID") or None, + "status": os.environ.get("RUN_STATUS") or None, + "exit_code": int(os.environ["BENCHMARK_EXIT_CODE"]), + "objective_target": os.environ["OBJECTIVE_TARGET_INPUT"], + "adversarial_targets": os.environ["ADVERSARIAL_TARGETS_INPUT"].split(), + "techniques": os.environ["TECHNIQUES_INPUT"].split(), + "dataset_name": os.environ["DATASET_NAME_INPUT"], + "max_dataset_size": int(os.environ["MAX_DATASET_SIZE_INPUT"]), + "max_concurrency": int(os.environ["MAX_CONCURRENCY_INPUT"]), + } + output = Path(os.environ["ARTIFACT_DIR"]) / "run-manifest.json" + output.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + PY + + if [[ -n "$result_id" && "$benchmark_exit_code" -eq 0 ]]; then + uv run pyrit_scan \ + --scenario-results "$result_id" \ + --view overview \ + > "$artifact_dir/overview.txt" 2>&1 || true + uv run pyrit_scan \ + --scenario-results "$result_id" \ + --view attacks \ + > "$artifact_dir/attacks.txt" 2>&1 || true + fi + + if [[ -f dbdata/pyrit.db ]]; then + cp dbdata/pyrit.db "$artifact_dir/pyrit.db" + fi + if [[ -f /tmp/pyrit_backend.log ]]; then + cp /tmp/pyrit_backend.log "$artifact_dir/backend.log" + fi + + exit "$benchmark_exit_code" + env: + OBJECTIVE_TARGET_INPUT: "${{ parameters.objectiveTarget }}" + ADVERSARIAL_TARGETS_INPUT: "${{ parameters.adversarialTargets }}" + TECHNIQUES_INPUT: "${{ parameters.techniques }}" + DATASET_NAME_INPUT: "${{ parameters.datasetName }}" + MAX_DATASET_SIZE_INPUT: "${{ parameters.maxDatasetSize }}" + MAX_CONCURRENCY_INPUT: "${{ parameters.maxConcurrency }}" + + - task: PublishPipelineArtifact@1 + displayName: Publish benchmark snapshot + condition: always() + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/adversarial-benchmark + artifactName: adversarial-benchmark-$(Build.BuildId) + publishLocation: pipeline + + - bash: rm -f ~/.pyrit/.env ~/.pyrit/.env.local + displayName: Remove local credential files + condition: always() From 6cd70c781d9cdca85468c6016c4531d25671f116 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 19 Aug 2026 17:38:11 -0400 Subject: [PATCH 02/12] DEV: make adversarial benchmark pipeline configurable Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .azuredevops/adversarial-benchmark.yml | 66 ++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/.azuredevops/adversarial-benchmark.yml b/.azuredevops/adversarial-benchmark.yml index dc0aac5684..42c13ab791 100644 --- a/.azuredevops/adversarial-benchmark.yml +++ b/.azuredevops/adversarial-benchmark.yml @@ -13,8 +13,17 @@ parameters: displayName: Space-separated adversarial target registry names type: string default: azure_openai_gpt4o azure_gpt4o_unsafe_chat - - name: techniques - displayName: Space-separated technique names + - name: techniquePreset + displayName: Technique set + type: string + default: union + values: + - current-default + - candidate + - union + - custom + - name: customTechniques + displayName: Custom space-separated techniques (used when Technique set is custom) type: string default: >- role_play_movie_script role_play_video_game role_play_trivia_game @@ -32,6 +41,19 @@ parameters: displayName: Maximum concurrency type: number default: 1 + - name: maxRetries + displayName: Maximum scenario retries + type: number + default: 0 + - name: logLevel + displayName: Log level + type: string + default: INFO + values: + - DEBUG + - INFO + - WARNING + - ERROR jobs: - job: AdversarialBenchmark @@ -127,6 +149,7 @@ jobs: uv run pyrit_scan \ --start-server \ --config-file "$config_file" \ + --log-level "$LOG_LEVEL_INPUT" \ --startup-timeout 180 stop_server() { @@ -134,17 +157,44 @@ jobs: } trap stop_server EXIT + case "$TECHNIQUE_PRESET_INPUT" in + current-default) + resolved_techniques="role_play_movie_script role_play_video_game role_play_trivia_game role_play_persuasion role_play_persuasion_written red_teaming context_compliance" + ;; + candidate) + resolved_techniques="role_play_movie_script red_teaming context_compliance tap crescendo_simulated" + ;; + union) + resolved_techniques="role_play_movie_script role_play_video_game role_play_trivia_game role_play_persuasion role_play_persuasion_written red_teaming context_compliance tap crescendo_simulated" + ;; + custom) + resolved_techniques="$CUSTOM_TECHNIQUES_INPUT" + ;; + *) + echo "Unknown technique preset: $TECHNIQUE_PRESET_INPUT" >&2 + exit 2 + ;; + esac + + if [[ -z "${resolved_techniques// }" ]]; then + echo "At least one technique is required." >&2 + exit 2 + fi + read -r -a adversarial_targets <<< "$ADVERSARIAL_TARGETS_INPUT" - read -r -a techniques <<< "$TECHNIQUES_INPUT" + read -r -a techniques <<< "$resolved_techniques" + export RESOLVED_TECHNIQUES_INPUT="$resolved_techniques" set +e uv run pyrit_scan benchmark.adversarial \ + --log-level "$LOG_LEVEL_INPUT" \ --target "$OBJECTIVE_TARGET_INPUT" \ --adversarial-targets "${adversarial_targets[@]}" \ --techniques "${techniques[@]}" \ --dataset-names "$DATASET_NAME_INPUT" \ --max-dataset-size "$MAX_DATASET_SIZE_INPUT" \ --max-concurrency "$MAX_CONCURRENCY_INPUT" \ + --max-retries "$MAX_RETRIES_INPUT" \ 2>&1 | tee "$scenario_log" benchmark_exit_code=${PIPESTATUS[0]} set -e @@ -182,10 +232,13 @@ jobs: "exit_code": int(os.environ["BENCHMARK_EXIT_CODE"]), "objective_target": os.environ["OBJECTIVE_TARGET_INPUT"], "adversarial_targets": os.environ["ADVERSARIAL_TARGETS_INPUT"].split(), - "techniques": os.environ["TECHNIQUES_INPUT"].split(), + "technique_preset": os.environ["TECHNIQUE_PRESET_INPUT"], + "techniques": os.environ["RESOLVED_TECHNIQUES_INPUT"].split(), "dataset_name": os.environ["DATASET_NAME_INPUT"], "max_dataset_size": int(os.environ["MAX_DATASET_SIZE_INPUT"]), "max_concurrency": int(os.environ["MAX_CONCURRENCY_INPUT"]), + "max_retries": int(os.environ["MAX_RETRIES_INPUT"]), + "log_level": os.environ["LOG_LEVEL_INPUT"], } output = Path(os.environ["ARTIFACT_DIR"]) / "run-manifest.json" output.write_text(json.dumps(manifest, indent=2), encoding="utf-8") @@ -213,10 +266,13 @@ jobs: env: OBJECTIVE_TARGET_INPUT: "${{ parameters.objectiveTarget }}" ADVERSARIAL_TARGETS_INPUT: "${{ parameters.adversarialTargets }}" - TECHNIQUES_INPUT: "${{ parameters.techniques }}" + TECHNIQUE_PRESET_INPUT: "${{ parameters.techniquePreset }}" + CUSTOM_TECHNIQUES_INPUT: "${{ parameters.customTechniques }}" DATASET_NAME_INPUT: "${{ parameters.datasetName }}" MAX_DATASET_SIZE_INPUT: "${{ parameters.maxDatasetSize }}" MAX_CONCURRENCY_INPUT: "${{ parameters.maxConcurrency }}" + MAX_RETRIES_INPUT: "${{ parameters.maxRetries }}" + LOG_LEVEL_INPUT: "${{ parameters.logLevel }}" - task: PublishPipelineArtifact@1 displayName: Publish benchmark snapshot From f2adb3bf6e38c053fac05c4b434d6bb34124edf3 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Wed, 19 Aug 2026 17:59:13 -0400 Subject: [PATCH 03/12] DEV: add balanced HarmBench benchmark dataset Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .azuredevops/adversarial-benchmark.yml | 8 +- .../prepare_adversarial_benchmark_dataset.py | 76 +++++++++++++++++++ ...t_prepare_adversarial_benchmark_dataset.py | 50 ++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 build_scripts/prepare_adversarial_benchmark_dataset.py create mode 100644 tests/unit/build_scripts/test_prepare_adversarial_benchmark_dataset.py diff --git a/.azuredevops/adversarial-benchmark.yml b/.azuredevops/adversarial-benchmark.yml index 42c13ab791..7be27b19e9 100644 --- a/.azuredevops/adversarial-benchmark.yml +++ b/.azuredevops/adversarial-benchmark.yml @@ -32,11 +32,11 @@ parameters: - name: datasetName displayName: Dataset name type: string - default: harmbench + default: harmbench-balanced-14-v1 - name: maxDatasetSize displayName: Maximum objectives type: number - default: 1 + default: 14 - name: maxConcurrency displayName: Maximum concurrency type: number @@ -146,6 +146,10 @@ jobs: --scope https://ml.azure.com/.default \ --output none + if [[ "$DATASET_NAME_INPUT" == "harmbench-balanced-14-v1" ]]; then + uv run python build_scripts/prepare_adversarial_benchmark_dataset.py + fi + uv run pyrit_scan \ --start-server \ --config-file "$config_file" \ diff --git a/build_scripts/prepare_adversarial_benchmark_dataset.py b/build_scripts/prepare_adversarial_benchmark_dataset.py new file mode 100644 index 0000000000..4bc32f64c3 --- /dev/null +++ b/build_scripts/prepare_adversarial_benchmark_dataset.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Prepare the deterministic HarmBench subset used by the adversarial benchmark pipeline.""" + +import asyncio + +from pyrit.datasets.seed_datasets.remote.harmbench_dataset import _HarmBenchDataset +from pyrit.memory import SQLiteMemory +from pyrit.models import SeedDataset + +DATASET_NAME = "harmbench-balanced-14-v1" + +_BEHAVIOR_IDS = ( + "morphine_homebake_detailed_instructions", + "anfo_bomb_home_heat_level", + "passage_da_vinci_code", + "lyrics_viva_la_vida", + "rootkit_code_obfuscation_mac_osx_1", + "logs_deletion_sql_injection_template", + "student_harassment_bullying_encouragement", + "jewish_classmate_insults", + "sexual_assault_romanticization_post", + "pornographic_snuff_film", + "airport_bomb_smuggling_plan", + "emergency_communications_disruption", + "fauci_recommendation_persuasive_reply", + "election_fraud_2020_flip_result_reply_1", +) + + +def _build_balanced_dataset(*, source_dataset: SeedDataset) -> SeedDataset: + """Select the benchmark objectives from a loaded HarmBench dataset.""" + seeds_by_id = { + str(seed.metadata["BehaviorID"]): seed + for seed in source_dataset.objectives + if seed.metadata and "BehaviorID" in seed.metadata + } + missing = [behavior_id for behavior_id in _BEHAVIOR_IDS if behavior_id not in seeds_by_id] + if missing: + raise ValueError(f"HarmBench is missing required benchmark behavior IDs: {missing}") + + selected = [] + for behavior_id in _BEHAVIOR_IDS: + seed = seeds_by_id[behavior_id].model_copy(deep=True) + seed.dataset_name = DATASET_NAME + seed.name = f"HarmBench: {behavior_id}" + selected.append(seed) + + return SeedDataset( + dataset_name=DATASET_NAME, + name="HarmBench Balanced 14", + description="A deterministic 14-objective HarmBench subset with two objectives per semantic category.", + seeds=selected, + ) + + +async def _main_async() -> None: + """Load, select, and persist the benchmark dataset.""" + source_dataset = await _HarmBenchDataset().fetch_dataset_async() + benchmark_dataset = _build_balanced_dataset(source_dataset=source_dataset) + memory = SQLiteMemory() + await memory.add_seed_datasets_to_memory_async( + datasets=[benchmark_dataset], + added_by="prepare_adversarial_benchmark_dataset", + ) + print(f"Loaded {len(benchmark_dataset.seeds)} objectives into dataset '{DATASET_NAME}'.") + + +def main() -> None: + """Run the dataset preparation script.""" + asyncio.run(_main_async()) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/build_scripts/test_prepare_adversarial_benchmark_dataset.py b/tests/unit/build_scripts/test_prepare_adversarial_benchmark_dataset.py new file mode 100644 index 0000000000..e3b0cc3dd1 --- /dev/null +++ b/tests/unit/build_scripts/test_prepare_adversarial_benchmark_dataset.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import pytest + +from build_scripts.prepare_adversarial_benchmark_dataset import ( + _BEHAVIOR_IDS, + DATASET_NAME, + _build_balanced_dataset, +) +from pyrit.models import SeedDataset, SeedObjective + + +def _source_dataset(*, behavior_ids: list[str]) -> SeedDataset: + return SeedDataset( + dataset_name="harmbench", + seeds=[ + SeedObjective( + value=f"Objective for {behavior_id}", + dataset_name="harmbench", + metadata={"BehaviorID": behavior_id}, + ) + for behavior_id in behavior_ids + ], + ) + + +def test_build_balanced_dataset_selects_expected_order() -> None: + source = _source_dataset(behavior_ids=["extra", *reversed(_BEHAVIOR_IDS)]) + + result = _build_balanced_dataset(source_dataset=source) + + assert result.dataset_name == DATASET_NAME + assert [seed.metadata["BehaviorID"] for seed in result.objectives] == list(_BEHAVIOR_IDS) + assert all(seed.dataset_name == DATASET_NAME for seed in result.objectives) + + +def test_build_balanced_dataset_does_not_mutate_source() -> None: + source = _source_dataset(behavior_ids=list(_BEHAVIOR_IDS)) + + _build_balanced_dataset(source_dataset=source) + + assert all(seed.dataset_name == "harmbench" for seed in source.objectives) + + +def test_build_balanced_dataset_raises_for_missing_behavior() -> None: + source = _source_dataset(behavior_ids=list(_BEHAVIOR_IDS[:-1])) + + with pytest.raises(ValueError, match=_BEHAVIOR_IDS[-1]): + _build_balanced_dataset(source_dataset=source) From 821733f18c9c00282325efd3998f2a17268cd6db Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 20 Aug 2026 10:28:42 -0400 Subject: [PATCH 04/12] FIX: run benchmark dataset preparation as module Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .azuredevops/adversarial-benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azuredevops/adversarial-benchmark.yml b/.azuredevops/adversarial-benchmark.yml index 7be27b19e9..3c20e391ef 100644 --- a/.azuredevops/adversarial-benchmark.yml +++ b/.azuredevops/adversarial-benchmark.yml @@ -147,7 +147,7 @@ jobs: --output none if [[ "$DATASET_NAME_INPUT" == "harmbench-balanced-14-v1" ]]; then - uv run python build_scripts/prepare_adversarial_benchmark_dataset.py + uv run python -m build_scripts.prepare_adversarial_benchmark_dataset fi uv run pyrit_scan \ From 88e69190bad28220d3837e333152eae27c7d4c76 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 20 Aug 2026 12:21:52 -0400 Subject: [PATCH 05/12] FIX: make benchmark artifacts reliable Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .azuredevops/adversarial-benchmark.yml | 234 +++++++++++++----- .../prepare_adversarial_benchmark_dataset.py | 11 +- 2 files changed, 184 insertions(+), 61 deletions(-) diff --git a/.azuredevops/adversarial-benchmark.yml b/.azuredevops/adversarial-benchmark.yml index 3c20e391ef..147260a0e5 100644 --- a/.azuredevops/adversarial-benchmark.yml +++ b/.azuredevops/adversarial-benchmark.yml @@ -75,6 +75,7 @@ jobs: - bash: | mkdir -p ~/.pyrit mkdir -p "$(Build.ArtifactStagingDirectory)/adversarial-benchmark" + echo "initialize" > "$(Build.ArtifactStagingDirectory)/adversarial-benchmark/phase.txt" displayName: Create configuration and artifact directories - task: AzureKeyVault@2 @@ -87,6 +88,7 @@ jobs: - bash: | set -euo pipefail + echo "configure-environment" > "$(Build.ArtifactStagingDirectory)/adversarial-benchmark/phase.txt" python -c " import os from pathlib import Path @@ -105,11 +107,15 @@ jobs: - bash: | set -euo pipefail + echo "install-uv" > "$(Build.ArtifactStagingDirectory)/adversarial-benchmark/phase.txt" curl -LsSf https://astral.sh/uv/install.sh | sh echo "##vso[task.prependpath]$HOME/.local/bin" displayName: Install uv - - bash: uv sync + - bash: | + set -euo pipefail + echo "install-pyrit" > "$(Build.ArtifactStagingDirectory)/adversarial-benchmark/phase.txt" + uv sync displayName: Install PyRIT - task: AzureCLI@2 @@ -123,9 +129,25 @@ jobs: artifact_dir="$BUILD_ARTIFACTSTAGINGDIRECTORY/adversarial-benchmark" config_file="$artifact_dir/benchmark.pyrit_conf" + execution_log="$artifact_dir/execution.log" scenario_log="$artifact_dir/scenario.log" mkdir -p "$artifact_dir" + exec > >(tee -a "$execution_log") 2>&1 + + server_started=false + stop_server() { + if [[ "$server_started" == "true" ]]; then + uv run pyrit_scan stop-server >/dev/null 2>&1 || true + fi + } + record_exit() { + exit_code=$? + echo "$exit_code" > "$artifact_dir/execution-exit-code.txt" + stop_server + } + trap record_exit EXIT + echo "write-config" > "$artifact_dir/phase.txt" cat > "$config_file" <<'EOF' memory_db_type: sqlite silent: false @@ -139,6 +161,7 @@ jobs: - name: technique EOF + echo "authenticate" > "$artifact_dir/phase.txt" az account get-access-token \ --scope https://cognitiveservices.azure.com/.default \ --output none @@ -147,29 +170,27 @@ jobs: --output none if [[ "$DATASET_NAME_INPUT" == "harmbench-balanced-14-v1" ]]; then + echo "prepare-dataset" > "$artifact_dir/phase.txt" uv run python -m build_scripts.prepare_adversarial_benchmark_dataset fi + echo "start-server" > "$artifact_dir/phase.txt" uv run pyrit_scan \ --start-server \ --config-file "$config_file" \ --log-level "$LOG_LEVEL_INPUT" \ --startup-timeout 180 - - stop_server() { - uv run pyrit_scan --stop-server >/dev/null 2>&1 || true - } - trap stop_server EXIT + server_started=true case "$TECHNIQUE_PRESET_INPUT" in current-default) - resolved_techniques="role_play_movie_script role_play_video_game role_play_trivia_game role_play_persuasion role_play_persuasion_written red_teaming context_compliance" + resolved_techniques="light" ;; candidate) resolved_techniques="role_play_movie_script red_teaming context_compliance tap crescendo_simulated" ;; union) - resolved_techniques="role_play_movie_script role_play_video_game role_play_trivia_game role_play_persuasion role_play_persuasion_written red_teaming context_compliance tap crescendo_simulated" + resolved_techniques="light tap crescendo_simulated" ;; custom) resolved_techniques="$CUSTOM_TECHNIQUES_INPUT" @@ -187,8 +208,9 @@ jobs: read -r -a adversarial_targets <<< "$ADVERSARIAL_TARGETS_INPUT" read -r -a techniques <<< "$resolved_techniques" - export RESOLVED_TECHNIQUES_INPUT="$resolved_techniques" + memory_labels="$(printf '{"pipeline_build_id":"%s"}' "$BUILD_BUILDID")" + echo "run-benchmark" > "$artifact_dir/phase.txt" set +e uv run pyrit_scan benchmark.adversarial \ --log-level "$LOG_LEVEL_INPUT" \ @@ -199,73 +221,83 @@ jobs: --max-dataset-size "$MAX_DATASET_SIZE_INPUT" \ --max-concurrency "$MAX_CONCURRENCY_INPUT" \ --max-retries "$MAX_RETRIES_INPUT" \ + --memory-labels "$memory_labels" \ 2>&1 | tee "$scenario_log" benchmark_exit_code=${PIPESTATUS[0]} set -e + echo "$benchmark_exit_code" > "$artifact_dir/benchmark-exit-code.txt" - result_id="$( - sed -n 's/^[[:space:]]*Result ID:[[:space:]]*//p' "$scenario_log" | - tail -n 1 | - tr -d '\r' - )" - run_status="$( - sed -n 's/^[[:space:]]*Status:[[:space:]]*//p' "$scenario_log" | - tail -n 1 | - tr -d '\r' - )" - - export BENCHMARK_EXIT_CODE="$benchmark_exit_code" - export RESULT_ID="$result_id" - export RUN_STATUS="$run_status" - export ARTIFACT_DIR="$artifact_dir" - - python - <<'PY' + echo "read-result" > "$artifact_dir/phase.txt" + export PIPELINE_BUILD_ID="$BUILD_BUILDID" + export SCENARIO_RESULT_OUTPUT="$artifact_dir/scenario-result.json" + uv run python - <<'PY' + import asyncio import json import os - from datetime import datetime, timezone from pathlib import Path - manifest = { - "pipeline_build_id": os.environ.get("BUILD_BUILDID"), - "source_branch": os.environ.get("BUILD_SOURCEBRANCH"), - "source_commit": os.environ.get("BUILD_SOURCEVERSION"), - "created_at": datetime.now(timezone.utc).isoformat(), - "scenario": "benchmark.adversarial", - "scenario_result_id": os.environ.get("RESULT_ID") or None, - "status": os.environ.get("RUN_STATUS") or None, - "exit_code": int(os.environ["BENCHMARK_EXIT_CODE"]), - "objective_target": os.environ["OBJECTIVE_TARGET_INPUT"], - "adversarial_targets": os.environ["ADVERSARIAL_TARGETS_INPUT"].split(), - "technique_preset": os.environ["TECHNIQUE_PRESET_INPUT"], - "techniques": os.environ["RESOLVED_TECHNIQUES_INPUT"].split(), - "dataset_name": os.environ["DATASET_NAME_INPUT"], - "max_dataset_size": int(os.environ["MAX_DATASET_SIZE_INPUT"]), - "max_concurrency": int(os.environ["MAX_CONCURRENCY_INPUT"]), - "max_retries": int(os.environ["MAX_RETRIES_INPUT"]), - "log_level": os.environ["LOG_LEVEL_INPUT"], - } - output = Path(os.environ["ARTIFACT_DIR"]) / "run-manifest.json" - output.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + from pyrit.memory import CentralMemory + from pyrit.setup import SQLITE, initialize_pyrit_async + + + async def main_async() -> None: + await initialize_pyrit_async( + memory_db_type=SQLITE, + load_defaults=False, + env_files=[], + silent=True, + ) + results = CentralMemory.get_memory_instance().get_scenario_results( + labels={"pipeline_build_id": os.environ["PIPELINE_BUILD_ID"]}, + limit=1, + ) + result = results[0] if results else None + payload = { + "scenario_result_id": str(result.id) if result else None, + "status": result.scenario_run_state.value if result else None, + } + Path(os.environ["SCENARIO_RESULT_OUTPUT"]).write_text( + json.dumps(payload, indent=2), + encoding="utf-8", + ) + + + asyncio.run(main_async()) PY - if [[ -n "$result_id" && "$benchmark_exit_code" -eq 0 ]]; then + result_id="$( + python -c \ + 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["scenario_result_id"] or "")' \ + "$artifact_dir/scenario-result.json" + )" + run_status="$( + python -c \ + 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["status"] or "")' \ + "$artifact_dir/scenario-result.json" + )" + + if [[ "$benchmark_exit_code" -eq 0 ]]; then + if [[ -z "$result_id" || "$run_status" != "COMPLETED" ]]; then + echo "Benchmark exited successfully without a completed persisted scenario result." >&2 + exit 3 + fi + + echo "export-results" > "$artifact_dir/phase.txt" uv run pyrit_scan \ - --scenario-results "$result_id" \ + scenario-results "$result_id" \ --view overview \ - > "$artifact_dir/overview.txt" 2>&1 || true + > "$artifact_dir/overview.txt" 2>&1 uv run pyrit_scan \ - --scenario-results "$result_id" \ + scenario-results "$result_id" \ --view attacks \ - > "$artifact_dir/attacks.txt" 2>&1 || true + > "$artifact_dir/attacks.txt" 2>&1 fi - if [[ -f dbdata/pyrit.db ]]; then - cp dbdata/pyrit.db "$artifact_dir/pyrit.db" - fi - if [[ -f /tmp/pyrit_backend.log ]]; then - cp /tmp/pyrit_backend.log "$artifact_dir/backend.log" + if [[ "$benchmark_exit_code" -eq 0 ]]; then + echo "complete" > "$artifact_dir/phase.txt" + else + echo "benchmark-failed" > "$artifact_dir/phase.txt" fi - exit "$benchmark_exit_code" env: OBJECTIVE_TARGET_INPUT: "${{ parameters.objectiveTarget }}" @@ -278,6 +310,90 @@ jobs: MAX_RETRIES_INPUT: "${{ parameters.maxRetries }}" LOG_LEVEL_INPUT: "${{ parameters.logLevel }}" + - bash: | + set -euo pipefail + artifact_dir="$(Build.ArtifactStagingDirectory)/adversarial-benchmark" + mkdir -p "$artifact_dir" + + if [[ -f dbdata/pyrit.db ]]; then + cp dbdata/pyrit.db "$artifact_dir/pyrit.db" + fi + if [[ -f /tmp/pyrit_backend.log ]]; then + cp /tmp/pyrit_backend.log "$artifact_dir/backend.log" + fi + + export ARTIFACT_DIR="$artifact_dir" + export JOB_STATUS="${AGENT_JOBSTATUS:-unknown}" + export OBJECTIVE_TARGET_INPUT="${{ parameters.objectiveTarget }}" + export ADVERSARIAL_TARGETS_INPUT="${{ parameters.adversarialTargets }}" + export TECHNIQUE_PRESET_INPUT="${{ parameters.techniquePreset }}" + export CUSTOM_TECHNIQUES_INPUT="${{ parameters.customTechniques }}" + export DATASET_NAME_INPUT="${{ parameters.datasetName }}" + export MAX_DATASET_SIZE_INPUT="${{ parameters.maxDatasetSize }}" + export MAX_CONCURRENCY_INPUT="${{ parameters.maxConcurrency }}" + export MAX_RETRIES_INPUT="${{ parameters.maxRetries }}" + export LOG_LEVEL_INPUT="${{ parameters.logLevel }}" + + python - <<'PY' + import json + import os + from datetime import datetime, timezone + from pathlib import Path + + artifact_dir = Path(os.environ["ARTIFACT_DIR"]) + + + def read_text(name: str) -> str | None: + path = artifact_dir / name + return path.read_text(encoding="utf-8").strip() if path.exists() else None + + + result_path = artifact_dir / "scenario-result.json" + result = json.loads(result_path.read_text(encoding="utf-8")) if result_path.exists() else {} + preset = os.environ["TECHNIQUE_PRESET_INPUT"] + presets = { + "current-default": ["light"], + "candidate": [ + "role_play_movie_script", + "red_teaming", + "context_compliance", + "tap", + "crescendo_simulated", + ], + "union": ["light", "tap", "crescendo_simulated"], + } + techniques = presets.get(preset, os.environ["CUSTOM_TECHNIQUES_INPUT"].split()) + + manifest = { + "pipeline_build_id": os.environ.get("BUILD_BUILDID"), + "source_branch": os.environ.get("BUILD_SOURCEBRANCH"), + "source_commit": os.environ.get("BUILD_SOURCEVERSION"), + "created_at": datetime.now(timezone.utc).isoformat(), + "scenario": "benchmark.adversarial", + "scenario_result_id": result.get("scenario_result_id"), + "scenario_status": result.get("status"), + "job_status": os.environ["JOB_STATUS"], + "last_phase": read_text("phase.txt"), + "execution_exit_code": read_text("execution-exit-code.txt"), + "benchmark_exit_code": read_text("benchmark-exit-code.txt"), + "objective_target": os.environ["OBJECTIVE_TARGET_INPUT"], + "adversarial_targets": os.environ["ADVERSARIAL_TARGETS_INPUT"].split(), + "technique_preset": preset, + "techniques": techniques, + "dataset_name": os.environ["DATASET_NAME_INPUT"], + "max_dataset_size": int(os.environ["MAX_DATASET_SIZE_INPUT"]), + "max_concurrency": int(os.environ["MAX_CONCURRENCY_INPUT"]), + "max_retries": int(os.environ["MAX_RETRIES_INPUT"]), + "log_level": os.environ["LOG_LEVEL_INPUT"], + } + (artifact_dir / "run-manifest.json").write_text( + json.dumps(manifest, indent=2), + encoding="utf-8", + ) + PY + displayName: Collect benchmark diagnostics + condition: always() + - task: PublishPipelineArtifact@1 displayName: Publish benchmark snapshot condition: always() diff --git a/build_scripts/prepare_adversarial_benchmark_dataset.py b/build_scripts/prepare_adversarial_benchmark_dataset.py index 4bc32f64c3..c391940fe8 100644 --- a/build_scripts/prepare_adversarial_benchmark_dataset.py +++ b/build_scripts/prepare_adversarial_benchmark_dataset.py @@ -6,8 +6,9 @@ import asyncio from pyrit.datasets.seed_datasets.remote.harmbench_dataset import _HarmBenchDataset -from pyrit.memory import SQLiteMemory +from pyrit.memory import CentralMemory from pyrit.models import SeedDataset +from pyrit.setup import SQLITE, initialize_pyrit_async DATASET_NAME = "harmbench-balanced-14-v1" @@ -57,9 +58,15 @@ def _build_balanced_dataset(*, source_dataset: SeedDataset) -> SeedDataset: async def _main_async() -> None: """Load, select, and persist the benchmark dataset.""" + await initialize_pyrit_async( + memory_db_type=SQLITE, + load_defaults=False, + env_files=[], + silent=True, + ) source_dataset = await _HarmBenchDataset().fetch_dataset_async() benchmark_dataset = _build_balanced_dataset(source_dataset=source_dataset) - memory = SQLiteMemory() + memory = CentralMemory.get_memory_instance() await memory.add_seed_datasets_to_memory_async( datasets=[benchmark_dataset], added_by="prepare_adversarial_benchmark_dataset", From 7f7b98a8cfd48cebbde9984d3d5bebd8557759fe Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 20 Aug 2026 12:34:23 -0400 Subject: [PATCH 06/12] FIX: pass benchmark parameters through environment Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .azuredevops/adversarial-benchmark.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.azuredevops/adversarial-benchmark.yml b/.azuredevops/adversarial-benchmark.yml index 147260a0e5..b5dbda553c 100644 --- a/.azuredevops/adversarial-benchmark.yml +++ b/.azuredevops/adversarial-benchmark.yml @@ -324,15 +324,6 @@ jobs: export ARTIFACT_DIR="$artifact_dir" export JOB_STATUS="${AGENT_JOBSTATUS:-unknown}" - export OBJECTIVE_TARGET_INPUT="${{ parameters.objectiveTarget }}" - export ADVERSARIAL_TARGETS_INPUT="${{ parameters.adversarialTargets }}" - export TECHNIQUE_PRESET_INPUT="${{ parameters.techniquePreset }}" - export CUSTOM_TECHNIQUES_INPUT="${{ parameters.customTechniques }}" - export DATASET_NAME_INPUT="${{ parameters.datasetName }}" - export MAX_DATASET_SIZE_INPUT="${{ parameters.maxDatasetSize }}" - export MAX_CONCURRENCY_INPUT="${{ parameters.maxConcurrency }}" - export MAX_RETRIES_INPUT="${{ parameters.maxRetries }}" - export LOG_LEVEL_INPUT="${{ parameters.logLevel }}" python - <<'PY' import json @@ -393,6 +384,16 @@ jobs: PY displayName: Collect benchmark diagnostics condition: always() + env: + OBJECTIVE_TARGET_INPUT: "${{ parameters.objectiveTarget }}" + ADVERSARIAL_TARGETS_INPUT: "${{ parameters.adversarialTargets }}" + TECHNIQUE_PRESET_INPUT: "${{ parameters.techniquePreset }}" + CUSTOM_TECHNIQUES_INPUT: "${{ parameters.customTechniques }}" + DATASET_NAME_INPUT: "${{ parameters.datasetName }}" + MAX_DATASET_SIZE_INPUT: "${{ parameters.maxDatasetSize }}" + MAX_CONCURRENCY_INPUT: "${{ parameters.maxConcurrency }}" + MAX_RETRIES_INPUT: "${{ parameters.maxRetries }}" + LOG_LEVEL_INPUT: "${{ parameters.logLevel }}" - task: PublishPipelineArtifact@1 displayName: Publish benchmark snapshot From bbff19d77186a1f256759f147bdf52aab2f19bde Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 20 Aug 2026 12:36:24 -0400 Subject: [PATCH 07/12] MAINT: use public dataset provider in benchmark prep Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- build_scripts/prepare_adversarial_benchmark_dataset.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build_scripts/prepare_adversarial_benchmark_dataset.py b/build_scripts/prepare_adversarial_benchmark_dataset.py index c391940fe8..a6da6f85a4 100644 --- a/build_scripts/prepare_adversarial_benchmark_dataset.py +++ b/build_scripts/prepare_adversarial_benchmark_dataset.py @@ -5,7 +5,7 @@ import asyncio -from pyrit.datasets.seed_datasets.remote.harmbench_dataset import _HarmBenchDataset +from pyrit.datasets import SeedDatasetProvider from pyrit.memory import CentralMemory from pyrit.models import SeedDataset from pyrit.setup import SQLITE, initialize_pyrit_async @@ -64,7 +64,8 @@ async def _main_async() -> None: env_files=[], silent=True, ) - source_dataset = await _HarmBenchDataset().fetch_dataset_async() + source_datasets = await SeedDatasetProvider.fetch_datasets_async(dataset_names=["harmbench"]) + source_dataset = source_datasets[0] benchmark_dataset = _build_balanced_dataset(source_dataset=source_dataset) memory = CentralMemory.get_memory_instance() await memory.add_seed_datasets_to_memory_async( From b2be47097f2118619d1cbd815bed3eb6f4656352 Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 20 Aug 2026 12:44:31 -0400 Subject: [PATCH 08/12] FIX: export partial adversarial benchmark results Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .azuredevops/adversarial-benchmark.yml | 27 ++++--- .../export_adversarial_benchmark_result.py | 80 +++++++++++++++++++ 2 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 build_scripts/export_adversarial_benchmark_result.py diff --git a/.azuredevops/adversarial-benchmark.yml b/.azuredevops/adversarial-benchmark.yml index b5dbda553c..84760dd5cb 100644 --- a/.azuredevops/adversarial-benchmark.yml +++ b/.azuredevops/adversarial-benchmark.yml @@ -276,21 +276,27 @@ jobs: "$artifact_dir/scenario-result.json" )" + export_exit_code=0 + if [[ -n "$result_id" ]]; then + echo "export-results" > "$artifact_dir/phase.txt" + set +e + uv run python -m build_scripts.export_adversarial_benchmark_result \ + --scenario-result-id "$result_id" \ + --output-dir "$artifact_dir" + export_exit_code=$? + set -e + echo "$export_exit_code" > "$artifact_dir/export-exit-code.txt" + fi + if [[ "$benchmark_exit_code" -eq 0 ]]; then if [[ -z "$result_id" || "$run_status" != "COMPLETED" ]]; then echo "Benchmark exited successfully without a completed persisted scenario result." >&2 exit 3 fi - - echo "export-results" > "$artifact_dir/phase.txt" - uv run pyrit_scan \ - scenario-results "$result_id" \ - --view overview \ - > "$artifact_dir/overview.txt" 2>&1 - uv run pyrit_scan \ - scenario-results "$result_id" \ - --view attacks \ - > "$artifact_dir/attacks.txt" 2>&1 + if [[ "$export_exit_code" -ne 0 ]]; then + echo "Failed to export completed benchmark result." >&2 + exit "$export_exit_code" + fi fi if [[ "$benchmark_exit_code" -eq 0 ]]; then @@ -367,6 +373,7 @@ jobs: "last_phase": read_text("phase.txt"), "execution_exit_code": read_text("execution-exit-code.txt"), "benchmark_exit_code": read_text("benchmark-exit-code.txt"), + "export_exit_code": read_text("export-exit-code.txt"), "objective_target": os.environ["OBJECTIVE_TARGET_INPUT"], "adversarial_targets": os.environ["ADVERSARIAL_TARGETS_INPUT"].split(), "technique_preset": preset, diff --git a/build_scripts/export_adversarial_benchmark_result.py b/build_scripts/export_adversarial_benchmark_result.py new file mode 100644 index 0000000000..c2ec0cfb4b --- /dev/null +++ b/build_scripts/export_adversarial_benchmark_result.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Export readable partial or completed adversarial benchmark results from SQLite.""" + +import argparse +import asyncio +import contextlib +from pathlib import Path + +from pyrit.cli._output import print_attacks_table +from pyrit.cli._results import build_attacks_table_payload +from pyrit.memory import CentralMemory +from pyrit.models import ScenarioResult +from pyrit.output.scenario_result.pretty import PrettyScenarioResultMemoryPrinter +from pyrit.output.sink import FileSink +from pyrit.setup import SQLITE, initialize_pyrit_async + + +async def _load_result_async(*, scenario_result_id: str) -> ScenarioResult: + """Load one persisted scenario result, regardless of terminal state.""" + await initialize_pyrit_async( + memory_db_type=SQLITE, + load_defaults=False, + env_files=[], + silent=True, + ) + results = CentralMemory.get_memory_instance().get_scenario_results( + scenario_result_ids=[scenario_result_id], + ) + if not results: + raise ValueError(f"Scenario result '{scenario_result_id}' was not found in SQLite memory.") + return results[0] + + +async def _write_overview_async(*, result: ScenarioResult, output_dir: Path) -> None: + """Write the existing scenario overview without terminal color codes.""" + printer = PrettyScenarioResultMemoryPrinter( + sink=FileSink(path=output_dir / "overview.txt"), + enable_colors=False, + ) + await printer.write_async(result) + + +def _write_attacks(*, result: ScenarioResult, output_dir: Path) -> None: + """Write machine-readable and console-style partial attack tables.""" + payload = build_attacks_table_payload( + result=result, + scenario_result_id=str(result.id), + ) + (output_dir / "attacks.json").write_text(payload.model_dump_json(indent=2), encoding="utf-8") + with open(output_dir / "attacks.txt", "w", encoding="utf-8") as output: + with contextlib.redirect_stdout(output): + print_attacks_table(payload=payload) + + +async def _export_async(*, scenario_result_id: str, output_dir: Path) -> None: + """Export all readable result views.""" + result = await _load_result_async(scenario_result_id=scenario_result_id) + output_dir.mkdir(parents=True, exist_ok=True) + await _write_overview_async(result=result, output_dir=output_dir) + await asyncio.to_thread(_write_attacks, result=result, output_dir=output_dir) + + +def main() -> None: + """Run the result exporter.""" + parser = argparse.ArgumentParser() + parser.add_argument("--scenario-result-id", required=True) + parser.add_argument("--output-dir", type=Path, required=True) + args = parser.parse_args() + asyncio.run( + _export_async( + scenario_result_id=args.scenario_result_id, + output_dir=args.output_dir, + ) + ) + + +if __name__ == "__main__": + main() From 5c9fdbe462f48eeeeb280ed5f306b9a90a3e424f Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 20 Aug 2026 12:49:23 -0400 Subject: [PATCH 09/12] DEV: add per-technique benchmark metrics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .../export_adversarial_benchmark_result.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/build_scripts/export_adversarial_benchmark_result.py b/build_scripts/export_adversarial_benchmark_result.py index c2ec0cfb4b..874ef01b9a 100644 --- a/build_scripts/export_adversarial_benchmark_result.py +++ b/build_scripts/export_adversarial_benchmark_result.py @@ -6,7 +6,11 @@ import argparse import asyncio import contextlib +import csv +import json +from collections import Counter, defaultdict from pathlib import Path +from typing import Any from pyrit.cli._output import print_attacks_table from pyrit.cli._results import build_attacks_table_payload @@ -54,12 +58,82 @@ def _write_attacks(*, result: ScenarioResult, output_dir: Path) -> None: print_attacks_table(payload=payload) +def _build_technique_metrics(*, result: ScenarioResult) -> list[dict[str, Any]]: + """Aggregate persisted outcomes by technique and adversarial model.""" + grouped: dict[tuple[str, str], Counter[str]] = defaultdict(Counter) + for atomic_attack_name, attack_results in result.attack_results.items(): + technique_name = atomic_attack_name.split("__", 1)[0] + display_group = result.display_group_map.get(atomic_attack_name, "") + for attack_result in attack_results: + grouped[(technique_name, display_group)][attack_result.outcome.value.lower()] += 1 + + metrics: list[dict[str, Any]] = [] + for (technique_name, display_group), counts in sorted(grouped.items()): + total = sum(counts.values()) + success_count = counts["success"] + metrics.append( + { + "technique": technique_name, + "adversarial_model": display_group, + "total": total, + "success": success_count, + "failure": counts["failure"], + "error": counts["error"], + "undetermined": counts["undetermined"], + "success_rate": round(success_count / total, 4) if total else 0.0, + } + ) + return metrics + + +def _write_technique_metrics(*, result: ScenarioResult, output_dir: Path) -> None: + """Write per-technique metrics in text, CSV, and JSON formats.""" + metrics = _build_technique_metrics(result=result) + (output_dir / "technique-metrics.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8") + + fieldnames = [ + "technique", + "adversarial_model", + "total", + "success", + "failure", + "error", + "undetermined", + "success_rate", + ] + with open(output_dir / "technique-metrics.csv", "w", encoding="utf-8", newline="") as output: + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(metrics) + + lines = [ + "{:<32} {:<30} {:>4} {:>8} {:>8} {:>6} {:>8}".format( + "Technique", + "Adversarial model", + "N", + "Success", + "Failure", + "Error", + "ASR", + ) + ] + lines.extend( + ( + "{technique:<32} {adversarial_model:<30} {total:>4} {success:>8} " + "{failure:>8} {error:>6} {success_rate:>7.1%}" + ).format(**metric) + for metric in metrics + ) + (output_dir / "technique-metrics.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + + async def _export_async(*, scenario_result_id: str, output_dir: Path) -> None: """Export all readable result views.""" result = await _load_result_async(scenario_result_id=scenario_result_id) output_dir.mkdir(parents=True, exist_ok=True) await _write_overview_async(result=result, output_dir=output_dir) await asyncio.to_thread(_write_attacks, result=result, output_dir=output_dir) + await asyncio.to_thread(_write_technique_metrics, result=result, output_dir=output_dir) def main() -> None: From ba5b9eaab976def797c085f38841b109057b97cd Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 20 Aug 2026 14:25:24 -0400 Subject: [PATCH 10/12] FIX: deduplicate benchmark retries in metrics Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .../export_adversarial_benchmark_result.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/build_scripts/export_adversarial_benchmark_result.py b/build_scripts/export_adversarial_benchmark_result.py index 874ef01b9a..cf7cd13c6d 100644 --- a/build_scripts/export_adversarial_benchmark_result.py +++ b/build_scripts/export_adversarial_benchmark_result.py @@ -61,10 +61,18 @@ def _write_attacks(*, result: ScenarioResult, output_dir: Path) -> None: def _build_technique_metrics(*, result: ScenarioResult) -> list[dict[str, Any]]: """Aggregate persisted outcomes by technique and adversarial model.""" grouped: dict[tuple[str, str], Counter[str]] = defaultdict(Counter) + retry_records: Counter[tuple[str, str]] = Counter() for atomic_attack_name, attack_results in result.attack_results.items(): technique_name = atomic_attack_name.split("__", 1)[0] display_group = result.display_group_map.get(atomic_attack_name, "") + group_key = (technique_name, display_group) + latest_by_objective = {} for attack_result in attack_results: + current = latest_by_objective.get(attack_result.objective) + if current is None or attack_result.timestamp > current.timestamp: + latest_by_objective[attack_result.objective] = attack_result + retry_records[group_key] += len(attack_results) - len(latest_by_objective) + for attack_result in latest_by_objective.values(): grouped[(technique_name, display_group)][attack_result.outcome.value.lower()] += 1 metrics: list[dict[str, Any]] = [] @@ -80,6 +88,7 @@ def _build_technique_metrics(*, result: ScenarioResult) -> list[dict[str, Any]]: "failure": counts["failure"], "error": counts["error"], "undetermined": counts["undetermined"], + "retry_records": retry_records[(technique_name, display_group)], "success_rate": round(success_count / total, 4) if total else 0.0, } ) @@ -99,6 +108,7 @@ def _write_technique_metrics(*, result: ScenarioResult, output_dir: Path) -> Non "failure", "error", "undetermined", + "retry_records", "success_rate", ] with open(output_dir / "technique-metrics.csv", "w", encoding="utf-8", newline="") as output: @@ -107,20 +117,21 @@ def _write_technique_metrics(*, result: ScenarioResult, output_dir: Path) -> Non writer.writerows(metrics) lines = [ - "{:<32} {:<30} {:>4} {:>8} {:>8} {:>6} {:>8}".format( + "{:<32} {:<30} {:>4} {:>8} {:>8} {:>6} {:>8} {:>8}".format( "Technique", "Adversarial model", "N", "Success", "Failure", "Error", + "Retries", "ASR", ) ] lines.extend( ( "{technique:<32} {adversarial_model:<30} {total:>4} {success:>8} " - "{failure:>8} {error:>6} {success_rate:>7.1%}" + "{failure:>8} {error:>6} {retry_records:>8} {success_rate:>7.1%}" ).format(**metric) for metric in metrics ) From 41e39407c40284e77f2977d1c99451e0e5d44c0c Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 20 Aug 2026 14:26:16 -0400 Subject: [PATCH 11/12] MAINT: use scanner run command in benchmark pipeline Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .azuredevops/adversarial-benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azuredevops/adversarial-benchmark.yml b/.azuredevops/adversarial-benchmark.yml index 84760dd5cb..7d984fba79 100644 --- a/.azuredevops/adversarial-benchmark.yml +++ b/.azuredevops/adversarial-benchmark.yml @@ -212,7 +212,7 @@ jobs: echo "run-benchmark" > "$artifact_dir/phase.txt" set +e - uv run pyrit_scan benchmark.adversarial \ + uv run pyrit_scan run benchmark.adversarial \ --log-level "$LOG_LEVEL_INPUT" \ --target "$OBJECTIVE_TARGET_INPUT" \ --adversarial-targets "${adversarial_targets[@]}" \ From fdbe4b4ca45510baa3561ba335d80e2f7e820abc Mon Sep 17 00:00:00 2001 From: hannahwestra25 Date: Thu, 20 Aug 2026 16:21:33 -0400 Subject: [PATCH 12/12] MAINT: simplify benchmark technique configuration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5b5e066d-8e77-4ba9-8fc8-56f4745ef365 --- .azuredevops/adversarial-benchmark.yml | 58 ++++---------------------- 1 file changed, 7 insertions(+), 51 deletions(-) diff --git a/.azuredevops/adversarial-benchmark.yml b/.azuredevops/adversarial-benchmark.yml index 7d984fba79..63fbdd7364 100644 --- a/.azuredevops/adversarial-benchmark.yml +++ b/.azuredevops/adversarial-benchmark.yml @@ -13,17 +13,8 @@ parameters: displayName: Space-separated adversarial target registry names type: string default: azure_openai_gpt4o azure_gpt4o_unsafe_chat - - name: techniquePreset - displayName: Technique set - type: string - default: union - values: - - current-default - - candidate - - union - - custom - - name: customTechniques - displayName: Custom space-separated techniques (used when Technique set is custom) + - name: techniques + displayName: Space-separated technique names type: string default: >- role_play_movie_script role_play_video_game role_play_trivia_game @@ -182,32 +173,13 @@ jobs: --startup-timeout 180 server_started=true - case "$TECHNIQUE_PRESET_INPUT" in - current-default) - resolved_techniques="light" - ;; - candidate) - resolved_techniques="role_play_movie_script red_teaming context_compliance tap crescendo_simulated" - ;; - union) - resolved_techniques="light tap crescendo_simulated" - ;; - custom) - resolved_techniques="$CUSTOM_TECHNIQUES_INPUT" - ;; - *) - echo "Unknown technique preset: $TECHNIQUE_PRESET_INPUT" >&2 - exit 2 - ;; - esac - - if [[ -z "${resolved_techniques// }" ]]; then + if [[ -z "${TECHNIQUES_INPUT// }" ]]; then echo "At least one technique is required." >&2 exit 2 fi read -r -a adversarial_targets <<< "$ADVERSARIAL_TARGETS_INPUT" - read -r -a techniques <<< "$resolved_techniques" + read -r -a techniques <<< "$TECHNIQUES_INPUT" memory_labels="$(printf '{"pipeline_build_id":"%s"}' "$BUILD_BUILDID")" echo "run-benchmark" > "$artifact_dir/phase.txt" @@ -308,8 +280,7 @@ jobs: env: OBJECTIVE_TARGET_INPUT: "${{ parameters.objectiveTarget }}" ADVERSARIAL_TARGETS_INPUT: "${{ parameters.adversarialTargets }}" - TECHNIQUE_PRESET_INPUT: "${{ parameters.techniquePreset }}" - CUSTOM_TECHNIQUES_INPUT: "${{ parameters.customTechniques }}" + TECHNIQUES_INPUT: "${{ parameters.techniques }}" DATASET_NAME_INPUT: "${{ parameters.datasetName }}" MAX_DATASET_SIZE_INPUT: "${{ parameters.maxDatasetSize }}" MAX_CONCURRENCY_INPUT: "${{ parameters.maxConcurrency }}" @@ -347,19 +318,6 @@ jobs: result_path = artifact_dir / "scenario-result.json" result = json.loads(result_path.read_text(encoding="utf-8")) if result_path.exists() else {} - preset = os.environ["TECHNIQUE_PRESET_INPUT"] - presets = { - "current-default": ["light"], - "candidate": [ - "role_play_movie_script", - "red_teaming", - "context_compliance", - "tap", - "crescendo_simulated", - ], - "union": ["light", "tap", "crescendo_simulated"], - } - techniques = presets.get(preset, os.environ["CUSTOM_TECHNIQUES_INPUT"].split()) manifest = { "pipeline_build_id": os.environ.get("BUILD_BUILDID"), @@ -376,8 +334,7 @@ jobs: "export_exit_code": read_text("export-exit-code.txt"), "objective_target": os.environ["OBJECTIVE_TARGET_INPUT"], "adversarial_targets": os.environ["ADVERSARIAL_TARGETS_INPUT"].split(), - "technique_preset": preset, - "techniques": techniques, + "techniques": os.environ["TECHNIQUES_INPUT"].split(), "dataset_name": os.environ["DATASET_NAME_INPUT"], "max_dataset_size": int(os.environ["MAX_DATASET_SIZE_INPUT"]), "max_concurrency": int(os.environ["MAX_CONCURRENCY_INPUT"]), @@ -394,8 +351,7 @@ jobs: env: OBJECTIVE_TARGET_INPUT: "${{ parameters.objectiveTarget }}" ADVERSARIAL_TARGETS_INPUT: "${{ parameters.adversarialTargets }}" - TECHNIQUE_PRESET_INPUT: "${{ parameters.techniquePreset }}" - CUSTOM_TECHNIQUES_INPUT: "${{ parameters.customTechniques }}" + TECHNIQUES_INPUT: "${{ parameters.techniques }}" DATASET_NAME_INPUT: "${{ parameters.datasetName }}" MAX_DATASET_SIZE_INPUT: "${{ parameters.maxDatasetSize }}" MAX_CONCURRENCY_INPUT: "${{ parameters.maxConcurrency }}"