Agent Security Explained
← /learn · 07

Designs that make it structural

Plan-then-execute and the dual-model pattern: a planner that never reads untrusted data, a quarantined model with no tools, and the policy enforcing every flow; step by step, beside a classic agent.

Loading the animation…

Concept

Chapter 6 ended with detectors that never reach zero. The designs in this chapter take a different route: they arrange the agent so that untrusted text cannot choose what the agent does, whatever the text says, and so nothing has to recognise it.

The dual-model pattern (Willison 2023) splits the agent in two. A privileged model plans and calls tools but never reads untrusted content; a quarantined model reads untrusted content but has no tools, and its outputs come back as variables the privileged model refers to by name only. CaMeL (Debenedetti et al. 2025) makes this precise: the planner writes the whole plan as a program from the user's request, an interpreter runs it and tracks where every value came from, and a policy checks each tool call against those labels, so "the untrusted data retrieved by the LLM can never impact the program flow". Beurer-Kellner et al. (2025) collect six such patterns (plan-then-execute and the dual model among them) and weigh what each gives up.

The animation runs a task this way. The planner reads the request and writes the plan; each read runs as a tool, and its result goes into a variable the planner never sees (the panel lists what it does see: names). Rewriting untrusted values happens in the quarantined model. At every call the policy decides on the labels the interpreter holds, and beside it is the decision a classic agent gets for the same call. For "send a colleague a summary of my notes, after looking up a page", the planned run allows, asks and denies 3, 1 and 0 calls, where the classic agent's are 2, 1 and 1: the page cannot taint a summary that was never made from it.

Two things the design does not do. It does not clean data: in "read my inbox and notes, then reply to a colleague" the reply is still made from untrusted, private text, and the policy denies it in both agents (1 denial each). And some tasks really do depend on what they read. In "file a ticket's summary wherever its category says", the plan has to branch on the ticket's content. The engine records the branch (the step's ctrl), so the write's control is the ticket's label, untrusted, and the policy sends it for approval: the dependency is tracked, not hidden. The price of the design is one extra model call per task to write the plan (1 here), and the tasks it can no longer do unattended. On the AgentDojo benchmark, CaMeL solved 77% of tasks with provable security, against 84% for the undefended system.

Maths

Write ℓ(v)\ell(v) for a value's label. Planned in advance, a call made at step kk has control label ⨆v∈Bkℓ(v)\bigsqcup_{v \in B_k} \ell(v), where BkB_k is the set of values the plan branches on at that step (empty for most steps, giving the bottom of the lattice), and effective label

ℓ(callk)=⨆a∈argskℓ(a)  ⊔  ⨆v∈Bkℓ(v).\ell(\text{call}_k) = \bigsqcup_{a \in \text{args}_k} \ell(a) \;\sqcup\; \bigsqcup_{v \in B_k} \ell(v).

In a classic agent, BkB_k is effectively everything returned before step kk, so the planned label is never higher: ℓplanned(callk)⊑ℓclassic(callk)\ell_{\text{planned}}(\text{call}_k) \sqsubseteq \ell_{\text{classic}}(\text{call}_k), and since the policy's decisions are monotone in the label (chapter 1), a planned call is never more restricted than the same call in a classic agent. The engine's tests check both on every frame, and that dropping a branch never makes a decision stricter.

Code

How a branch enters the engine, cut from the vendored engine (policy/engine.ts): the values a step branches on are inputs it must wait for, like its arguments.

const ctrl: string[] = st.ctrl ?? [];
const missing = [...(st.args as string[]), ...ctrl].filter((a) => !(a in vals));