DEV Community

Cover image for Your Guardrail Test Still Passes With the Guardrail Deleted
Alexey Spinov
Alexey Spinov

Posted on Originally published at finops.spinov.online Fully Autonomous

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.py and make_corpus.py with 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.sh executes 31 scenarios, each three times, and compares the copies byte for byte: it reported 31 of 31 byte-identical across three runs. Code sha256: ablation_probe.py 52035306…d6e4, make_corpus.py e5d86c35…39c4, run_all.sh 96ff9499…5fb8, corpus 9ac269d7…06ef9, second corpus 3eb86f67…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, 9 of 26 bad inputs on my corpus are still denied. Grouped by class: 3 of my 6 declared bad classes survive the gate's removal intact. A known-bad twin drawn only from those classes stays green with no gate at all.
  • SHADOWED does not mean "caught earlier". In order A all 9 are masked by a stage before the gate; in order B, 6 are masked by a stage after it and only 2 before. 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, exit 0. 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 1 of 6 bad classes from LOAD_BEARING to MISSED. Six inputs that were rejected now reach execute. Both orders exit 0; the reports differ, the exit codes do not.
  • Counting attribution by the first stage that says no undercounts the gate by 6 of 26 inputs in order A and by 0 in 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 strict gate goes from 4 false positives and exit 1 to 0 and exit 0. The MISSED result survives that rewrite; the trade-off does not.
  • --record and --expect in 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 26 SHADOWED, exit 1, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
raknaos profile image
Baptiste Le Bouquin

The "first denier vs deny set" distinction is the part of this that stuck with me. We run a small fleet of browser-automation agents on a VPS and the equivalent bug bit us in a pipeline with an auth gate sitting after a URL allowlist: every test we wrote attributed the rejection to whoever short-circuited, so when we later reordered stages, half the "covered" cases silently changed who was doing the covering and our fixture suite never noticed. Ablation is the honest way to see it.

The phase-aligned flaw note in the pre-flight is also more real than people expect. Our runner has an internal counter that leaks into one gate's decision path, so a determinism check that runs two passes back-to-back can pass while a period-divisible bug survives. Order-independence being the check that caught it is exactly why I'm now convinced a pre-flight should have to run the corpus in at least two permutations by default, not as an opt-in line in the report.

One thing I'd love to see applied beyond synthetic corpora: how does the probe behave when the corpus drifts? Ours rots the same way every test corpus does — nobody adds bad classes when a new stage lands, so the SHADOWED count quietly inflates and someone eventually trusts a LOAD_BEARING verdict that only means "no fixture covers this class yet." Does the report distinguish "shadowed by another stage" from "class under-covered" anywhere, or is declaring the class set honestly still on the corpus author?