Skip to main content

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, which re rejects);
  • 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

textattack_detection_atr-0.1.0.tar.gz (260.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

textattack_detection_atr-0.1.0-py3-none-any.whl (254.3 kB view details)

Uploaded Python 3

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

Hashes for textattack_detection_atr-0.1.0.tar.gz
Algorithm Hash digest
SHA256 2e785360112e13df048f8c4d918e7e7a88b493048f76a32605e9650c60ce4cbe
MD5 5a9e96cded7dc7a4299a3ca0db8eea78
BLAKE2b-256 8599dc2034767ef80f2b0e13f5dbe464e34a14010c38d0c65807c748eac3699c

See more details on using hashes here.

File details

Details for the file textattack_detection_atr-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for textattack_detection_atr-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0f43f4ca85808bc618c1298f9dab96fd62fcf83a2cb2ea2ac2dd6f6bfb3e7082
MD5 08da841e29f48eaf84103424b1a4500b
BLAKE2b-256 64664c364e4cf01fcd8eefcd7f5e567ece38502f4137d52b1d93f9782dd09901

See more details on using hashes here.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page