Your Guardrail Test Still Passes With the Guardrail Deleted
To test whether an AI agent guardrail is still working, run your labelled corpus twice: once as built, once with the gate replaced by a pass-through. ablation_probe.py sorts each bad input into LOAD_BEARING, SHADOWED or MISSED, and exits 1 when no verdict moved. A rejection shows that something said no. It does not show that the gate did.
Here is the shape of the problem. You have a policy gate. You wrote a fixture that it is supposed to reject. CI runs the fixture, the pipeline says DENY, the build is green. Now delete the gate. On my corpus, 9 of 26 bad inputs are still rejected, by other stages that were in the pipeline the whole time.
Those 9 are decoration. Denied with the gate, denied without it. Whatever they test, it is not the gate.
AI disclosure. I wrote
ablation_probe.pyandmake_corpus.pywith AI assistance and ran them myself before publishing: offline, standard library only, no network, no keys, no funds. Every output block below is pasted from a real run on Python 3.13.5.run_all.shexecutes 31 scenarios, each three times, and compares the copies byte for byte: it reported31 of 31 byte-identical across three runs. Code sha256:ablation_probe.py52035306…d6e4,make_corpus.pye5d86c35…39c4,run_all.sh96ff9499…5fb8, corpus9ac269d7…06ef9, second corpus3eb86f67…c315. Output blocks are excerpts from one run of the named scenario:[...]marks rows dropped from inside a table, and where a block is not the whole report I have cut headers, the pre-flight or the good-input table around it. Nothing inside a block is reworded. The pipeline and both corpora are synthetic, written by me, and I say so again where they appear. Five defects of my own die further down the page. Two I found; the other three were found by a pre-publication review of this draft, after I had already written the numbers that turned out to be describing something else. One of those three is the exact failure this article is about, sitting inside the tool that is supposed to detect it.
In short:
- With the gate deleted,
9of26bad inputs on my corpus are still denied. Grouped by class:3of my6declared bad classes survive the gate’s removal intact. A known-bad twin drawn only from those classes stays green with no gate at all. SHADOWEDdoes not mean “caught earlier”. In order A all9are masked by a stage before the gate; in order B,6are masked by a stage after it and only2before. My tool printed the word “earlier” as a hardcoded constant for a day, in the very order whose numbers this post publishes.- Moving the gate two positions earlier changes nothing: order C has a class map identical to order A,
17/9, exit0. What flips a class is crossing the path canonicaliser, which is an undeclared precondition of the gate, not a position in a list. - Crossing it turns
1of6bad classes fromLOAD_BEARINGtoMISSED. Six inputs that were rejected now reachexecute. Both orders exit0; the reports differ, the exit codes do not. - Counting attribution by the first stage that says no undercounts the gate by
6of26inputs in order A and by0in order B. - The “measured trade-off” I published in the first draft was a property of how I wrote six paths. Rewrite the good rows in absolute form and the
strictgate goes from4false positives and exit1to0and exit0. TheMISSEDresult survives that rewrite; the trade-off does not. --recordand--expectin one command was green by construction, and a failing run could freeze itself as the baseline. Both are now refusals.- Null control: ablate a declared no-op stage and the probe reports
26SHADOWED, exit1, no crash, no claim that the pipeline is broken.
Nobody checks whether the guardrail is running
The framing is not mine. arun rajkumar (@mickyarun) published Nobody Checks Whether the Guardrail Is Running on Dev.to on 2026-09-07. His own line for it: “A guardrail that has never fired and a guardrail that silently stopped running produce identical output. Green.” In the same post he relays a point from Heinrich Neb, that every grader needs a known-bad twin, “an input it is supposed to reject, plus a recorded date of when it last actually rejected something”, and calls Neb’s framing the sharpest version he has seen.
We said the statistical half of that ourselves in July, in zero failures is not zero risk: “a check that always returns ‘fine’ and a check that has stopped running produce the identical screenshot.” That post answered with a bound on the failure rate you have not yet observed. It did not build anything that could tell the two checks apart. This one does, and finds that the twin has a hole of its own.
A known-bad twin proves something said no
Write the assert and look at it: assert pipeline(bad_input) == DENY.
The subject of that sentence is the pipeline. Not the gate. And the gate is not first: arguments have to be parsed before a policy gate has anything to read. Whether schema validation and the path allowlist sit in front of it or behind it is a design choice, and I measure three arrangements below. Either way, a known-bad input that is also malformed gets rejected by the schema, whether or not the gate is alive.
That is not hypothetical, it is the boring case. Fixtures get written by hand, in a hurry. My guess is that the first bad inputs most people reach for are a path traversal and a malformed payload, and it is only a guess: I have not surveyed anyone.
The observable quantity is not the verdict. It is whether the verdict moves when the gate goes.
How do you test whether a guardrail is still working? Delete it and rerun
One mutant, pointed at a guardrail. The mutant is the deleted stage. If your suite does not notice, your suite is not testing that stage. Three outcomes per bad input:
| class | with gate | gate removed | what it means |
|---|---|---|---|
LOAD_BEARING | DENY | ALLOW | this input can detect a dead gate |
SHADOWED | DENY | DENY | some other stage catches it; useless as a canary |
MISSED | ALLOW | ALLOW | a hole; the gate did not cover it in either run |
Good inputs are a separate control. A good input denied is a FALSE_POSITIVE, because a gate that rejects too much scores well on bad inputs alone. Two of the runs below do exactly that.
Run it, no keys, no network:
python3 make_corpus.py corpus.jsonl
python3 ablation_probe.py corpus.jsonl --order A --gate v2 --ablate policy_gate
The pipeline under test is in the same file: normalize (trims and lowercases), schema_check (types and required fields), resolve_path (collapses .. against the workspace root, string arithmetic only, no filesystem), path_allowlist, audit_log (a declared no-op), policy_gate, execute (a sink; nothing is executed). The gate refuses destructive SQL, tools outside the mandate, absolute paths outside /work/ws, and writes to .git, .env and .ssh inside it.
About the corpus, precisely, because the first version of this post overstated it: two of the six bad classes are the product of declared axes, destructive_sql at four verbs by two tables and protected_inside_workspace at three targets by two spellings, which is 14 of the 26 bad rows. The other four classes are lists of three that I wrote. The nine good rows are five column names crossed with one table, plus four tool-and-path pairs I wrote. So: partly enumerated, partly picked, and the split is 14 generated against 21 hand-written across the whole 35-row file.
The column axis in the good rows is doing real work, and it is the reason it exists. Four of those five column names contain a destructive verb as a substring: deleted_at, altered_at, dropped_by, truncated_at.
Three classes on the same corpus
corpus corpus.jsonl sha256 9ac269d75eb19cbd
35 rows: 26 bad in 6 classes, 9 good
pipeline order A
stages normalize -> schema_check -> resolve_path -> path_allowlist -> audit_log -> policy_gate -> execute
gate variant v2
ablated stage policy_gate
note: the counts below are exhaustive over a declared corpus, not a sample
drawn from one. No standard error is printed because nothing here is
estimated; every row is a decision the pipeline actually made.
pre-flight
determinism two full ON passes byte-identical: yes
policy_gate invoked 61 times per pass. A flaw whose period divides
that number survives THIS check: both passes start in phase.
order-independence same rows visited back to front, same verdicts: yes
this is the one that catches a phase-aligned flaw, and it is
here because the check above let one through.
transforms resolve_path (cannot be ablated: removing one changes the input downstream)
detected on THIS corpus, not declared: a transform with nothing
to transform here reads as pure. Scope of that claim is 35 rows.
target purity policy_gate left all 35 corpus rows byte-identical OK
The note: block is printed by the tool, not added here. These counts are exhaustive over a declared corpus rather than a sample from one, so no standard error appears anywhere in the output. Nothing is estimated. Every row is a decision the pipeline made.
by bad class (n = inputs in that class; survives = still DENY with policy_gate deleted)
bad_class n classes survives gate deletion
--------------------------------------------------------------------------------------------
destructive_sql 8 LOAD_BEARING 0 of 8
escape_workspace_absolute 3 SHADOWED 3 of 3
escape_workspace_traversal 3 SHADOWED 3 of 3
protected_inside_workspace 6 LOAD_BEARING 0 of 6
tool_outside_mandate 3 LOAD_BEARING 0 of 3
schema_broken 3 SHADOWED 3 of 3
summary
LOAD_BEARING 17
SHADOWED 9
ok 9
verdict GATE-LOAD-BEARING
exit 0
report-sha256 a09db437922eb50d1447e3e0b7db32d151c42df973b6a0d78d5755f2a726bade
Which of your bad fixtures can actually detect a dead gate?
This is the part I would run first on someone else’s repo.
known-bad twin blindness
with policy_gate deleted, 9 of 26 bad inputs are still DENIED by another stage.
where that stage sits relative to policy_gate: before it 9 · after it 0 · both 0
'shadowed' does not mean 'caught earlier'. In this order it means caught at all.
a twin built only from these classes stays 100% green with no gate at all:
escape_workspace_absolute
escape_workspace_traversal
schema_broken
that is 3 of 6 classes, 9 of 26 bad inputs.
Three of six. Path traversal, absolute-path escape, malformed payload.
The denominator here is mine: I declared six classes and 26 inputs. The split is not mine. Which class lands on which side falls out of where the stages sit.
A twin covering the whole corpus does catch deletion in this order: 9 of 26 still deny, so 17 flip to allow and the suite goes red. The blindness is a subset problem, and it bites whoever writes fixtures by hand rather than enumerating classes. I have never watched anyone enumerate them, which is not the same as saying nobody does.
Note the second line of that block, and read it in order B before you trust the word “shadowed”:
where that stage sits relative to policy_gate: before it 2 · after it 6 · both 1
In order B the gate runs before path_allowlist, so 6 of the 9 masked inputs are masked by a stage that comes after the gate. Shadowing is not a fact about being earlier. It is a fact about being covered twice, anywhere in the chain.
Why does the first stage that says no lie about coverage?
Short-circuit the pipeline and you learn which stage rejected the input first. That is not the same as which stages would have. So the probe asks every stage in place, without short-circuiting, and builds a set.
stage census (bad inputs each stage would deny, asked stage by stage, no short circuit)
normalize deny_set 0 of 26 first-denier 0 of 26
schema_check deny_set 3 of 26 first-denier 3 of 26
resolve_path deny_set 0 of 26 first-denier 0 of 26
path_allowlist deny_set 7 of 26 first-denier 6 of 26
audit_log deny_set 0 of 26 first-denier 0 of 26
policy_gate deny_set 23 of 26 first-denier 17 of 26 <- the stage under test
execute deny_set 0 of 26 first-denier 0 of 26
coverage of policy_gate as a SET: 23 of 26 bad inputs. By first denier only: 17.
Short-circuit attribution undercounts policy_gate by 6 input(s) in this order.
Six inputs where the gate would have said no and never got asked. In order B the same numbers are 14 and 14, undercount 0. That 6 is a fact about two things at once: about the ordering, and about how much of my corpus two stages cover jointly. It is the size of the two classes that path_allowlist and policy_gate both reject, 3 + 3. Change either and the number changes.
I take this one personally. My last tool, a static gate for unbounded LLM call sites, shipped with a bug of this shape: it took the largest caller instead of the sum over callers, printed a ceiling of $0.2496 for 8 runs, and executed 14. Same mistake wearing different clothes. Here the set is the primitive and denied_by is printed beside it, never instead of it.
What happens to the same gate in three different stage orders?
Order A canonicalises the path, then applies policy. Order B applies policy early, then canonicalises. Order C moves the gate exactly two positions earlier than A but keeps it after the canonicaliser. All three are legal arrangements of the same seven stages, and I am not claiming a distribution over which one teams pick. The gate source is byte-identical across them.
Order C is the one that matters, because it is the control I did not have in the first draft:
C_v2 exit 0 deterministic
Its class map is identical to order A, line for line: 17 LOAD_BEARING, 9 SHADOWED, 0 MISSED. A genuine two-position move changes nothing. Checked against the recorded baseline from order A:
baseline comparison against baseline_A.json
recorded under order A gate v2; this run order C gate v2
no class changed against the recorded baseline
So position is not the variable. Crossing resolve_path is. Order B is the only one of the three where the gate is handed a path that has not been canonicalised yet, and there the map moves:
summary
LOAD_BEARING 11
SHADOWED 9
MISSED 6
ok 9
MISSED classes are holes, not credit for the gate: protected_inside_workspace
verdict GATE-LOAD-BEARING
exit 0
The mechanism is dull and old. The gate reasons about absolute paths, because after resolve_path there are no relative ones left. Placed before it, the gate is handed notes/../.git/hooks/pre-commit, sees a relative path, has no opinion, and passes it on. Then resolve_path turns it into /work/ws/.git/hooks/pre-commit, the allowlist sees a path inside the workspace and approves, and the write to .git/hooks/pre-commit goes through.
That precondition is written in the gate’s docstring and printed in no report. Which is the actual lesson: a guardrail has preconditions, they are usually undeclared, and moving it across one is not a refactor.
Look at the verdict line. exit 0. Both orders.
My own exit code cannot tell A from B, because in order B there are still 11 load-bearing inputs and no false positive, which is all exit 0 asks for. What separates them is the recorded map:
recorded under order A gate v2; this run order B gate v2
CHANGED protected_inside_workspace: LOAD_BEARING -> MISSED
1 of 6 classes changed against the recorded baseline
verdict GATE-NOT-PROVEN (class map moved against baseline)
exit 1
So the CI shape is --record once on the order you shipped and --expect on every pull request. To be exact about what that catches: it goes red when a change moves the class map, which order B does and order C does not. A reorder that changes nothing stays green, correctly.
Two ways to be wrong about a relative path
If the gate’s blind spot is relative paths, tighten it. That is one line: refuse relative paths outright. I ran that as a third gate implementation, strict, across all three orders. Three orders by three gate variants, one corpus.
| gate | order | LOAD_BEARING | MISSED | FALSE_POSITIVE | exit |
|---|---|---|---|---|---|
v2 | A | 17 | 0 | 0 | 0 |
v2 | B | 11 | 6 | 0 | 0 |
v2 | C | 17 | 0 | 0 | 0 |
strict | A | 17 | 0 | 0 | 0 |
strict | B | 17 | 0 | 4 | 1 |
strict | C | 17 | 0 | 0 | 0 |
v1 | A | 17 | 0 | 4 | 1 |
v1 | B | 11 | 6 | 4 | 1 |
v1 | C | 17 | 0 | 4 | 1 |
The first draft of this post read that strict/B row as a measured trade-off: the one variant I wrote that survives reordering pays for it on the good control. A reviewer took the four good rows whose paths I had written relatively, rewrote them in absolute form, changed nothing else, and the trade-off evaporated:
abs_B_strict exit 0 deterministic
LOAD_BEARING 17, SHADOWED 9, ok 9, FALSE_POSITIVE 0. Under an absolute calling convention strict is not a trade at all, it is strictly better than v2. So the honest statement is narrow: under the relative-path calling convention I chose for four good rows, strict rejects 4 of 9 legitimate requests in order B. That is a fact about my corpus, not about the gate.
What does survive the rewrite is the thing this post is actually about. On the absolute corpus, v2 in order B still loses the whole protected_inside_workspace class to MISSED and still exits 0. The MISSED result holds under both conventions; the trade-off held under one.
v1 and strict are also not two points on one scale of strictness. v1 is too broad about SQL substrings, strict is too broad about relative paths. Different axes, and I have swept one point on each, not a range.
v1 is the version I wrote first, matching destructive verbs as substrings rather than leading tokens. It scores the same 17 load-bearing inputs as the correct gate. What separates them is 3 hunks and 19 lines of a 105-line report: the variant label, four good rows, and the four summary lines those rows move.
good-02 FALSE_POSITIVE - DENY ALLOW policy_gate {policy_gate} - <- gate rejects legitimate traffic
good-03 FALSE_POSITIVE - DENY ALLOW policy_gate {policy_gate} - <- gate rejects legitimate traffic
good-04 FALSE_POSITIVE - DENY ALLOW policy_gate {policy_gate} - <- gate rejects legitimate traffic
good-05 FALSE_POSITIVE - DENY ALLOW policy_gate {policy_gate} - <- gate rejects legitimate traffic
SELECT deleted_at, SELECT altered_at, SELECT dropped_by, SELECT truncated_at. In the first draft this rested on a single row I had written by hand, already knowing about the substring delete inside deleted_at. Now it is a column axis, and four of the five columns trip it. A load-bearing count is not a quality score: without the good control, a broken gate and a correct gate were indistinguishable on this corpus.
Where the probe refuses to answer
Fourteen scenarios, run by run_all.sh on every invocation. Twelve exit 2, two exit 1 for reasons given below.
ref_transform exit 2 deterministic
ref_normalize_noop exit 1 deterministic
ref_normalize_real exit 2 deterministic
ref_flaky exit 2 deterministic
ref_flaky_aligned exit 2 deterministic
ref_nogood exit 2 deterministic
ref_nobad exit 2 deterministic
ref_brokenjson exit 2 deterministic
ref_unknownstage exit 2 deterministic
ref_othercorpus exit 2 deterministic
ref_badutf8 exit 2 deterministic
ref_recordexpect exit 2 deterministic
ref_otherablate exit 2 deterministic
ref_recordonfail exit 1 deterministic
The one that matters most:
CANNOT ATTRIBUTE: removing resolve_path changed the input downstream
this is not 'the stage is not needed', it is 'the experiment is impossible'
Ablate a stage that rewrites the request and the two runs are no longer comparable: the second one is running on different data. The probe detects that itself by comparing the canonical JSON before and after each stage, then refuses. A tool that answered “not load-bearing” here would be telling you to delete your path canonicaliser.
And the good-control refusal:
CANNOT ATTRIBUTE: corpus has no good inputs
without a good control a gate that denies everything looks perfect
Exit 2 beats exit 1 beats exit 0. Across the fourteen refusal scenarios the runner exercises, none returned 0.
Five things my own tool got wrong
Two I found. Three came out of a pre-publication review that reran everything and went looking for the seams.
1. The determinism check is blind at one period, and it shipped a false green. First line of the pre-flight, and the guard I trusted most. The flaky gate variant returns ALLOW on every Nth invocation, standing in for a gate that reads a clock. policy_gate is invoked 61 times per pass on this corpus. Set the period to 61:
determinism two full ON passes byte-identical: yes
Yes. It said yes, because the second pass started at exactly the phase the first one did. For a while that run went all the way to verdict GATE-LOAD-BEARING, exit 0 on a gate that is not deterministic, which is precisely the failure this post is about, in the tool that detects it. The fix is not a different modulus, it is a different question: run the same rows back to front and compare verdicts keyed by input.
order-independence same rows visited back to front, same verdicts: NO
CANNOT ATTRIBUTE: the pipeline depends on the order its inputs are visited in
I left the blind check in and printed the invocation count next to it, so you can see when it is going to be useless.
2. The purity check is empirical, and I over-read it. --ablate normalize returns exit 1 on my main corpus, not the refusal I expected. normalize trims whitespace and lowercases the tool name, and the generated corpus is already trimmed and lowercase, so it changes nothing and really is an identity here. Rewrite the good rows with " FS.WRITE " and a padded path and the same command refuses with exit 2. Two corpora, two answers, both correct, and the claim “this stage is pure” is scoped to the rows you fed it.
3. “Shadowed” did not mean what the tool printed. The report said masked inputs were “still DENIED by an earlier stage”. That word was a constant in a format string, not a measurement. In order B, six of the nine masked inputs are caught by path_allowlist, which runs after the gate. The classification was right and the explanation was wrong, in the order whose numbers this post publishes. The tool now computes the masking stages and prints each one’s position.
4. --record and --expect in one command was green by construction. The baseline was written before the comparison, so a CI job written as a single record-and-check command agreed with itself by construction. Worse, a failing run wrote its baseline anyway, so a broken gate could be frozen as the reference and the pull request that fixed it would go red. Both are refusals now, and the baseline is written only after a clean verdict. A baseline recorded for a different corpus or a different ablation target is also refused; a different order or gate is not, because that is the signal.
5. A corpus that is not valid UTF-8 crashed instead of refusing. The decode sat outside the try, so invalid bytes produced a traceback and exit 1, which in CI reads as “gate not proven” rather than “I cannot answer”. One line moved.
What this is not
It is not a replacement for the known-bad twin. The twin checks that the verdict is right; this checks who produced it. Run both.
It does not catch a gate turned off by config in production while it is on in CI. Different problem, and the probe cannot see your production config from a test process.
It is not mutation testing in general. One mutant, chosen by you, on one stage.
It is not a drop-in for your repo, and the first draft of this post promised otherwise. The stage list and the two-run pattern are hardcoded; what ports is the mechanism, and the mechanism is 21 lines: run the chain short-circuited, run it again asking every stage in place, do both with the target replaced by an identity, and compare. Rewriting those against your own stage list is an afternoon, not a download.
It says nothing about inputs outside the corpus you hand it. Every classification count above describes 35 declared rows, and the MISSED class exists precisely because holes get found by inputs nobody added.
It is not a static check. Neighbouring pieces cover the neighbouring questions: the green checkmark auditor reads test files with ast and never runs them; checkpoint skip gate is about a required check that never ran at all in a recorded trajectory. Here the gate ran, said DENY, and the verdict did not depend on it. The root of the series is still tracking is not control: gate before the action, not a dashboard after it. This adds one line to that: fired is not load-bearing.
The pipeline and both corpora are synthetic. I wrote them, and three of the five defects above were found by someone rerunning them and disagreeing with my prose. What is not synthetic is the mechanism.
If you run this on your own guardrail suite, I want one number from you: with the gate cut out, how many of your known-bad fixtures still fail, and which stage catches them instead. Say whether that stage runs before your gate or after it, because I got that wrong for a day and the answer surprised me. Follow along for the next runner and its numbers.