textattack-detection-atr
A detection-evaluation companion for TextAttack.
TextAttack answers "how often did the attack fool the model." It does not answer "and would a content-layer defence have noticed." This package adds the second question, backed by Agent Threat Rules (ATR, MIT) as a reference detector.
Nothing here modifies TextAttack. No fork, no patch, no new kwarg upstream.
Install
pip install textattack-detection-atr
Use it
ATRBypassRate is a textattack.metrics.Metric. It takes the list that
Attacker.attack_dataset() already returns, the same as AttackSuccessRate:
from textattack import Attacker
from textattack.metrics import AttackSuccessRate
from textattack_detection_atr import ATRBypassRate
results = Attacker(attack, dataset, attack_args).attack_dataset()
print(AttackSuccessRate().calculate(results))
print(ATRBypassRate().calculate(results))
{'successful_attacks': 3, 'attacks_detected': 1, 'attacks_bypassed': 2,
'bypass_rate': 66.67, 'detection_rate': 33.33, 'original_text_flagged': 3,
'results_not_fully_evaluated': 0,
'detected_by_category': {'agent-manipulation': 1, 'prompt-injection': 1}}
An attack is bypassing when it succeeded against the victim model and the detector did not flag its perturbed text. Failed and skipped results are excluded from the denominator: a skipped result never produced an adversarial example and a failed one produced an example the model resisted anyway, so counting either would move the rate for reasons unrelated to the detector.
original_text_flagged is reported rather than netted out. A detector that
already flagged the unperturbed input did not catch the attack, and a
detection rate quoted without that number cannot be read.
The detector= form
The proposal that started this package showed
Attacker(attack, dataset, detector=...). That does not work:
textattack.Attacker.__init__ is (self, attack, dataset, attack_args=None)
and has no detector keyword, so the example in the proposal would have
required a change to TextAttack — which the same proposal promised not to
need. A subclass gets the ergonomics and keeps the promise:
from textattack_detection_atr import ATRDetector, DetectionAttacker
attacker = DetectionAttacker(attack, dataset, attack_args, detector=ATRDetector())
results = attacker.attack_dataset() # returns exactly what Attacker returns
print(attacker.detection_metrics)
There is a test asserting that upstream Attacker still has no detector
kwarg. If TextAttack ever grows one, that test fails and this subclass should
be revisited rather than kept.
The detector interface
One method. It is a Protocol, not a base class, so an existing classifier
satisfies it without importing anything from here:
class Detector(Protocol):
def detect(self, text: str) -> Verdict: ...
Verdict carries flagged: bool and findings: tuple[Finding, ...]; a
Finding carries rule_id, category, severity, matched_field. An
unflagged verdict cannot carry findings — that is enforced, not conventional.
Pass any object with that method to ATRBypassRate(detector=...). ATRDetector
is one implementation, not the interface.
What ATRDetector is pinned to
Rules are not fetched at runtime. A precompiled digest is vendored and pinned to one ATR commit, so a given release always means the same thing:
>>> from textattack_detection_atr import digest_pin
>>> digest_pin()
DigestPin(atr_version='4.0.0', atr_commit='54d3e13e94f8980d7b36f9d79511b26174954dfc', ...)
Field scope, which is the load-bearing choice
ATR conditions are each written against a named field — user_input,
content, tool_args, agent_output. The engine routes by event type rather
than matching field names literally: for an event whose type is llm_input,
content is supplied to user_input conditions as well, and other fields are
looked up in the event's fields mapping. A consumer holding one
undifferentiated string has no such routing at all, and handing it every
pattern evaluates tool-call conditions against prose they were never written
for.
The vendored digest ships default_fields = ["agent_output", "content"],
which is the scope for a consumer scoring what a model produced. TextAttack
hands a detector the text that was fed to the victim, so this package
selects ("user_input", "content") instead. Taking the digest's default here
would score adversarial input with rules written about output.
Override it if you know better:
ATRDetector(fields=("user_input", "content", "tool_args"), min_severity="high")
The false-positive claim, stated properly
ATR is often quoted as having zero false positives. That is true of one maturity lane and not of the corpus as a whole. Rather than restate a number, this package ships the script that produces it:
python scripts/summarise_fp_measurement.py
scripts/ and examples/ ship in the source distribution and the repository,
not in the wheel, so pip install alone does not put them on disk. Clone the
repository or pip download --no-binary :all: to run them.
It reads ATR's own data/benign-fp-measurement.json, produced by
scripts/gate-promotion-fp.ts --emit-measurement, and reports its provenance
with every run. At the time of writing:
measured : 2026-08-20 at ATR commit c813f2ca7374
benign samples: 12060
rules measured : 785
fired on no sample : 594 (75.7%)
fired on at least 1 : 191
total benign hits : 15298
per maturity lane, share of rules that fired on nothing:
draft 13/14 92.9%
experimental 54/65 83.1%
stable 106/106 100.0% <- the lane a 'zero false positives' claim can refer to
test 421/600 70.2%
So: zero false positives on 12,060 benign samples is a true statement about
the 106 stable rules. It is not a true statement about all 785. The default
min_severity="medium" here selects well beyond the stable lane, so expect
false positives and read original_text_flagged.
Examples
examples/bypass_rate_end_to_end.py — a full Attacker.attack_dataset() run
against a transparent keyword victim, then the metric. No model download.
examples/detector_robustness.py — points TextAttack at the detector itself
and measures how much perturbation evades it, over the malicious skill
documents in the ATR benchmark.
Honest limits
Regex against known shapes is what this detector is. It is not a paraphrase detector, and the numbers below are the point of shipping the second example rather than a marketing sentence.
Measured, on ATR's own skill benchmark
432 benign and 32 malicious skill documents, ATRDetector() at its defaults,
reproduce with scripts/measure_detector_parity.py:
| detector | malicious flagged | benign flagged |
|---|---|---|
ATRDetector (vendored digest) |
31/32 (96.9%) | 90/432 (20.8%) |
PyATRDetector (ATR's own engine) |
30/32 (93.8%) | 90/432 (20.8%) |
One in five benign skill documents is flagged at the default settings. That is
the number to keep in view when reading a bypass_rate, and it is why
original_text_flagged is reported alongside it.
Tightening the severity floor barely helps, because the false positives come from high- and critical-severity rules that are not in ATR's enforce lane:
min_severity |
rules selected | malicious | benign |
|---|---|---|---|
medium |
all | 96.9% | 20.8% |
high |
all | 96.9% | 20.4% |
critical |
all | 93.8% | 17.8% |
| any | ATR maturity: stable only |
3.1–6.3% | 0.0% |
The last row is the honest headline. ATR's zero-false-positive claim is about
its stable lane, and on this corpus at this scope that lane flags one or two
of thirty-two malicious documents. Ninety-seven percent detection costs a
twenty-one percent false-positive rate. There is no free operating point here,
and a package that quoted only the first column would be lying by selection.
Selecting the enforce lane is awkward on purpose, because neither backend
models it: the vendored digest does not carry maturity, and a pyatr bundled
rule carries status (59 stable) which is a different field from maturity
(106 stable). PyATRDetector(rule_ids=...) exists so the lane can be
selected from ATR's published measurement file until a backend carries it.
Do the two backends agree
On the same 464 documents, ATRDetector and PyATRDetector reach the same
flag decision on 463 (99.8%): one document the digest flags and the engine
does not, none the other way. Their finding counts differ by construction —
the digest emits one finding per matching condition, pyatr one per matching
rule — so compare rule-id sets, not counts.
How brittle is it
examples/detector_robustness.py points TextAttack at the detector itself
over the 31 malicious documents it flags, with
WordSwapRandomCharacterDeletion, GreedyWordSwapWIR, and a 1500-query
budget:
evaded 6/31 (19.4%), held 25, skipped 0
Every one of the six took a single word changed. The twenty-five that held
exhausted the budget rather than resisting cleverly: a long document tends to
carry several independent rule hits, and one character deletion removes one of
them. A short single-phrase payload has no such redundancy, which is why the
bypass_rate example above shows two of three single-sentence injections
evading after one deletion each.
The evasion figure is a floor, not a bound: it comes from a single weak
transformation (WordSwapRandomCharacterDeletion) under a fixed query budget.
A stronger attacker does better.
The detector runs a smaller ruleset than ATR does
The digest is exported with every pattern test-compiled, and anything that
does not survive is dropped there rather than at import time. Sixteen entries
are recorded in the digest's excluded block for two distinct reasons, and
neither was mentioned in an earlier version of this file:
- twelve conditions do not compile under Python
re(variable-width lookbehind, whichrerejects); - four rules use
condition: all, which a flat list of independent patterns cannot express, and a rule silently downgraded from AND to OR is a false-positive generator rather than a rule.
The effect at the defaults is concrete. Six rules are absent entirely —
ATR-2026-00140, ATR-2026-02210, ATR-2026-02261, ATR-2026-02377
(condition: all) and ATR-2026-02100, ATR-2026-02300 (every condition
non-compiling) — and two more ship partially: ATR-2026-00442 carries two of
its four conditions and ATR-2026-02304 one of its two.
Read it yourself rather than taking this paragraph's word for it:
>>> from textattack_detection_atr import digest_pin
>>> from textattack_detection_atr.atr import excluded_reasons
>>> pin = digest_pin(); pin.rules_seen, pin.rules_emitted, len(pin.excluded_rules)
(778, 772, 16)
>>> excluded_reasons()["ATR-2026-00140"]
'condition: all -- ...'
PyATRDetector has none of this gap; it runs ATR's own engine.
Catastrophic backtracking, and what is done about it
Several ATR patterns backtrack catastrophically on inputs this package exists
to accept from an attacker. ATR-2026-01005 takes over four minutes on a
129-byte string under the standard library's re, and a re match cannot be
interrupted once started, so a length cap is no defence.
Patterns are therefore compiled with the third-party regex module, and every
search runs under a time budget: condition_timeout (0.05s) per pattern and
total_timeout (5s) per detect() call. A pattern that exceeds its budget is
recorded in Verdict.not_evaluated and surfaced by the metric as
results_not_fully_evaluated. It is never counted as a non-match, because
"we could not look" reported as "we looked and found nothing" makes every
number computed downstream wrong in the flattering direction.
>>> d = ATRDetector()
>>> v = d.detect("a" * 20000) # 0.5s, not 142s
>>> v.complete, len(v.not_evaluated)
(False, 3)
regex and re were checked for agreement before the swap: all 3303 digest
patterns compile under both, and they return the same decision on every one of
16,515 pattern/sample pairs tried.
One more limit
ATR rules that depend on joining observations across a session or an agent cannot run here at all — a detector that sees one string has no session.
Licence
MIT. ATR is MIT.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file textattack_detection_atr-0.1.0.tar.gz.
File metadata
- Download URL: textattack_detection_atr-0.1.0.tar.gz
- Upload date:
- Size: 260.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.16
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2e785360112e13df048f8c4d918e7e7a88b493048f76a32605e9650c60ce4cbe
|
|
| MD5 |
5a9e96cded7dc7a4299a3ca0db8eea78
|
|
| BLAKE2b-256 |
8599dc2034767ef80f2b0e13f5dbe464e34a14010c38d0c65807c748eac3699c
|
File details
Details for the file textattack_detection_atr-0.1.0-py3-none-any.whl.
File metadata
- Download URL: textattack_detection_atr-0.1.0-py3-none-any.whl
- Upload date:
- Size: 254.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.11.16
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0f43f4ca85808bc618c1298f9dab96fd62fcf83a2cb2ea2ac2dd6f6bfb3e7082
|
|
| MD5 |
08da841e29f48eaf84103424b1a4500b
|
|
| BLAKE2b-256 |
64664c364e4cf01fcd8eefcd7f5e567ece38502f4137d52b1d93f9782dd09901
|