FEAT add AgentThreatRulesScorer (ATR taxonomy scorer)#1893
Conversation
e55c2a6 to
4d55a7d
Compare
|
Freshened this onto the latest main — it is up to date and mergeable now. Ready for review when convenient. |
adrian-gavrila
left a comment
There was a problem hiding this comment.
Thanks for picking this up! Overall it looks good, but there are a couple of things that should be addressed, primarily around things actually being excercised in CI and following some style guidelines.
| # pyatr returns matches sorted by severity (critical first). | ||
| hits = [m for m in matches if _SEVERITY_ORDER.get((m.severity or "").lower(), 0) >= self._severity_floor] | ||
| triggered = bool(hits) | ||
|
|
||
| if triggered: | ||
| top = hits[0] |
There was a problem hiding this comment.
top = hits[0] relies on pyatr returning matches pre-sorted critical-first (per the comment on line 108). That ordering is an undocumented pyatr internal, so if a future release changes or drops the sort, max_severity and the highest-severity value in the description silently go wrong while every test still passes.
We already own a severity ranking in _SEVERITY_ORDER, so we can pick the top match ourselves instead of trusting the engine's order:
top = max(hits, key=lambda m: _SEVERITY_ORDER.get((m.severity or "").lower(), 0))Separately, there's a casing inconsistency: the filter on line 109 lowercases severity before comparing, but line 122 stores top.severity raw. If pyatr ever emits mixed case, the stored max_severity won't match what the filter normalized.
| import pytest | ||
|
|
||
| # The scorer relies on the optional `pyatr` engine; skip if it is not installed. | ||
| pytest.importorskip("pyatr") |
There was a problem hiding this comment.
This importorskip is at module scope, so it gates the whole file, and pyatr isn't declared anywhere in pyproject.toml. CI never installs it, so all four tests are skipped on every run. That's also why the broken assertion in test_atr_scorer_passes_benign below was never caught.
Two things to fix it:
- Wire
pyatr>=0.2.6into an optional extra and theallgroup so CI installs it and the live tests actually run. - Move
test_atr_scorer_rejects_invalid_min_severityout from behind this module-level skip. It only asserts that a badmin_severityraisesValueError, which happens before thepyatrimport in__init__, so it doesn't need the engine and shouldn't be gated.
| return self._create_identifier( | ||
| params={ | ||
| "score_aggregator": self._score_aggregator.__name__, # type: ignore[ty:unresolved-attribute] | ||
| "min_severity": self._min_severity, |
There was a problem hiding this comment.
Nit, optional and low likelihood: _build_identifier omits rules_dir, so two scorers loading different custom rulesets share an eval_hash (and stored scorer_class_identifier). It only bites if you pass a custom rules_dir and use the scorer-eval metrics harness, so the bundled-ruleset path is never affected. Cheap to close though, and consistent with how SubStringScorer includes substring in its identifier:
| "min_severity": self._min_severity, | |
| "min_severity": self._min_severity, | |
| "rules_dir": self._rules_dir, |
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT license. | ||
|
|
||
| from typing import Optional |
There was a problem hiding this comment.
This file currently fails ruff check under the repo config (pyrit/score/** isn't DOC/D-exempt), so CI will be red until these are addressed. I ran it locally; the failures are:
- UP045 (x5):
Optional[...]should beX | None(lines 4, 42, 43, 45, 94, 119). This is the modern union syntax the codebase standardized on;SubStringScorer, which this file mirrors, already uses it. - D213 (x3): multi-line docstring summary should start on the second line (lines 19, 47, 95), matching SubStringScorer's docstring style.
- DOC501 (x2):
__init__raisesValueError(badmin_severity) andImportError(missing pyatr), but neither is in aRaises:section. - DOC201:
_score_piece_asyncdescribes its return in prose but has noReturns:section.
ruff check --fix auto-resolves the UP045 and D213; the Raises: and Returns: sections need to be added by hand.
|
|
||
| assert len(scores) == 1 | ||
| assert scores[0].get_value() is False | ||
| assert scores[0].score_metadata is None |
There was a problem hiding this comment.
Two assertion issues here, both of which only surface once this file actually runs in CI (see the coverage comment at the top of the file):
- Line 34:
assert scores[0].score_metadata is Nonecan never pass. TheScoremodel has amode="before"validator that coercesNoneto{}, so on the benign path this is effectivelyassert {} is None. I confirmed it by installing pyatr locally and running the module: 1 failed, 3 passed. Assert the no-match contract you actually want, e.g.not scores[0].score_metadataor== {}. - Line 23:
assert scores[0].score_metadata is not Noneis vacuous for the same reason (metadata is always a dict), so it never tests anything. The next line (["matched_rule_ids"]) is the real check, so this one can go.
|
Thanks for the thorough review, Adrian — all addressed in the latest push:
Verified locally: ruff passes, and against pyatr 0.2.6 the injection string trips 5 rules (top severity critical) while the benign string trips none, so the severity-floor tests hold. Ready for another look when you have a moment. |
|
Thanks for the thorough review — all points addressed (pushed in 6010cb0). CI exercising the scorer (the root cause): wired Test assertions: the benign path now asserts Severity robustness + casing: the filtered hits are now sorted by our own Style: The checks here are still gated ( |
adrian-gavrila
left a comment
There was a problem hiding this comment.
Thanks for addressing those! A couple of things lingering now that we have added this as an optional dependency.
| requires_pyatr = pytest.mark.skipif( | ||
| importlib.util.find_spec("pyatr") is None, reason="pyatr is not installed" | ||
| ) |
There was a problem hiding this comment.
ruff format --check fails on this file, so the pre-commit CI job will go red (the ruff-format hook runs on changed files; ruff check passing masked it since it's a separate hook). The hand-wrapped 3 lines fit under 120 chars, so the formatter wants them collapsed to one line.
Good spot to also adopt the house idiom while fixing it: importlib.util.find_spec is the only such use in tests/. Other optional-dep tests define an is_X_installed() helper (e.g. is_opencv_installed() in test_video_scorer.py). A one-line version fixes both at once:
requires_pyatr = pytest.mark.skipif(not is_pyatr_installed(), reason="pyatr is not installed")Unrelated housekeeping: pyatr was added to the pyproject extras but uv.lock wasn't regenerated -> uv lock + commit so the lockfile matches.
| raise ImportError( | ||
| "AgentThreatRulesScorer requires the optional 'pyatr' package (>= 0.2.6). " | ||
| "Install it with `pip install pyatr`." | ||
| ) from exc |
There was a problem hiding this comment.
This message and the class docstring (:27-28) both tell users pip install pyatr, but this PR adds an atr extra to pyproject, so the idiomatic install is pip install pyrit[atr] (matches pip install pyrit[opencv] etc. in the other optional-dep scorers). Worth updating both spots.
Minor: peers catch ModuleNotFoundError here rather than the broader ImportError. ImportError would also swallow an import failure from inside pyatr itself and misreport it as "not installed", so ModuleNotFoundError is a touch more precise.
|
Thanks Adrian. All addressed:
With pyatr wired into the atr extra and the all group, the four tests run instead of skipping. I ran them locally against pyatr 0.2.6 and all four pass. |
adrian-gavrila
left a comment
There was a problem hiding this comment.
Looks great! Thank you for following up on the comments
|
Pushed a one-line fix (a91bcb4) for the only failing check — the The local The new runs are sitting in |
|
Thanks for the thorough review and the approval, @adrian-gavrila — the CI-exercise and style points made the PR better. Is there anything left on your side before it can merge, or is it good to go? @romanlutz, since you'd asked about the scorer over on #1702 — this is it, approved and ready whenever you'd like to bring it in. |
Adds a true/false scorer backed by the pyatr package, exposed as an optional `atr` extra so the dependency stays out of the base install. Rebuilt on current main. The branch had been sitting 102 commits behind and conflicting on pyproject.toml, uv.lock and the score package exports -- all three churn frequently upstream, so a merge would have been noisier than a rebuild. The content is unchanged from the version approved on 2026-06-16: the diff against main is still 5 files, +245/-1, matching the original exactly. The scorer and its test were carried over untouched; the export line was reapplied with a three-way merge; pyproject.toml took the same five lines; uv.lock was regenerated rather than merged, which is where pyatr resolves to 0.2.7. 4 passed.
d123692 to
59c2996
Compare
|
@adrian-gavrila — heads up that I force-pushed this branch. It had drifted 102 commits behind main and was conflicting, so it could not merge despite your approval on 2026-06-16, and it has been sitting since. Rebuilt on current main rather than merged. The conflicts were all in The content you approved is unchanged. The diff against main is still 5 files, +245/-1, matching the original exactly:
Tests pass locally (4 passed). If the force-push invalidated your approval on GitHub's side, could you re-approve when you have a moment? Happy to walk through the rebuild if you would rather verify it independently first. |
Adds AgentThreatRulesScorer, the scorer half of #1702 (the dataset loader landed in #1715).
What it does
Dependency
Pairs with the _AgentThreatRulesDataset loader: the dataset supplies ATR-derived adversarial prompts, and this scorer detects whether a response trips an ATR rule.