Skip to content

Commit 4e0951b

Browse files
Merge branch 'main' into fix-xlsb-cultural-category-commas
2 parents d78d914 + 3f729ac commit 4e0951b

367 files changed

Lines changed: 29279 additions & 5100 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.

‎.github/instructions/scorers.instructions.md‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,16 @@ Scorers evaluate model responses against an objective and live under `pyrit/scor
1313
`Scorer` subclasses MUST use the keyword-only constructor shape:
1414

1515
```python
16-
class MyScorer(Scorer):
16+
class MyScorer(MessageTrueFalseScorer):
1717
def __init__(
1818
self,
1919
*,
20-
chat_target: PromptTarget | None = None,
21-
threshold: float = 0.5,
20+
chat_target: PromptTarget,
2221
validator: ScorerPromptValidator | None = None,
2322
) -> None:
24-
super().__init__(
25-
validator=validator or self._DEFAULT_VALIDATOR,
26-
chat_target=chat_target,
27-
)
23+
super().__init__(validator=validator or self._DEFAULT_VALIDATOR)
24+
self._prompt_target = chat_target
25+
self._judge = TargetJudge(target=chat_target, requirements=self.TARGET_REQUIREMENTS)
2826
```
2927

3028
Requirements:
@@ -34,9 +32,20 @@ Requirements:
3432
`Scorer.__init_subclass__` calling `enforce_keyword_only_init`
3533
(see `pyrit/common/brick_contract.py`). Non-conforming subclasses
3634
raise `TypeError` at import time.
37-
- ``super().__init__(validator=..., chat_target=...)`` is required so the
38-
base class wires the validator and validates ``TARGET_REQUIREMENTS``
39-
against any provided ``chat_target``.
35+
- Message-family bases wire the validator. Their deprecated `chat_target` parameter and the
36+
one on `Scorer` only validate `TARGET_REQUIREMENTS` until removal in 1.4.0; they do not store
37+
a target or create a judge. New concrete target-backed scorers compose `TargetJudge`, which
38+
validates the requirements. Specialized service scorers validate at their concrete owner.
39+
- Scorers render prompts, pass the effective expectation explicitly in `JudgmentRequest`, and
40+
convert the returned judgment. The judge delegates transport and retries; the response handler
41+
owns parsing. Raw `ObservationSource` implementations acquire evidence without criteria.
42+
- `JudgmentRequest` is data only. Message scorers call `_capture_judgment_evidence` before
43+
sending it; other callers supply evidence references directly. The exchange consumes the request
44+
without reading the active message or expectation context.
45+
- Preserve `get_chat_target()` for target discovery. Use `_score_piece_with_expectation_async`
46+
for migrated judge consumers; do not replace it with an objective-only hook.
47+
- A legacy `_score_piece_async` override below a typed scorer raises `TypeError` at construction.
48+
Keep this fail-fast check: implicit dispatch through both hooks can skip or repeat custom policy.
4049

4150
## Condition contract
4251

‎.github/workflows/codeql.yml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: CodeQL
2+
3+
on:
4+
push:
5+
branches:
6+
- "main"
7+
- "releases/v*"
8+
pull_request:
9+
branches:
10+
- "main"
11+
- "releases/**"
12+
merge_group:
13+
workflow_dispatch:
14+
schedule:
15+
- cron: "23 2 * * 1" # Mondays at 02:23 UTC
16+
17+
permissions: {}
18+
19+
jobs:
20+
analyze:
21+
name: CodeQL (${{ matrix.language }})
22+
runs-on: ubuntu-latest
23+
permissions:
24+
actions: read
25+
contents: read
26+
security-events: write
27+
strategy:
28+
fail-fast: false
29+
matrix:
30+
language: [actions, javascript-typescript, python]
31+
steps:
32+
- name: Checkout
33+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
34+
with:
35+
persist-credentials: false
36+
37+
- name: Initialize CodeQL
38+
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
39+
with:
40+
languages: ${{ matrix.language }}
41+
build-mode: none
42+
43+
- name: Perform CodeQL Analysis
44+
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
45+
with:
46+
category: "/language:${{ matrix.language }}"

‎.github/workflows/docker_build.yml‎

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ on:
1515
workflow_dispatch:
1616
inputs:
1717
pypiVersion:
18-
description: 'Exact stamped PyPI release (overrides PYRIT_PYPI_VERSION)'
18+
description: 'Exact published PyPI version (defaults to the latest stable release)'
1919
required: false
2020
type: string
2121

@@ -31,6 +31,45 @@ concurrency:
3131
cancel-in-progress: true
3232

3333
jobs:
34+
validate-compose-bindings:
35+
name: Validate Compose Bindings
36+
runs-on: ubuntu-latest
37+
steps:
38+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
39+
40+
- name: Require loopback-only published ports
41+
working-directory: docker
42+
shell: pwsh
43+
run: |
44+
$ErrorActionPreference = 'Stop'
45+
$env:PYRIT_SOURCE_COMMIT = git rev-parse --verify HEAD
46+
if ($LASTEXITCODE -ne 0) {
47+
throw "Cannot determine source commit."
48+
}
49+
$sourceStatus = git status --porcelain
50+
if ($LASTEXITCODE -ne 0) {
51+
throw "Cannot determine source dirty state."
52+
}
53+
$env:PYRIT_SOURCE_DIRTY = if ($sourceStatus) { 'true' } else { 'false' }
54+
$json = docker compose --file docker-compose.yaml --profile gui --profile jupyter config --format json
55+
if ($LASTEXITCODE -ne 0) {
56+
throw "Docker Compose configuration resolution failed."
57+
}
58+
$services = ($json | ConvertFrom-Json).services
59+
$expectedPorts = @{ 'pyrit-gui' = 8000; 'pyrit-jupyter' = 8888 }
60+
foreach ($service in $expectedPorts.Keys) {
61+
$port = $expectedPorts[$service]
62+
$bindings = @($services.$service.ports)
63+
if ($bindings.Count -ne 1 -or
64+
$bindings[0].host_ip -ne '127.0.0.1' -or
65+
$bindings[0].published -ne "$port" -or
66+
$bindings[0].target -ne $port -or
67+
$bindings[0].protocol -ne 'tcp') {
68+
throw "Expected only 127.0.0.1:${port}:${port}/tcp for ${service}; got $($bindings | ConvertTo-Json -Compress)."
69+
}
70+
Write-Output "${service}: verified 127.0.0.1:${port}:${port}/tcp"
71+
}
72+
3473
build-and-test-local:
3574
name: Build and test (local)
3675
runs-on: ubuntu-latest
@@ -108,7 +147,8 @@ jobs:
108147
build-and-test-pypi:
109148
name: Build and test (PyPI)
110149
runs-on: ubuntu-latest
111-
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
150+
# Restore main-push checks after validating 1.2.0: https://github.com/microsoft/PyRIT/issues/3007
151+
if: github.event_name == 'workflow_dispatch'
112152
outputs:
113153
production: ${{ steps.production.outcome }}
114154
import: ${{ steps.import.outcome }}
@@ -117,20 +157,17 @@ jobs:
117157
steps:
118158
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
119159

120-
- name: Select coordinated PyPI release
160+
- name: Select PyPI release
121161
id: pypi-version
162+
timeout-minutes: 5
122163
env:
123-
PYRIT_PYPI_VERSION: ${{ inputs.pypiVersion || vars.PYRIT_PYPI_VERSION }}
164+
PYRIT_PYPI_VERSION: ${{ inputs.pypiVersion }}
124165
shell: bash
125166
run: |
126167
set -euo pipefail
127-
version_pattern='^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?(\.post[0-9]+)?(\.dev[0-9]+)?$'
128-
if [[ ! "${PYRIT_PYPI_VERSION:-}" =~ $version_pattern ]]; then
129-
echo "::error::Set pypiVersion or repository variable PYRIT_PYPI_VERSION to an exact coordinated PyPI release."
130-
exit 1
131-
fi
132-
echo "version=$PYRIT_PYPI_VERSION" >> "$GITHUB_OUTPUT"
133-
echo "Testing coordinated PyPI release: $PYRIT_PYPI_VERSION"
168+
version=$(python3 build_scripts/select_pypi_version.py --version "${PYRIT_PYPI_VERSION:-}")
169+
echo "version=$version" >> "$GITHUB_OUTPUT"
170+
echo "Testing published PyPI release: $version"
134171
135172
- name: Set up cached devcontainer builder
136173
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
@@ -219,7 +256,7 @@ jobs:
219256
pypi-production-check:
220257
name: Build Production (PyPI)
221258
needs: build-and-test-pypi
222-
if: ${{ always() && (github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch') }}
259+
if: ${{ always() && (github.event_name == 'workflow_dispatch') }}
223260
runs-on: ubuntu-latest
224261
steps:
225262
- name: Check execution and stage results
@@ -237,7 +274,7 @@ jobs:
237274
pypi-import-check:
238275
name: Test Import (PyPI)
239276
needs: build-and-test-pypi
240-
if: ${{ always() && (github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch') }}
277+
if: ${{ always() && (github.event_name == 'workflow_dispatch') }}
241278
runs-on: ubuntu-latest
242279
steps:
243280
- name: Check execution and stage results
@@ -255,7 +292,7 @@ jobs:
255292
pypi-gui-check:
256293
name: Test GUI (PyPI)
257294
needs: build-and-test-pypi
258-
if: ${{ always() && (github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch') }}
295+
if: ${{ always() && (github.event_name == 'workflow_dispatch') }}
259296
runs-on: ubuntu-latest
260297
steps:
261298
- name: Check execution and stage results
@@ -273,7 +310,7 @@ jobs:
273310
pypi-jupyter-check:
274311
name: Test Jupyter (PyPI)
275312
needs: build-and-test-pypi
276-
if: ${{ always() && (github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch') }}
313+
if: ${{ always() && (github.event_name == 'workflow_dispatch') }}
277314
runs-on: ubuntu-latest
278315
steps:
279316
- name: Check execution and stage results

‎.github/workflows/docs.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ jobs:
8787
python-version: "3.13"
8888

8989
- name: Install uv
90-
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
90+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
9191
with:
9292
version: "0.9.17"
9393
enable-cache: false
@@ -230,7 +230,7 @@ jobs:
230230
python-version: "3.13"
231231

232232
- name: Install uv
233-
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
233+
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
234234
with:
235235
version: "0.9.17"
236236
enable-cache: false

‎.pre-commit-config.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ repos:
6767
- id: detect-private-key
6868

6969
- repo: https://github.com/astral-sh/ruff-pre-commit
70-
rev: v0.16.9
70+
rev: v0.16.10
7171
hooks:
7272
- id: ruff-format
7373
- id: ruff-check

‎build_scripts/inject_version_picker.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
* <script>...inlined picker.js...</script> (CSS is bundled inside)
1313
1414
Idempotent: files that already contain our marker are skipped.
15+
Embedded players opt out with the ``pyrit-no-version-picker`` HTML comment.
1516
1617
Why inline the JS instead of <script src="..."> AND why no <link>?
1718
@@ -40,6 +41,7 @@
4041
from pathlib import Path
4142

4243
INJECT_MARKER = "<!-- pyrit-version-picker -->"
44+
SKIP_MARKER = "<!-- pyrit-no-version-picker -->"
4345
ASSETS_SOURCE_DIR = Path(__file__).resolve().parent / "version_picker_assets"
4446

4547
# The same marker string is used in compose_docs_dist.py's 404 template. Both
@@ -54,7 +56,7 @@ def _head_block(base: str, picker_js: str) -> str:
5456

5557

5658
def _inject(html: str, base: str, picker_js: str) -> tuple[str, bool]:
57-
if INJECT_MARKER in html:
59+
if INJECT_MARKER in html or SKIP_MARKER in html:
5860
return html, False
5961
head_block = _head_block(base, picker_js)
6062
new = html.replace("</head>", f"{head_block}</head>", 1) if "</head>" in html else head_block + html
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT license.
3+
4+
"""Select a published PyRIT release without installing project dependencies."""
5+
6+
import argparse
7+
import json
8+
import re
9+
from urllib.request import urlopen
10+
11+
12+
def _validate_version(value: object) -> str:
13+
if (
14+
not isinstance(value, str)
15+
or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?(?:\.post[0-9]+)?(?:\.dev[0-9]+)?", value) is None
16+
):
17+
raise ValueError("PyRIT version must be an exact release, such as X.Y.Z or X.Y.Zrc1")
18+
return value
19+
20+
21+
def _validate_distributions(*, files: object, version: str) -> None:
22+
if not isinstance(files, list) or any(
23+
not isinstance(file, dict) or not isinstance(file.get("yanked"), bool) for file in files
24+
):
25+
raise ValueError(f"Invalid distribution metadata for PyPI release {version}")
26+
if not any(
27+
file["yanked"] is False
28+
and isinstance(file.get("filename"), str)
29+
and (
30+
(
31+
file.get("packagetype") == "bdist_wheel"
32+
and file["filename"].startswith(f"pyrit-{version}-")
33+
and file["filename"].endswith(".whl")
34+
)
35+
or (
36+
file.get("packagetype") == "sdist"
37+
and file["filename"] in {f"pyrit-{version}.tar.gz", f"pyrit-{version}.zip"}
38+
)
39+
)
40+
and isinstance(file.get("url"), str)
41+
and file["url"].startswith("https://files.pythonhosted.org/")
42+
and file["url"].endswith(f"/{file['filename']}")
43+
for file in files
44+
):
45+
raise ValueError(f"PyPI release {version} has no non-yanked published wheel or sdist")
46+
47+
48+
def resolve_pypi_version(version: str = "") -> str:
49+
"""Resolve PyPI's latest stable release, or validate an explicit published version.
50+
51+
Args:
52+
version: Exact manual override. An empty string selects the latest stable release.
53+
54+
Returns:
55+
str: The exact published version to install and test.
56+
57+
Raises:
58+
OSError: If the PyPI lookup fails.
59+
ValueError: If the version, metadata, or published distributions are invalid.
60+
"""
61+
if version:
62+
_validate_version(version)
63+
url = f"https://pypi.org/pypi/pyrit/{version}/json" if version else "https://pypi.org/pypi/pyrit/json"
64+
with urlopen(url, timeout=30) as response:
65+
metadata = json.load(response)
66+
info = metadata.get("info") if isinstance(metadata, dict) else None
67+
if not isinstance(info, dict) or not isinstance(info.get("name"), str) or info["name"].lower() != "pyrit":
68+
raise ValueError("Invalid PyPI project metadata: expected PyRIT release information")
69+
selected = _validate_version(info.get("version"))
70+
if version and selected != version:
71+
raise ValueError(f"Requested PyPI release {version}, but PyPI returned {selected}")
72+
# The project endpoint orders non-yanked, stable releases using PyPI's version ordering.
73+
if not version and re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:\.post[0-9]+)?", selected) is None:
74+
raise ValueError("PyPI did not return a stable, non-yanked PyRIT release")
75+
if info.get("yanked") is not False:
76+
raise ValueError(f"PyPI release {selected} is yanked or has invalid yank metadata")
77+
_validate_distributions(files=metadata.get("urls"), version=selected)
78+
return selected
79+
80+
81+
def main() -> None:
82+
"""Print only the validated version, or fail without supplying a fallback."""
83+
parser = argparse.ArgumentParser(description=__doc__)
84+
parser.add_argument(
85+
"--version", default="", help="Exact published override; defaults to PyPI's latest stable release"
86+
)
87+
args = parser.parse_args()
88+
try:
89+
version = resolve_pypi_version(args.version)
90+
except (OSError, ValueError) as exc:
91+
parser.exit(1, f"::error::PyPI release selection failed: {exc}\n")
92+
print(version)
93+
94+
95+
if __name__ == "__main__":
96+
main()
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<!doctype html>
2+
<!-- pyrit-no-version-picker -->
3+
<html lang="en">
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1">
7+
<title>CoPyRIT GUI walkthrough</title>
8+
<link rel="stylesheet" href="player.css">
9+
<script src="player.js" defer></script>
10+
</head>
11+
<body>
12+
<video controls playsinline preload="none" src="copyrit-walkthrough.mp4"
13+
poster="../copyrit-demo.png" aria-label="CoPyRIT GUI walkthrough">
14+
<a href="copyrit-walkthrough.mp4">Open the CoPyRIT GUI walkthrough directly</a>.
15+
</video>
16+
</body>
17+
</html>

0 commit comments

Comments
 (0)