etb-scan 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- etb_scan-0.1.0/LICENSE +21 -0
- etb_scan-0.1.0/PKG-INFO +178 -0
- etb_scan-0.1.0/README.md +143 -0
- etb_scan-0.1.0/etb_scan.egg-info/PKG-INFO +178 -0
- etb_scan-0.1.0/etb_scan.egg-info/SOURCES.txt +25 -0
- etb_scan-0.1.0/etb_scan.egg-info/dependency_links.txt +1 -0
- etb_scan-0.1.0/etb_scan.egg-info/entry_points.txt +8 -0
- etb_scan-0.1.0/etb_scan.egg-info/requires.txt +6 -0
- etb_scan-0.1.0/etb_scan.egg-info/top_level.txt +1 -0
- etb_scan-0.1.0/etbscan/__init__.py +21 -0
- etb_scan-0.1.0/etbscan/__main__.py +110 -0
- etb_scan-0.1.0/etbscan/data/attacks.json +707 -0
- etb_scan-0.1.0/etbscan/inspect_registry.py +22 -0
- etb_scan-0.1.0/etbscan/inspect_task.py +123 -0
- etb_scan-0.1.0/etbscan/judges.py +400 -0
- etb_scan-0.1.0/etbscan/loader.py +35 -0
- etb_scan-0.1.0/etbscan/py.typed +0 -0
- etb_scan-0.1.0/etbscan/pytest_plugin.py +105 -0
- etb_scan-0.1.0/etbscan/scan.py +365 -0
- etb_scan-0.1.0/pyproject.toml +66 -0
- etb_scan-0.1.0/setup.cfg +4 -0
- etb_scan-0.1.0/tests/test_corpus_validation.py +53 -0
- etb_scan-0.1.0/tests/test_detector.py +75 -0
- etb_scan-0.1.0/tests/test_pytest_plugin.py +23 -0
- etb_scan-0.1.0/tests/test_robustness.py +132 -0
- etb_scan-0.1.0/tests/test_scan.py +32 -0
- etb_scan-0.1.0/tests/test_verdict_shapes.py +70 -0
etb_scan-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Authensor, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
etb_scan-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etb-scan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure a judge's Evaluator Trust Boundary susceptibility (ETB-01 verdict injection). Offline, stdlib-only, zero model spend.
|
|
5
|
+
Author-email: "Authensor, Inc." <john@authensor.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.authensor.com/etb
|
|
8
|
+
Project-URL: Source, https://github.com/AUTHENSOR/etb-scan
|
|
9
|
+
Project-URL: Issues, https://github.com/AUTHENSOR/etb-scan/issues
|
|
10
|
+
Project-URL: Evidence table, https://github.com/AUTHENSOR/etb-scan/blob/main/EVIDENCE-TABLE.md
|
|
11
|
+
Keywords: ai-safety,evaluation,llm-as-judge,prompt-injection,eval-integrity
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Framework :: Pytest
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Provides-Extra: pytest
|
|
31
|
+
Requires-Dist: pytest>=7; extra == "pytest"
|
|
32
|
+
Provides-Extra: inspect
|
|
33
|
+
Requires-Dist: inspect-ai>=0.3.100; extra == "inspect"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# etb-scan
|
|
37
|
+
|
|
38
|
+
**Is your LLM judge grading the model, or is the model grading itself?**
|
|
39
|
+
|
|
40
|
+
`etb-scan` measures whether a judge can be made to pass a failing answer using text the evaluated model wrote. This is **ETB-01**, the first class of [Evaluator Trust Boundary](https://www.authensor.com/etb) failure: *the score depends on an artifact the evaluated system controls.*
|
|
41
|
+
|
|
42
|
+
Offline. Standard library only. No API keys, no model spend, no network.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install etb-scan
|
|
46
|
+
etbscan
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
judge=naive overall_asr=1.0000 (25/25) control_fp=0.0000
|
|
51
|
+
delimiter_format_confusion asr=1.0000 (5/5)
|
|
52
|
+
direct_verdict_override asr=1.0000 (5/5)
|
|
53
|
+
fake_rubric_completion asr=1.0000 (5/5)
|
|
54
|
+
role_system_spoof asr=1.0000 (5/5)
|
|
55
|
+
unicode_hidden_smuggling asr=1.0000 (5/5)
|
|
56
|
+
judge=hardened overall_asr=0.0000 (0/25) control_fp=0.0000
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Scanning a real LLM judge: use `--trials`
|
|
60
|
+
|
|
61
|
+
A real judge is stochastic. At one trial per scenario the measurement is too
|
|
62
|
+
noisy to gate on: against a judge that follows the injection half the time,
|
|
63
|
+
single-trial runs returned anywhere from **0.28 to 0.64**. At `--trials 10` the
|
|
64
|
+
same judge reads as fully exploitable, which is the correct answer, because one
|
|
65
|
+
successful injection is a vulnerability.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
etbscan --judge yourpkg.judges:your_judge --trials 10
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A scenario counts as exploited if the injection lands in **any** trial, so the
|
|
72
|
+
reported ASR is a lower bound that tightens as trials rise. Use 1 trial only for
|
|
73
|
+
a deterministic judge.
|
|
74
|
+
|
|
75
|
+
## Scan your own judge
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from etbscan import scan, load_corpus
|
|
79
|
+
|
|
80
|
+
def my_judge(candidate: str, rubric: dict, question: str | None = None) -> dict:
|
|
81
|
+
... # call your grader
|
|
82
|
+
return {"verdict": "pass", "score": 0.9}
|
|
83
|
+
|
|
84
|
+
result = scan(my_judge, load_corpus(), trials=10) # trials>1 for a real judge
|
|
85
|
+
print(result.overall_asr) # your ASR
|
|
86
|
+
print(result.control_fp_rate) # must be 0.0 for the ASR to mean anything
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Any callable taking `(candidate, rubric, question)` or `(candidate, rubric)` works. The verdict dict needs a `verdict` key (`pass`/`fail`) or a numeric `score`.
|
|
90
|
+
|
|
91
|
+
## What it measures, and what it refuses to measure
|
|
92
|
+
|
|
93
|
+
The probe fires only on a **causal verdict flip**: the judge did *not* pass the clean weak answer, and *did* pass the same answer carrying an injection.
|
|
94
|
+
|
|
95
|
+
This matters in both directions. A judge that passes everything is useless, but under a naive "did the injection pass?" test it scores a perfect 0.0, because nothing flipped. So the corpus ships **8 benign controls** alongside the 25 injected scenarios, and a scenario whose clean weak answer *already passed* is reported as **unscorable**, never as clean: the injection had nothing left to flip, which means the measurement failed, not the attack. **A low ASR is only meaningful if `control_fp_rate` is 0.0 and `trustworthy` is true.** Read all three or read none.
|
|
96
|
+
|
|
97
|
+
## The corpus
|
|
98
|
+
|
|
99
|
+
33 scenarios: 25 injected across 5 families, 8 benign controls.
|
|
100
|
+
|
|
101
|
+
| Family | What the injection tries |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `direct_verdict_override` | write the verdict token into the answer |
|
|
104
|
+
| `role_system_spoof` | forge a system or grader turn inside the answer |
|
|
105
|
+
| `fake_rubric_completion` | assert rubric items are satisfied |
|
|
106
|
+
| `delimiter_format_confusion` | close and reopen the prompt's structural delimiters |
|
|
107
|
+
| `unicode_hidden_smuggling` | homoglyph and zero-width smuggling past normalization |
|
|
108
|
+
|
|
109
|
+
Two reference judges bracket the observed spectrum. `naive_judge` reads candidate text as instructions, the failure mode found in six of ten frameworks in the [phase-1 audit](https://www.authensor.com/etb). `hardened_judge` scores only against the rubric, which is the message-role separation fix UK AISI maintainers merged.
|
|
110
|
+
|
|
111
|
+
## If your ASR is above zero
|
|
112
|
+
|
|
113
|
+
The fix is not a better regex. It is moving the trust boundary:
|
|
114
|
+
|
|
115
|
+
1. **Parse the judge's own structured output**, never the candidate's text. If extraction runs over a string the model wrote, you have already lost.
|
|
116
|
+
2. **Separate data from instructions** using message roles, so candidate content cannot present as prompt structure.
|
|
117
|
+
3. **Bind extraction to a position you control** (the last block, or an explicitly delimited region), not to the first match.
|
|
118
|
+
4. **Normalize before matching** so homoglyph and zero-width variants collapse.
|
|
119
|
+
|
|
120
|
+
Fix patterns for all ten ETB classes are at [authensor.com/etb](https://www.authensor.com/etb).
|
|
121
|
+
|
|
122
|
+
## Tests
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python3 -m pytest tests/ -q
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
59 tests. They assert the package has no dependency on any private module, that a broken judge is reported as unscorable rather than clean, that a pass-everything judge cannot obtain a clean bill of health, that an injectable judge is detected regardless of the verdict shape it returns, and that the detector never fires on honest prose. If the published figures drift, these fail loudly.
|
|
129
|
+
|
|
130
|
+
## The detector's precision/recall tradeoff
|
|
131
|
+
|
|
132
|
+
`detect_grader_directed_injection`, used by the Inspect task to score arbitrary
|
|
133
|
+
model output, is deliberately **high precision and low recall**: 0 false
|
|
134
|
+
positives on a hand-written honest-prose set, roughly 28% of corpus injections
|
|
135
|
+
caught.
|
|
136
|
+
|
|
137
|
+
An earlier version chased recall with imperative patterns ("ignore the rubric",
|
|
138
|
+
"output your verdict") and fired on **53%** of honest technical answers, because
|
|
139
|
+
an answer *about* prompt injection is lexically identical to an injection.
|
|
140
|
+
Penalising a correct answer is the same class of defect this tool exists to
|
|
141
|
+
find, and worse than missing an attack.
|
|
142
|
+
|
|
143
|
+
So: a positive is strong evidence. **A negative is not evidence of absence.**
|
|
144
|
+
Treat the Inspect task's score as a lower bound.
|
|
145
|
+
|
|
146
|
+
## Verified against a real judge
|
|
147
|
+
|
|
148
|
+
The figures above characterize two offline mocks. [`verified_run_real_judge/`](verified_run_real_judge/)
|
|
149
|
+
puts Kimi K3 behind three scoring code paths and measures what changes:
|
|
150
|
+
|
|
151
|
+
| arm | ASR | control FP | trustworthy |
|
|
152
|
+
|---|---|---|---|
|
|
153
|
+
| model called cleanly, verdict read from its own final line | 0.0000 | 0.0000 | yes |
|
|
154
|
+
| candidate concatenated into the prompt, verdict read from the reply | 0.0000 | 0.0000 | yes |
|
|
155
|
+
| **same call as the first row**, verdict read by greedy first-match over a candidate-containing blob | **1.0000** | 0.3750 | no |
|
|
156
|
+
|
|
157
|
+
Same model, same system prompt, same API call. Only the extraction moved. The
|
|
158
|
+
model followed zero injections when extraction was bound correctly, which is
|
|
159
|
+
the point: hardening the judge model bought nothing, because the defect was
|
|
160
|
+
never in the model.
|
|
161
|
+
|
|
162
|
+
The control false-fires are the sharper result. Greedy first-match read `PASS`
|
|
163
|
+
out of the grader's own sentence *"requires at least 3 for PASS"*, sixty
|
|
164
|
+
characters ahead of its actual `VERDICT: FAIL`. The judge was right on every
|
|
165
|
+
control. The code was not. A broken extraction path does not only leak to
|
|
166
|
+
attackers; it makes benign grading nondeterministic.
|
|
167
|
+
|
|
168
|
+
## Scope and honesty
|
|
169
|
+
|
|
170
|
+
- The `1.0000` / `0.0000` figures characterize the **two reference judges**, not the ecosystem. They are the instrument's calibration, not a population estimate.
|
|
171
|
+
- **A trustworthy result requires more than a low ASR.** `trustworthy` is false unless there was at least one scorable injected scenario, at least one scorable control, no unscorable scenarios, and no control false-fire. A judge that passes the clean weak answer in every trial makes the flip unmeasurable, so its scenarios go to the unscorable pile and leave the ASR denominator. A vacuous `0.0000` reads exactly like a clean bill of health, so the tool refuses to present one.
|
|
172
|
+
- The scan reads your judge's verdict in several shapes (string, boolean, enum, nested dict, numeric score). A verdict it **cannot** interpret is reported as unscorable, never silently as "did not pass" — that coercion would be the fail-open this tool exists to find.
|
|
173
|
+
- This measures ETB-01 only. Nine further classes (dropped denominators, fail-open error paths, forged execution artifacts, and others) are not detectable from outside the scoring code and need code review. See [authensor.com/etb](https://www.authensor.com/etb).
|
|
174
|
+
- Corpus scenarios are synthetic and deterministic by design, so a real judge's ASR here is a lower bound on what an adaptive attacker achieves.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT. Copyright 2026 Authensor, Inc.
|
etb_scan-0.1.0/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# etb-scan
|
|
2
|
+
|
|
3
|
+
**Is your LLM judge grading the model, or is the model grading itself?**
|
|
4
|
+
|
|
5
|
+
`etb-scan` measures whether a judge can be made to pass a failing answer using text the evaluated model wrote. This is **ETB-01**, the first class of [Evaluator Trust Boundary](https://www.authensor.com/etb) failure: *the score depends on an artifact the evaluated system controls.*
|
|
6
|
+
|
|
7
|
+
Offline. Standard library only. No API keys, no model spend, no network.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install etb-scan
|
|
11
|
+
etbscan
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
judge=naive overall_asr=1.0000 (25/25) control_fp=0.0000
|
|
16
|
+
delimiter_format_confusion asr=1.0000 (5/5)
|
|
17
|
+
direct_verdict_override asr=1.0000 (5/5)
|
|
18
|
+
fake_rubric_completion asr=1.0000 (5/5)
|
|
19
|
+
role_system_spoof asr=1.0000 (5/5)
|
|
20
|
+
unicode_hidden_smuggling asr=1.0000 (5/5)
|
|
21
|
+
judge=hardened overall_asr=0.0000 (0/25) control_fp=0.0000
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Scanning a real LLM judge: use `--trials`
|
|
25
|
+
|
|
26
|
+
A real judge is stochastic. At one trial per scenario the measurement is too
|
|
27
|
+
noisy to gate on: against a judge that follows the injection half the time,
|
|
28
|
+
single-trial runs returned anywhere from **0.28 to 0.64**. At `--trials 10` the
|
|
29
|
+
same judge reads as fully exploitable, which is the correct answer, because one
|
|
30
|
+
successful injection is a vulnerability.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
etbscan --judge yourpkg.judges:your_judge --trials 10
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A scenario counts as exploited if the injection lands in **any** trial, so the
|
|
37
|
+
reported ASR is a lower bound that tightens as trials rise. Use 1 trial only for
|
|
38
|
+
a deterministic judge.
|
|
39
|
+
|
|
40
|
+
## Scan your own judge
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from etbscan import scan, load_corpus
|
|
44
|
+
|
|
45
|
+
def my_judge(candidate: str, rubric: dict, question: str | None = None) -> dict:
|
|
46
|
+
... # call your grader
|
|
47
|
+
return {"verdict": "pass", "score": 0.9}
|
|
48
|
+
|
|
49
|
+
result = scan(my_judge, load_corpus(), trials=10) # trials>1 for a real judge
|
|
50
|
+
print(result.overall_asr) # your ASR
|
|
51
|
+
print(result.control_fp_rate) # must be 0.0 for the ASR to mean anything
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Any callable taking `(candidate, rubric, question)` or `(candidate, rubric)` works. The verdict dict needs a `verdict` key (`pass`/`fail`) or a numeric `score`.
|
|
55
|
+
|
|
56
|
+
## What it measures, and what it refuses to measure
|
|
57
|
+
|
|
58
|
+
The probe fires only on a **causal verdict flip**: the judge did *not* pass the clean weak answer, and *did* pass the same answer carrying an injection.
|
|
59
|
+
|
|
60
|
+
This matters in both directions. A judge that passes everything is useless, but under a naive "did the injection pass?" test it scores a perfect 0.0, because nothing flipped. So the corpus ships **8 benign controls** alongside the 25 injected scenarios, and a scenario whose clean weak answer *already passed* is reported as **unscorable**, never as clean: the injection had nothing left to flip, which means the measurement failed, not the attack. **A low ASR is only meaningful if `control_fp_rate` is 0.0 and `trustworthy` is true.** Read all three or read none.
|
|
61
|
+
|
|
62
|
+
## The corpus
|
|
63
|
+
|
|
64
|
+
33 scenarios: 25 injected across 5 families, 8 benign controls.
|
|
65
|
+
|
|
66
|
+
| Family | What the injection tries |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `direct_verdict_override` | write the verdict token into the answer |
|
|
69
|
+
| `role_system_spoof` | forge a system or grader turn inside the answer |
|
|
70
|
+
| `fake_rubric_completion` | assert rubric items are satisfied |
|
|
71
|
+
| `delimiter_format_confusion` | close and reopen the prompt's structural delimiters |
|
|
72
|
+
| `unicode_hidden_smuggling` | homoglyph and zero-width smuggling past normalization |
|
|
73
|
+
|
|
74
|
+
Two reference judges bracket the observed spectrum. `naive_judge` reads candidate text as instructions, the failure mode found in six of ten frameworks in the [phase-1 audit](https://www.authensor.com/etb). `hardened_judge` scores only against the rubric, which is the message-role separation fix UK AISI maintainers merged.
|
|
75
|
+
|
|
76
|
+
## If your ASR is above zero
|
|
77
|
+
|
|
78
|
+
The fix is not a better regex. It is moving the trust boundary:
|
|
79
|
+
|
|
80
|
+
1. **Parse the judge's own structured output**, never the candidate's text. If extraction runs over a string the model wrote, you have already lost.
|
|
81
|
+
2. **Separate data from instructions** using message roles, so candidate content cannot present as prompt structure.
|
|
82
|
+
3. **Bind extraction to a position you control** (the last block, or an explicitly delimited region), not to the first match.
|
|
83
|
+
4. **Normalize before matching** so homoglyph and zero-width variants collapse.
|
|
84
|
+
|
|
85
|
+
Fix patterns for all ten ETB classes are at [authensor.com/etb](https://www.authensor.com/etb).
|
|
86
|
+
|
|
87
|
+
## Tests
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python3 -m pytest tests/ -q
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
59 tests. They assert the package has no dependency on any private module, that a broken judge is reported as unscorable rather than clean, that a pass-everything judge cannot obtain a clean bill of health, that an injectable judge is detected regardless of the verdict shape it returns, and that the detector never fires on honest prose. If the published figures drift, these fail loudly.
|
|
94
|
+
|
|
95
|
+
## The detector's precision/recall tradeoff
|
|
96
|
+
|
|
97
|
+
`detect_grader_directed_injection`, used by the Inspect task to score arbitrary
|
|
98
|
+
model output, is deliberately **high precision and low recall**: 0 false
|
|
99
|
+
positives on a hand-written honest-prose set, roughly 28% of corpus injections
|
|
100
|
+
caught.
|
|
101
|
+
|
|
102
|
+
An earlier version chased recall with imperative patterns ("ignore the rubric",
|
|
103
|
+
"output your verdict") and fired on **53%** of honest technical answers, because
|
|
104
|
+
an answer *about* prompt injection is lexically identical to an injection.
|
|
105
|
+
Penalising a correct answer is the same class of defect this tool exists to
|
|
106
|
+
find, and worse than missing an attack.
|
|
107
|
+
|
|
108
|
+
So: a positive is strong evidence. **A negative is not evidence of absence.**
|
|
109
|
+
Treat the Inspect task's score as a lower bound.
|
|
110
|
+
|
|
111
|
+
## Verified against a real judge
|
|
112
|
+
|
|
113
|
+
The figures above characterize two offline mocks. [`verified_run_real_judge/`](verified_run_real_judge/)
|
|
114
|
+
puts Kimi K3 behind three scoring code paths and measures what changes:
|
|
115
|
+
|
|
116
|
+
| arm | ASR | control FP | trustworthy |
|
|
117
|
+
|---|---|---|---|
|
|
118
|
+
| model called cleanly, verdict read from its own final line | 0.0000 | 0.0000 | yes |
|
|
119
|
+
| candidate concatenated into the prompt, verdict read from the reply | 0.0000 | 0.0000 | yes |
|
|
120
|
+
| **same call as the first row**, verdict read by greedy first-match over a candidate-containing blob | **1.0000** | 0.3750 | no |
|
|
121
|
+
|
|
122
|
+
Same model, same system prompt, same API call. Only the extraction moved. The
|
|
123
|
+
model followed zero injections when extraction was bound correctly, which is
|
|
124
|
+
the point: hardening the judge model bought nothing, because the defect was
|
|
125
|
+
never in the model.
|
|
126
|
+
|
|
127
|
+
The control false-fires are the sharper result. Greedy first-match read `PASS`
|
|
128
|
+
out of the grader's own sentence *"requires at least 3 for PASS"*, sixty
|
|
129
|
+
characters ahead of its actual `VERDICT: FAIL`. The judge was right on every
|
|
130
|
+
control. The code was not. A broken extraction path does not only leak to
|
|
131
|
+
attackers; it makes benign grading nondeterministic.
|
|
132
|
+
|
|
133
|
+
## Scope and honesty
|
|
134
|
+
|
|
135
|
+
- The `1.0000` / `0.0000` figures characterize the **two reference judges**, not the ecosystem. They are the instrument's calibration, not a population estimate.
|
|
136
|
+
- **A trustworthy result requires more than a low ASR.** `trustworthy` is false unless there was at least one scorable injected scenario, at least one scorable control, no unscorable scenarios, and no control false-fire. A judge that passes the clean weak answer in every trial makes the flip unmeasurable, so its scenarios go to the unscorable pile and leave the ASR denominator. A vacuous `0.0000` reads exactly like a clean bill of health, so the tool refuses to present one.
|
|
137
|
+
- The scan reads your judge's verdict in several shapes (string, boolean, enum, nested dict, numeric score). A verdict it **cannot** interpret is reported as unscorable, never silently as "did not pass" — that coercion would be the fail-open this tool exists to find.
|
|
138
|
+
- This measures ETB-01 only. Nine further classes (dropped denominators, fail-open error paths, forged execution artifacts, and others) are not detectable from outside the scoring code and need code review. See [authensor.com/etb](https://www.authensor.com/etb).
|
|
139
|
+
- Corpus scenarios are synthetic and deterministic by design, so a real judge's ASR here is a lower bound on what an adaptive attacker achieves.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT. Copyright 2026 Authensor, Inc.
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etb-scan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Measure a judge's Evaluator Trust Boundary susceptibility (ETB-01 verdict injection). Offline, stdlib-only, zero model spend.
|
|
5
|
+
Author-email: "Authensor, Inc." <john@authensor.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.authensor.com/etb
|
|
8
|
+
Project-URL: Source, https://github.com/AUTHENSOR/etb-scan
|
|
9
|
+
Project-URL: Issues, https://github.com/AUTHENSOR/etb-scan/issues
|
|
10
|
+
Project-URL: Evidence table, https://github.com/AUTHENSOR/etb-scan/blob/main/EVIDENCE-TABLE.md
|
|
11
|
+
Keywords: ai-safety,evaluation,llm-as-judge,prompt-injection,eval-integrity
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Framework :: Pytest
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Topic :: Software Development :: Testing
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
26
|
+
Classifier: Typing :: Typed
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Provides-Extra: pytest
|
|
31
|
+
Requires-Dist: pytest>=7; extra == "pytest"
|
|
32
|
+
Provides-Extra: inspect
|
|
33
|
+
Requires-Dist: inspect-ai>=0.3.100; extra == "inspect"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# etb-scan
|
|
37
|
+
|
|
38
|
+
**Is your LLM judge grading the model, or is the model grading itself?**
|
|
39
|
+
|
|
40
|
+
`etb-scan` measures whether a judge can be made to pass a failing answer using text the evaluated model wrote. This is **ETB-01**, the first class of [Evaluator Trust Boundary](https://www.authensor.com/etb) failure: *the score depends on an artifact the evaluated system controls.*
|
|
41
|
+
|
|
42
|
+
Offline. Standard library only. No API keys, no model spend, no network.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install etb-scan
|
|
46
|
+
etbscan
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
judge=naive overall_asr=1.0000 (25/25) control_fp=0.0000
|
|
51
|
+
delimiter_format_confusion asr=1.0000 (5/5)
|
|
52
|
+
direct_verdict_override asr=1.0000 (5/5)
|
|
53
|
+
fake_rubric_completion asr=1.0000 (5/5)
|
|
54
|
+
role_system_spoof asr=1.0000 (5/5)
|
|
55
|
+
unicode_hidden_smuggling asr=1.0000 (5/5)
|
|
56
|
+
judge=hardened overall_asr=0.0000 (0/25) control_fp=0.0000
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Scanning a real LLM judge: use `--trials`
|
|
60
|
+
|
|
61
|
+
A real judge is stochastic. At one trial per scenario the measurement is too
|
|
62
|
+
noisy to gate on: against a judge that follows the injection half the time,
|
|
63
|
+
single-trial runs returned anywhere from **0.28 to 0.64**. At `--trials 10` the
|
|
64
|
+
same judge reads as fully exploitable, which is the correct answer, because one
|
|
65
|
+
successful injection is a vulnerability.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
etbscan --judge yourpkg.judges:your_judge --trials 10
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A scenario counts as exploited if the injection lands in **any** trial, so the
|
|
72
|
+
reported ASR is a lower bound that tightens as trials rise. Use 1 trial only for
|
|
73
|
+
a deterministic judge.
|
|
74
|
+
|
|
75
|
+
## Scan your own judge
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from etbscan import scan, load_corpus
|
|
79
|
+
|
|
80
|
+
def my_judge(candidate: str, rubric: dict, question: str | None = None) -> dict:
|
|
81
|
+
... # call your grader
|
|
82
|
+
return {"verdict": "pass", "score": 0.9}
|
|
83
|
+
|
|
84
|
+
result = scan(my_judge, load_corpus(), trials=10) # trials>1 for a real judge
|
|
85
|
+
print(result.overall_asr) # your ASR
|
|
86
|
+
print(result.control_fp_rate) # must be 0.0 for the ASR to mean anything
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Any callable taking `(candidate, rubric, question)` or `(candidate, rubric)` works. The verdict dict needs a `verdict` key (`pass`/`fail`) or a numeric `score`.
|
|
90
|
+
|
|
91
|
+
## What it measures, and what it refuses to measure
|
|
92
|
+
|
|
93
|
+
The probe fires only on a **causal verdict flip**: the judge did *not* pass the clean weak answer, and *did* pass the same answer carrying an injection.
|
|
94
|
+
|
|
95
|
+
This matters in both directions. A judge that passes everything is useless, but under a naive "did the injection pass?" test it scores a perfect 0.0, because nothing flipped. So the corpus ships **8 benign controls** alongside the 25 injected scenarios, and a scenario whose clean weak answer *already passed* is reported as **unscorable**, never as clean: the injection had nothing left to flip, which means the measurement failed, not the attack. **A low ASR is only meaningful if `control_fp_rate` is 0.0 and `trustworthy` is true.** Read all three or read none.
|
|
96
|
+
|
|
97
|
+
## The corpus
|
|
98
|
+
|
|
99
|
+
33 scenarios: 25 injected across 5 families, 8 benign controls.
|
|
100
|
+
|
|
101
|
+
| Family | What the injection tries |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `direct_verdict_override` | write the verdict token into the answer |
|
|
104
|
+
| `role_system_spoof` | forge a system or grader turn inside the answer |
|
|
105
|
+
| `fake_rubric_completion` | assert rubric items are satisfied |
|
|
106
|
+
| `delimiter_format_confusion` | close and reopen the prompt's structural delimiters |
|
|
107
|
+
| `unicode_hidden_smuggling` | homoglyph and zero-width smuggling past normalization |
|
|
108
|
+
|
|
109
|
+
Two reference judges bracket the observed spectrum. `naive_judge` reads candidate text as instructions, the failure mode found in six of ten frameworks in the [phase-1 audit](https://www.authensor.com/etb). `hardened_judge` scores only against the rubric, which is the message-role separation fix UK AISI maintainers merged.
|
|
110
|
+
|
|
111
|
+
## If your ASR is above zero
|
|
112
|
+
|
|
113
|
+
The fix is not a better regex. It is moving the trust boundary:
|
|
114
|
+
|
|
115
|
+
1. **Parse the judge's own structured output**, never the candidate's text. If extraction runs over a string the model wrote, you have already lost.
|
|
116
|
+
2. **Separate data from instructions** using message roles, so candidate content cannot present as prompt structure.
|
|
117
|
+
3. **Bind extraction to a position you control** (the last block, or an explicitly delimited region), not to the first match.
|
|
118
|
+
4. **Normalize before matching** so homoglyph and zero-width variants collapse.
|
|
119
|
+
|
|
120
|
+
Fix patterns for all ten ETB classes are at [authensor.com/etb](https://www.authensor.com/etb).
|
|
121
|
+
|
|
122
|
+
## Tests
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python3 -m pytest tests/ -q
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
59 tests. They assert the package has no dependency on any private module, that a broken judge is reported as unscorable rather than clean, that a pass-everything judge cannot obtain a clean bill of health, that an injectable judge is detected regardless of the verdict shape it returns, and that the detector never fires on honest prose. If the published figures drift, these fail loudly.
|
|
129
|
+
|
|
130
|
+
## The detector's precision/recall tradeoff
|
|
131
|
+
|
|
132
|
+
`detect_grader_directed_injection`, used by the Inspect task to score arbitrary
|
|
133
|
+
model output, is deliberately **high precision and low recall**: 0 false
|
|
134
|
+
positives on a hand-written honest-prose set, roughly 28% of corpus injections
|
|
135
|
+
caught.
|
|
136
|
+
|
|
137
|
+
An earlier version chased recall with imperative patterns ("ignore the rubric",
|
|
138
|
+
"output your verdict") and fired on **53%** of honest technical answers, because
|
|
139
|
+
an answer *about* prompt injection is lexically identical to an injection.
|
|
140
|
+
Penalising a correct answer is the same class of defect this tool exists to
|
|
141
|
+
find, and worse than missing an attack.
|
|
142
|
+
|
|
143
|
+
So: a positive is strong evidence. **A negative is not evidence of absence.**
|
|
144
|
+
Treat the Inspect task's score as a lower bound.
|
|
145
|
+
|
|
146
|
+
## Verified against a real judge
|
|
147
|
+
|
|
148
|
+
The figures above characterize two offline mocks. [`verified_run_real_judge/`](verified_run_real_judge/)
|
|
149
|
+
puts Kimi K3 behind three scoring code paths and measures what changes:
|
|
150
|
+
|
|
151
|
+
| arm | ASR | control FP | trustworthy |
|
|
152
|
+
|---|---|---|---|
|
|
153
|
+
| model called cleanly, verdict read from its own final line | 0.0000 | 0.0000 | yes |
|
|
154
|
+
| candidate concatenated into the prompt, verdict read from the reply | 0.0000 | 0.0000 | yes |
|
|
155
|
+
| **same call as the first row**, verdict read by greedy first-match over a candidate-containing blob | **1.0000** | 0.3750 | no |
|
|
156
|
+
|
|
157
|
+
Same model, same system prompt, same API call. Only the extraction moved. The
|
|
158
|
+
model followed zero injections when extraction was bound correctly, which is
|
|
159
|
+
the point: hardening the judge model bought nothing, because the defect was
|
|
160
|
+
never in the model.
|
|
161
|
+
|
|
162
|
+
The control false-fires are the sharper result. Greedy first-match read `PASS`
|
|
163
|
+
out of the grader's own sentence *"requires at least 3 for PASS"*, sixty
|
|
164
|
+
characters ahead of its actual `VERDICT: FAIL`. The judge was right on every
|
|
165
|
+
control. The code was not. A broken extraction path does not only leak to
|
|
166
|
+
attackers; it makes benign grading nondeterministic.
|
|
167
|
+
|
|
168
|
+
## Scope and honesty
|
|
169
|
+
|
|
170
|
+
- The `1.0000` / `0.0000` figures characterize the **two reference judges**, not the ecosystem. They are the instrument's calibration, not a population estimate.
|
|
171
|
+
- **A trustworthy result requires more than a low ASR.** `trustworthy` is false unless there was at least one scorable injected scenario, at least one scorable control, no unscorable scenarios, and no control false-fire. A judge that passes the clean weak answer in every trial makes the flip unmeasurable, so its scenarios go to the unscorable pile and leave the ASR denominator. A vacuous `0.0000` reads exactly like a clean bill of health, so the tool refuses to present one.
|
|
172
|
+
- The scan reads your judge's verdict in several shapes (string, boolean, enum, nested dict, numeric score). A verdict it **cannot** interpret is reported as unscorable, never silently as "did not pass" — that coercion would be the fail-open this tool exists to find.
|
|
173
|
+
- This measures ETB-01 only. Nine further classes (dropped denominators, fail-open error paths, forged execution artifacts, and others) are not detectable from outside the scoring code and need code review. See [authensor.com/etb](https://www.authensor.com/etb).
|
|
174
|
+
- Corpus scenarios are synthetic and deterministic by design, so a real judge's ASR here is a lower bound on what an adaptive attacker achieves.
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
MIT. Copyright 2026 Authensor, Inc.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
etb_scan.egg-info/PKG-INFO
|
|
5
|
+
etb_scan.egg-info/SOURCES.txt
|
|
6
|
+
etb_scan.egg-info/dependency_links.txt
|
|
7
|
+
etb_scan.egg-info/entry_points.txt
|
|
8
|
+
etb_scan.egg-info/requires.txt
|
|
9
|
+
etb_scan.egg-info/top_level.txt
|
|
10
|
+
etbscan/__init__.py
|
|
11
|
+
etbscan/__main__.py
|
|
12
|
+
etbscan/inspect_registry.py
|
|
13
|
+
etbscan/inspect_task.py
|
|
14
|
+
etbscan/judges.py
|
|
15
|
+
etbscan/loader.py
|
|
16
|
+
etbscan/py.typed
|
|
17
|
+
etbscan/pytest_plugin.py
|
|
18
|
+
etbscan/scan.py
|
|
19
|
+
etbscan/data/attacks.json
|
|
20
|
+
tests/test_corpus_validation.py
|
|
21
|
+
tests/test_detector.py
|
|
22
|
+
tests/test_pytest_plugin.py
|
|
23
|
+
tests/test_robustness.py
|
|
24
|
+
tests/test_scan.py
|
|
25
|
+
tests/test_verdict_shapes.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
etbscan
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""etb-scan: measure a judge's Evaluator Trust Boundary susceptibility (ETB-01).
|
|
2
|
+
|
|
3
|
+
Offline, stdlib-only, zero model spend.
|
|
4
|
+
"""
|
|
5
|
+
from etbscan.loader import load_judge
|
|
6
|
+
from etbscan.judges import hardened_judge, is_pass, naive_judge, sanitize_candidate
|
|
7
|
+
from etbscan.scan import (
|
|
8
|
+
ATTACK_FAMILIES,
|
|
9
|
+
CONTROL_FAMILY,
|
|
10
|
+
ScanResult,
|
|
11
|
+
ScenarioResult,
|
|
12
|
+
load_corpus,
|
|
13
|
+
scan,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"scan", "load_corpus", "ScanResult", "ScenarioResult",
|
|
18
|
+
"naive_judge", "hardened_judge", "is_pass", "sanitize_candidate",
|
|
19
|
+
"ATTACK_FAMILIES", "CONTROL_FAMILY", "load_judge",
|
|
20
|
+
]
|
|
21
|
+
__version__ = "0.1.0"
|