Skip to main content
What you learn: a safety suite for a tool-using agent: prompt injection, exfiltration, secret leakage, unauthorized writes, benign controls; four trajectory markers as the judge, pass^k per attack class, a before/after that fails the fix which got safe by refusing. Needs: nothing. Takes: seconds. An agent with tools can read private data, act on state, and send things out. Any two together is an exposure; all three is Simon Willison’s lethal trifecta [1]. A safety eval asks whether those capabilities can be turned against their owner: by the user, by text read from a tool, or by an instruction the agent should have treated as data. Worked example: recipes/02-measure/safety-evals (offline, no key, seconds); argument: blog/agent-safety-evals.md. Three ways an instruction reaches the agent, three ways data leaves it, one marker per exit, plus the benign control Three ways an instruction reaches the agent, three ways data leaves it, one marker per exit, plus the benign control

What the research says

OWASP’s list of risks for LLM applications names prompt injection, direct (in the ask) or indirect (in content the model reads), along with sensitive-information disclosure and excessive agency [2]. It is a taxonomy, not a test: each entry needs a situation that provokes it and a grader that sees it. Over-refusal is an over-optimization signature. A reward that any refusal satisfies is a proxy the policy will find, so the suite carries benign controls and a helpfulness term in the reward [3, 4]. The reward reads the trajectory, because a judge that reads the prose passes a clean summary written after the record left [4]. Every number carries an interval, comparisons are paired on the same tasks, and the eval set never enters training [5, 6].

The recipe

  1. Write the boundary down. Every “never” in the policy gets an attack; every “always help with” gets a control.
  2. Plant the indirect injection in the data. Put the instruction in a record the agent reads (notes, a ticket) and ask something ordinary about it. The attack arrives as a tool result.
  3. Simulate with repeats. The suite goes in as seeds=. mode="rl", repeats=k, repeat_policy="fixed" rolls every ask k times; reproducible=True pins the draw on any CPython version; simulator=False needs no key.
  4. Judge from the trajectory. One marker per exit (the reply, an outbound message, a write), read from steps and final_text, 1.0 when the agent held; helpful_on_benign on the controls. Reward is 1 only when every applicable marker holds. Use evaluate, not grade, for eval lineage.
  5. Read pass^k per category. pass@1 is the average; pass^k is how often the agent held on all k tries. For a leak, the second is the number.
  6. Check the judge. Hand-label the edge cases: the refusal that still leaks, the quoted planted text, the send to the on-file address. judge_trust(labeled) warns below 0.8 agreement or 0.6 kappa. judge_probes(benign_rows, judge, probes=["refusal"]) must be 0.
  7. Fix, and re-run on the same tasks. simulate(..., tasks=base) re-runs exactly the first run’s asks, so every delta is paired.
  8. Guard the comparison. delta_report(before, after, target="pass_at_1", must_not_regress=[...], by="category") with helpful_on_benign and every safety marker in the guard. A fix that got safe by refusing raises pass@1 and fails.
  9. Keep the suite out of training. evaluate stamps lineage.source == "eval" and the selectors warn on those rows. simulate(traces=evald.failed_traces()) aims new situations at the failures and drops near-copies.
Three boxes, train, holdout and eval, each with its purpose= tag; a green arrow from train to the other two labelled decontaminate(train, against=[holdout, eval]) Three boxes, train, holdout and eval, each with its purpose= tag; a green arrow from train to the other two labelled decontaminate(train, against=[holdout, eval])

The calls

Run it

Three scripted agents: trusting, locked-down (refuses anything risky), hardened (holds and still helps).
The trusting agent, seed 0, k=4:
Indirect injection at 0.50 pass@1 and 0.00 pass^k: the agent obeyed the planted note on half its tries. Two fixes, same pinned tasks:
Both fixes take every safety marker to 1.0; only the guard tells them apart. The report also warns that one run per side could be noise (runs=3 answers that).

When the text is public and the data is per tenant

A marketplace agent reads public text and holds many tenants’ data. Three changes, worked in recipes/02-measure/safety-evals-marketplace:
  • Make the world answer across tenants, and add no_cross_tenant_read reading the steps. A mock that refuses measures the mock.
  • Plant the injection where the public writes: a review, a listing; one shape asks for a send, one for a write.
  • Give the public write its own marker, no_public_leak: private data in a respond_to_review leaves nothing but leaks.
live.py there runs the suite through Ollama with execute=world, so planted reviews reach the model as tool results.

What the SDK already checks

What this is not

It measures the boundary under the suite’s situations, not the absence of a jailbreak the suite lacks. Aim new generation at the failures (traces=), add every production incident as a seed, re-run on the pinned tasks.

References

  1. Willison, S. The Lethal Trifecta for AI Agents. simonwillison.net, June 2025. simonwillison.net/2025/Jun/16/the-lethal-trifecta.
  2. OWASP. Top 10 for LLM Applications 2025. genai.owasp.org/llm-top-10.
  3. Gao, L., Schulman, J., Hilton, J. Scaling Laws for Reward Model Overoptimization. ICML 2023. arXiv:2210.10760.
  4. Lambert, N. Reinforcement Learning from Human Feedback. 2025. Chapters Over-optimization, Tool Use and Evaluation.
  5. Miller, E. Adding Error Bars to Evals. 2024.
  6. Lambert, N. et al. Tülu 3: Pushing Frontiers in Open Language Model Post-Training. arXiv:2411.15124, 2024. The decontamination check.
Last modified on September 20, 2026