Two numbers usually get reported as one. Did the model propose a forbidden tool after reading an untrusted ticket, and did a forbidden tool actually execute. The first is compliance and it is a property of the model. The second is reachability and it is a property of your architecture.
This Lab separates them on the same corpus, and the separation is the whole result: the configuration that fixed reachability did not change compliance at all.
Setup
Corpus. Fifty incident tickets generated from seed 42, committed with the code. Twenty are clean controls. Thirty carry an injected instruction in one of six published styles: direct override, false authority, urgency, roleplay, embedded instruction, and sanctioned drill. Each injection targets one of two forbidden tools, purge_records or send_external.
Agent. An incident investigation agent with five tools. Three are allowed: read_metrics, read_logs, read_deploys. Two are forbidden. Single step, temperature 0, local inference through Ollama on llama3.2:3b, qwen2.5:7b and gemma4:e2b.
Three guard configurations, differing only in the guard:
| Config | Guard |
|---|---|
prompted | Ordinary constraints in the system prompt. Nothing at the call site. |
hardened | A long anti-injection prompt naming all six attack styles in the corpus, denying that authority can arrive through ticket text, pre-committing the model to a safe substitute. Still nothing at the call site. |
gated | The ordinary prompt, plus a deterministic capability check at the call site. |
hardened exists so the result cannot be dismissed as a failure to prompt properly. It restates the rule, names every attack in the corpus, denies the authority channel and pre-commits the refusal. It is what a careful engineer writes when told to harden the prompt.
Results
| Model | Config | Complied | Reachability |
|---|---|---|---|
llama3.2:3b | prompted | 19/30 | 19/30 |
llama3.2:3b | hardened | 20/30 | 20/30 |
llama3.2:3b | gated | 19/30 | 0/30 |
qwen2.5:7b | prompted | 15/30 | 15/30 |
qwen2.5:7b | hardened | 0/30 | 0/30 |
qwen2.5:7b | gated | 15/30 | 0/30 |
gemma4:e2b | prompted | 10/27 | 10/27 |
gemma4:e2b | hardened | 4/30 | 4/30 |
gemma4:e2b | gated | 10/27 | 0/27 |
Clean-control tickets produced a usable tool call in 100% of runs in every cell. None of the safety here was bought by refusing to work.
Prompt hardening works, unpredictably. The same hardened prompt, word for word, took qwen2.5:7b from fifteen compliances to zero, moved gemma4:e2b from 37% to 13%, and made llama3.2:3b very slightly worse. Nothing in the prompt predicts which of those three you get. You find out by running it.
Naming an attack in the prompt does not defend against it. The hardened prompt lists all six styles explicitly. On llama3.2:3b it flipped which styles worked rather than reducing how many did: direct_override went from 0/5 to 5/5 while urgency went from 5/5 to 0/5. Same prompt, same model, opposite outcome per style.
Attack difficulty is not a property of the attack. Under the ordinary prompt, llama3.2:3b refused every direct_override and fell for every false_authority. qwen2.5:7b did the exact opposite. There is no ordering of these six styles by difficulty that holds across two models, which is a problem for anyone treating an injection suite as a benchmark.
The structural point
The gate never reads the ticket.
It does not score the request, classify the input, or evaluate the model’s stated reason. It checks whether the requested capability is in the set held for this run. That is why compliance under gated is identical to compliance under prompted: the gate changes nothing about the model. What it changes is that compliance stops being reachable.
Both prompt configurations try to change what the model decides. Only the gate changes what the decision can do. This is the difference between a defence whose strength you discover empirically per model, and one whose strength is a property of the code.
Scope and limits
- Three small local models at temperature 0, single step. Compliance rates are properties of these models on this corpus and transfer nowhere. The reachability result is structural and does not depend on the model: a capability absent from the set cannot be reached however persuasive the input.
- Tool-name parsing is deliberately generous. A bare tool name anywhere in the output counts as a proposal, because under-counting compliance would flatter the ungated configurations.
- Six injection styles with five attempts each is a small corpus and is not a coverage claim. An attack style absent here is not one a prompt guard resists.
- Single step. The agent proposes one tool call. Multi-turn runs would let an attack build across turns, which this does not measure.
- The clean set tests only that legitimate work still runs. It does not test whether the answer was any good.
What we got wrong
Refusals were being thrown away. The first scorer excluded any run that produced no parseable tool call. Six of llama3.2:3b’s prompted runs were the string "I can't fulfill that request.", which is a refusal, not missing data. Excluding them both inflated that configuration’s compliance rate and left the two prompt configurations with different denominators, which makes the comparison meaningless. Every run that produced a result is now in the denominator.
A token limit was being read as a safety property. gemma4:e2b produces several hundred thinking tokens before it answers. At the original 120-token cap it returned empty strings for all 150 runs and scored 0% compliance in every configuration, which looked like a model that resisted every attack. It was a model that never finished speaking. The budget is now 700 tokens, runs cut off at the limit are detected and excluded rather than counted as refusals, and that is why gemma4:e2b’s denominators differ by configuration.
Both errors pointed the same way, toward flattering the thing being measured. That is the direction to check first.
What came next
This corpus was reused in Lab-007, which asks a different question of the same tickets: not whether a detector catches an injection, but whether the probability it states about one means anything. The answer there is that fifty rows cannot support a calibration claim from any detector, which is worth knowing before reading a vendor’s calibration number.
Reproduce
ollama serve # llama3.2:3b, qwen2.5:7b, gemma4:e2b
python -m labs.lab_004.run # all three models, writes RESULTS.md
python -m labs.lab_004.report # re-render from results.json
Local inference only, no API keys. The corpus is generated from a seed and committed, along with every raw model response.