Metadata-Version: 2.4
Name: textattack-detection-atr
Version: 0.1.0
Summary: Detection-evaluation companion for TextAttack: measures how many successful attacks a content-layer defence would have caught.
Author-email: Adam Lin <adam@threatrule.org>
License: MIT
Project-URL: Homepage, https://github.com/Agent-Threat-Rule/textattack-detection-atr
Project-URL: Agent Threat Rules, https://github.com/Agent-Threat-Rule/agent-threat-rules
Keywords: textattack,adversarial,nlp,detection,security
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Security
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: textattack>=0.3.8
Requires-Dist: regex>=2023.0.0
Provides-Extra: pyatr
Requires-Dist: pyatr>=0.3.0; extra == "pyatr"
Provides-Extra: test
Requires-Dist: pytest>=7; extra == "test"
Dynamic: license-file

# textattack-detection-atr

A detection-evaluation companion for [TextAttack](https://github.com/QData/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](https://github.com/Agent-Threat-Rule/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`:

```python
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:

```python
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:

```python
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:

```python
>>> 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:

```python
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:

```python
>>> 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.

```python
>>> 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.
