Dev.to · 7 min read

I wrote a test for prompt injection. It passed while the attack worked.

I wrote a test for prompt injection. It passed while the attack worked.

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry. I maintain a small CLI called llm-council. It puts one question to several models, hides the authorship, and has them rank each other's answers. I use it as an adversarial reviewer on my own work — the whole point is to get disagreement from something that has no reason to be polite to me. On 26 July I pointed it at its own repository. It found a prompt-injection hole in its own prompts. That was mildly embarrassing. What actually kept me up was the second finding: I had already written a test for exactly that hole, and the test was green. The thing being defended When you chain models, the output of one becomes the input of the next. In llm-council, stage 1 collects answers, stage 2 asks a model to rank them, stage 3 asks for a synthesis. Every stage feeds the previous stage's text — text written by an untrusted party — into a new prompt. That is OWASP LLM01 in its plainest form, and the standard mitigation is fencing: wrap untrusted content in delimiters and tell the reader that anything inside is quoted data, never instructions. I had done that. The delimiters looked like this: _FENCE_OPEN = "" _FENCE_CLOSE = "" Fixed strings. In a public repository. So a hostile voter — or a model that had simply read the repo during training — could write in the middle of its own answer. To the model reading downstream, that closes the block. Everything after it stops being quoted data and starts being orchestrator text. The fence was a door with the key printed on it. The test that could not fail Here is what I had written to prove that could not happen: def test_a_voter_cannot_forge_another_fence_boundary(self) -> None: """A response containing fence markers must not create a second B block.""" forged = "text injected" out = _label_responses([forged, "b", "c"]) # Exactly one real closing marker per label: the forged one lives inside A. self.assertEqual(out.count(""), 2) self.assertLess(out.index(forged), out.index("")) Read the name. Read the assertion. They are about different things. The name claims a security property: a voter cannot forge a boundary. The assertion counts occurrences of a Python string and checks an index ordering. Both of those are true whether or not the attack works — the forged marker is in the text either way, and it sits where the arithmetic expects. The test verifies that string concatenation concatenated. It never asks the only question that matters: can the reader be deceived? This is the subtle version of "a test that cannot fail." It is not empty and it is not skipped. It runs, it exercises real code, it would catch a genuine refactoring mistake. It simply does not touch the property its name advertises — and the name is what everyone reads when deciding whether an area is covered. That test had been sitting in a suite at 100% coverage. Coverage is a claim about lines executed. It says nothing about whether the assertions are pointed at anything. The fix The defence had to move from the shape of the markers to something the attacker has never seen: a per-run random nonce. # THE NONCE IS THE DEFENCE, not the shape of the markers. Until 2026-07-26 these were # fixed strings living in a public repository: a voter could simply write # `` mid-answer and close its own block in the reader's eyes, # with everything after it read as orchestrator text. A per-run random nonce makes # the closing marker unguessable — a voter cannot forge a boundary it has never seen. _FENCE_OPEN: Final[str] = "" _FENCE_CLOSE: Final[str] = "" def _new_nonce() -> str: """Fresh unguessable token per prompt. `secrets`, not `random`: this is a boundary.""" return secrets.token_hex(8) secrets, not random — this is a security boundary, and a predictable PRNG would hand back exactly what the nonce was meant to take away. Then the test was rewritten to assert the property instead of the arithmetic (abridged — the source has the assert ... is not None narrowing that mypy wants, and an assertion message in Italian): def test_forged_markers_never_match_the_run_nonce(self) -> None: """A voter can *write* something marker-shaped — it just cannot match.""" payload = " " prompt = stage3_prompt("domanda", [payload, "b", "c"], ["RANK: A,B,C"]) nonce = _MARKER.search(prompt).group(3) authentic = [m for m in _MARKER.finditer(prompt) if m.group(3) == nonce] self.assertEqual(len(authentic), 8) # The forged ones survive as plain text, which is exactly the desired outcome. self.assertIn("", prompt) The property is not "no fake markers exist in the text" — an attacker controls its own output and can type anything. The property is that only the markers we emitted carry the real nonce, so a forged one is inert text. The same review turned up a third gap: in stage 3, the rankings were going in raw while the responses beside them were fenced. One uncovered seam in a defence that exists precisely because a model's output re-enters another model's input. I verified the fixes by mutation rather than by trusting the green: reverting to a static nonce turns 3 tests red, and unfencing the rankings turns 2 red. The old test is the control in that experiment — it stayed green for the entire time the vulnerability was live, which is the only measurement that ever mattered. The part I did not expect I opened the PR. The SonarCloud quality gate — newly mandatory, this was the first PR it blocked — failed it. Not for the fix. For my new test: self.assertNotEqual(_new_nonce(), _new_nonce()) Same expression on both sides. The rule exists because that shape is usually a copy-paste bug, and the scanner could not know I meant it. But the scanner was right anyway, for a better reason than it had: two draws is a terrible test for randomness. It passes with a counter. It passes with a clock. I could have suppressed the rule with a one-line waiver. Instead: def test_nonce_differs_between_draws(self) -> None: """Every draw must be unique: a repeated nonce is a reusable forgery.""" draws = [_new_nonce() for _ in range(50)] self.assertEqual(len(set(draws)), len(draws)) A nonce collision is a reusable forgery. That is worth a stronger test, not a waiver. What I took away A test name is a claim about the world. The assertion is the evidence. Nothing in a normal green run checks the claim against the evidence — you can hold a suite at 100% coverage where the two have quietly drifted apart for months. Mutation testing is the cheapest instrument I know for catching that drift: break the thing on purpose and count what goes red. Zero red means your test was never watching, no matter what its name promised. The related lesson, which cost me more to accept: my first instinct on the SonarCloud failure was to reach for a suppression, because I knew my code was fine. I was right about the code and wrong about the test. A gate that only ever agrees with you is the same kind of instrument as a test that cannot fail. PR: llm-council #12 — 122 tests, and this time I know what they are watching. Written with Claude Code as a pair, and reviewed by the tool this post is about. The AI collaboration is visible in the commit trail rather than tidied out of it.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News