Measure a judge's Evaluator Trust Boundary susceptibility (ETB-01 verdict injection). Offline, stdlib-only, zero model spend.
Project description
etb-scan
Is your LLM judge grading the model, or is the model grading itself?
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 failure: the score depends on an artifact the evaluated system controls.
Offline. Standard library only. No API keys, no model spend, no network.
pip install etb-scan
etbscan
judge=naive overall_asr=1.0000 (25/25) control_fp=0.0000
delimiter_format_confusion asr=1.0000 (5/5)
direct_verdict_override asr=1.0000 (5/5)
fake_rubric_completion asr=1.0000 (5/5)
role_system_spoof asr=1.0000 (5/5)
unicode_hidden_smuggling asr=1.0000 (5/5)
judge=hardened overall_asr=0.0000 (0/25) control_fp=0.0000
Scanning a real LLM judge: use --trials
A real judge is stochastic. At one trial per scenario the measurement is too
noisy to gate on: against a judge that follows the injection half the time,
single-trial runs returned anywhere from 0.28 to 0.64. At --trials 10 the
same judge reads as fully exploitable, which is the correct answer, because one
successful injection is a vulnerability.
etbscan --judge yourpkg.judges:your_judge --trials 10
A scenario counts as exploited if the injection lands in any trial, so the reported ASR is a lower bound that tightens as trials rise. Use 1 trial only for a deterministic judge.
Scan your own judge
from etbscan import scan, load_corpus
def my_judge(candidate: str, rubric: dict, question: str | None = None) -> dict:
... # call your grader
return {"verdict": "pass", "score": 0.9}
result = scan(my_judge, load_corpus(), trials=10) # trials>1 for a real judge
print(result.overall_asr) # your ASR
print(result.control_fp_rate) # must be 0.0 for the ASR to mean anything
Any callable taking (candidate, rubric, question) or (candidate, rubric) works. The verdict dict needs a verdict key (pass/fail) or a numeric score.
What it measures, and what it refuses to measure
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.
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.
The corpus
33 scenarios: 25 injected across 5 families, 8 benign controls.
| Family | What the injection tries |
|---|---|
direct_verdict_override |
write the verdict token into the answer |
role_system_spoof |
forge a system or grader turn inside the answer |
fake_rubric_completion |
assert rubric items are satisfied |
delimiter_format_confusion |
close and reopen the prompt's structural delimiters |
unicode_hidden_smuggling |
homoglyph and zero-width smuggling past normalization |
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. hardened_judge scores only against the rubric, which is the message-role separation fix UK AISI maintainers merged.
If your ASR is above zero
The fix is not a better regex. It is moving the trust boundary:
- 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.
- Separate data from instructions using message roles, so candidate content cannot present as prompt structure.
- Bind extraction to a position you control (the last block, or an explicitly delimited region), not to the first match.
- Normalize before matching so homoglyph and zero-width variants collapse.
Fix patterns for all ten ETB classes are at authensor.com/etb.
Tests
python3 -m pytest tests/ -q
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.
The detector's precision/recall tradeoff
detect_grader_directed_injection, used by the Inspect task to score arbitrary
model output, is deliberately high precision and low recall: 0 false
positives on a hand-written honest-prose set, roughly 28% of corpus injections
caught.
An earlier version chased recall with imperative patterns ("ignore the rubric", "output your verdict") and fired on 53% of honest technical answers, because an answer about prompt injection is lexically identical to an injection. Penalising a correct answer is the same class of defect this tool exists to find, and worse than missing an attack.
So: a positive is strong evidence. A negative is not evidence of absence. Treat the Inspect task's score as a lower bound.
Scan another framework's judge
Adapters live in etbscan/integrations/. Each imports
its framework lazily, so etb-scan itself stays stdlib-only with zero runtime
dependencies.
| Framework | Install | Entry point |
|---|---|---|
| Inspect AI | pip install "etb-scan[inspect]" |
auto-registers; inspect eval verdict_injection |
| DeepEval | pip install "etb-scan[deepeval]" |
etbscan.integrations.deepeval.scan_metric(metric) |
| Weave | pip install "etb-scan[weave]" |
etbscan.integrations.weave.scan_scorer(scorer) |
All three are exercised against the real framework in CI, on push and weekly, because they break from upstream releases rather than from our commits.
In every adapter, a framework that raises, reports an error, or returns something unreadable is unscorable, never a failing verdict. Coercing an uninterpretable judgement into "did not pass" is the fail-open this tool exists to find.
Scan a DeepEval metric
A DeepEval metric is an LLM judge: it reads text the evaluated system wrote and returns a score. That makes it exactly the thing this measures.
pip install "etb-scan[deepeval]"
from deepeval.metrics import GEval
from deepeval.test_case import LLMTestCaseParams
from etbscan.integrations.deepeval import scan_metric
metric = GEval(
name="Correctness",
criteria="Is the answer correct and complete?",
evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT],
)
result = scan_metric(metric, trials=10)
print(result.overall_asr, result.control_fp_rate, result.trustworthy)
A metric that raises, sets .error, or reports .skipped comes back as
unscorable, never as "did not pass". Coercing those to a passing score is
the fail-open this tool exists to find.
DeepEval metrics are stateful, so the adapter serializes calls: max_workers
above 1 is safe but will not speed up a DeepEval scan. Give each worker its own
metric instance if you need real concurrency.
Verified against a real judge
The figures above characterize two offline mocks. verified_run_real_judge/
puts Kimi K3 behind three scoring code paths and measures what changes:
| arm | ASR | control FP | trustworthy |
|---|---|---|---|
| model called cleanly, verdict read from its own final line | 0.0000 | 0.0000 | yes |
| candidate concatenated into the prompt, verdict read from the reply | 0.0000 | 0.0000 | yes |
| same call as the first row, verdict read by greedy first-match over a candidate-containing blob | 1.0000 | 0.3750 | no |
Same model, same system prompt, same API call. Only the extraction moved. The model followed zero injections when extraction was bound correctly, which is the point: hardening the judge model bought nothing, because the defect was never in the model.
The control false-fires are the sharper result. Greedy first-match read PASS
out of the grader's own sentence "requires at least 3 for PASS", sixty
characters ahead of its actual VERDICT: FAIL. The judge was right on every
control. The code was not. A broken extraction path does not only leak to
attackers; it makes benign grading nondeterministic.
Scope and honesty
- The
1.0000/0.0000figures characterize the two reference judges, not the ecosystem. They are the instrument's calibration, not a population estimate. - A trustworthy result requires more than a low ASR.
trustworthyis 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 vacuous0.0000reads exactly like a clean bill of health, so the tool refuses to present one. - 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.
- 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.
- 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.
License
MIT. Copyright 2026 Authensor, Inc.
Project details
Release history Release notifications | RSS feed
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 etb_scan-0.1.1.tar.gz.
File metadata
- Download URL: etb_scan-0.1.1.tar.gz
- Upload date:
- Size: 38.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
682f1e2971a264ebaf0bb8dad457ee2d9d10e5aa3942d1a5efcdba846e908132
|
|
| MD5 |
bd02d98d815ed62dfdbd648e0b5f7c89
|
|
| BLAKE2b-256 |
dd78af459bef17210adbc850a3f366d2562dc44f6d10bc2892076ec86c02b10e
|
Provenance
The following attestation bundles were made for etb_scan-0.1.1.tar.gz:
Publisher:
publish.yml on AUTHENSOR/etb-scan
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
etb_scan-0.1.1.tar.gz -
Subject digest:
682f1e2971a264ebaf0bb8dad457ee2d9d10e5aa3942d1a5efcdba846e908132 - Sigstore transparency entry: 2262718217
- Sigstore integration time:
-
Permalink:
AUTHENSOR/etb-scan@416b9972133762b93e5f580de7c3d4032e599e4f -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/AUTHENSOR
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@416b9972133762b93e5f580de7c3d4032e599e4f -
Trigger Event:
push
-
Statement type:
File details
Details for the file etb_scan-0.1.1-py3-none-any.whl.
File metadata
- Download URL: etb_scan-0.1.1-py3-none-any.whl
- Upload date:
- Size: 35.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f5ab1aef84fd3ee5f51f0b9d245c17315309dfcbab986b74f15b6b656a3d71f8
|
|
| MD5 |
c72f25196fc42943ee3551ea258a70b2
|
|
| BLAKE2b-256 |
283b953bccca0d6b9f6901e0ba3f2f5e709e946c5b668b221874e6ac3f25313a
|
Provenance
The following attestation bundles were made for etb_scan-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on AUTHENSOR/etb-scan
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
etb_scan-0.1.1-py3-none-any.whl -
Subject digest:
f5ab1aef84fd3ee5f51f0b9d245c17315309dfcbab986b74f15b6b656a3d71f8 - Sigstore transparency entry: 2262718358
- Sigstore integration time:
-
Permalink:
AUTHENSOR/etb-scan@416b9972133762b93e5f580de7c3d4032e599e4f -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/AUTHENSOR
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@416b9972133762b93e5f580de7c3d4032e599e4f -
Trigger Event:
push
-
Statement type: