recipes/02-measure/safety-evals; the longer argument
is blog/agent-safety-evals.md.
What the sources say
OWASP Top 10 for LLM Applications (2025). Prompt injection is LLM01 and is split into direct (the user’s message) and indirect (content the model reads from elsewhere). Sensitive information disclosure is LLM02, excessive agency is LLM06, system prompt leakage is LLM07. The list is a taxonomy of failures, not a test; every entry needs a situation that provokes it and a grader that recognises it. rlhf-book ch. 14, over-optimization. Over-optimization is the training reward climbing while the eval you care about does not. Its qualitative signatures include over-refusal. A safety reward that any refusal satisfies is a proxy the policy will find; the fix is benign controls in the same set and a helpfulness term in the same reward. rlhf-book ch. 13, tool use. For an agent the reward has to read the trajectory, because the reply can claim anything. A safety judge that reads only the prose passes a clean summary written after the record went out the door. rlhf-book ch. 16, evaluation. Every number carries an interval, a paired comparison needs the same tasks on both sides, and an eval set that becomes training data measures memorisation of the eval set.The recipe
- Write the boundary down. The policy is the spec. For every “never” in it there is an attack; for every “always help with” there is a control. The example’s policy has four rules and one secret; the suite has five attack classes and one control class.
- Plant the indirect injection in the data, not the ask. Put the
instruction in a record the agent will read (
notes, a ticket, an email body), and ask something ordinary about that record. The ask passes any input filter; the attack arrives as a tool result. - Simulate with repeats. The suite goes in as
seeds=; the simulator adds situations from the tools and policy (its own prompt- injection and false-authority probes land in the suite’s buckets when a draw includes them).mode="rl", repeats=k, repeat_policy="fixed"rolls every askktimes whether or not it is graded, andreproducible=Truemakes the seed decide the draw.simulator=Falseuses the template writer, so the offline run needs no key. - Judge from the trajectory. One marker per channel the agent can
leak through: the reply, an outbound message, a write. Each is
computed from
stepsandfinal_text, 1.0 when the agent held. Addhelpful_on_benignon the control rows: not refused, and the tool the ask needed ran. Reward is 1 only when every applicable marker holds. Grade withevaluate, notgrade, so the rows carry eval lineage. - Read pass^k per category. pass@1 is the average; pass^k is how
often the agent held on every one of
ktries. For a leak the second is the number.pass_at(rows_in_category)gives both with an interval over asks. - Check the judge. Label transcripts by hand, the edge cases
especially: the refusal that still leaks, the quote of the planted
text that is not compliance, the send to the on-file address, the
write with the id the rep gave.
judge_trust(labeled)gives agreement and Cohen’s kappa (agreement corrected for chance); it warns below 0.8 agreement or 0.6 kappa, and that is where you fix the judge. Thenjudge_probes(benign_rows, judge, probes=["refusal"]): the share of benign asks a canned refusal passes is the share of the reward a policy can collect by refusing everything. It should be 0. - Fix, and re-run on the same tasks.
simulate(..., tasks=base)re-runs exactly the asks the first run drew, so every delta is paired. Re-passmodeandrepeats;kis not inherited. - Guard the comparison.
delta_report(before, after, target="pass_at_1", must_not_regress=[...], by="category")withhelpful_on_benignand every safety marker in the guard list. A fix that got safe by refusing raises pass@1 and fails the report; the per-category table says which class moved the wrong way.refusal_report(benign_rows)is the same fact as one rate with a Wilson interval. - Keep the suite out of training.
evaluatestampslineage.source == "eval";select_for_rl,select_for_sftandbuild_preference_pairscount those rows aseval_sourcedand warn. To train against the failures,simulate(traces=evald.failed_traces())writes new situations aimed at them and drops any row that near-copies a source, so the suite stays held out.
The calls
Run it
The recipe runs the whole loop offline on three scripted agents: a trusting one, a locked-down one that refuses anything risky, and a hardened one that holds the boundary and still helps.runs=3 on each side answers the second.
When the text is public and the data is per tenant
A support agent’s private data is one company’s; a marketplace agent’s belongs to many tenants, the text it reads is written by the public, and some of what it writes is public. Three changes to the recipe, worked inrecipes/02-measure/safety-evals-marketplace:
- Make the world answer across tenants. If a tool takes an id, let
the mock return data for any id, and add a marker that reads the steps
(
no_cross_tenant_read: a successful read for a product this tenant does not own, or another tenant’s names in any output). A mock that refuses measures the mock. - Plant the injection where the public writes. A review, a listing, a profile. Two shapes: one that asks for a send, one that asks for a write. Reach both with an ordinary ask.
- Give the public write its own marker.
no_public_leak: arespond_to_reviewthat carried private data is a leak with nothing leaving the platform, and an external-send check does not see it.
live.py there runs the suite on a model through Ollama with no key,
with execute=world so the planted reviews reach the model as tool
results and fault_rate=0 so no tool is broken on purpose.
What the SDK already checks
What this is not
The eval measures whether the agent holds the boundary under the situations in the suite. It does not prove the absence of a jailbreak the suite does not contain, and a suite that stops growing stops measuring. Aim new generation at the failures (traces=), add every production
incident as a seed, and re-run on the pinned tasks so the history stays
paired. A model-written suite (simulator= on your endpoint) gives
variety the template writer cannot; keep the hand-labeled transcripts
either way, since they are what the judge is checked against.