Assume the model will obey the injected instruction. Your job is to make that obedience cheap: the forward to [email protected] never enters the mailer; the rm -rf never enters the shell; the data export never opens a connection. Put the deny in your process, around execute, before the side effect. That is blast-radius containment. It is not a classifier, and it will not notice an injection that only changes the model's words.
The naive approach (and when it is sufficient)
Three common first moves, in increasing honesty:
- A system prompt (“never forward mail off-domain”). Sufficient for a demo. Insufficient the moment untrusted text is in context — which is the definition of an inbox agent.
- An output filter that greps the model's planned tool call. Better. Still a model-shaped object; encodings, extra tools, and a second hop through a URL fetch bypass it.
- An allowlist in code around each tool:
async function forwardEmail({ to, body }) {
if (!to.endsWith('@acme.com')) {
throw new Error('external forward refused')
}
return mailer.forward({ to, body })
}
Option 3 is the whole game if you have few tools, they all go through these functions, and you are willing to copy the same allowlist into every wrapper. Do that. Do not wait for a library. A read-only summarizer with no send/delete/shell tools already has a blast radius of “wrong paragraph in Slack.” Leave it alone.
What the wrapper still has to get right
- The model must not receive an unwrapped twin. If
forwardEmailis guarded butrun_sqlis not, the injection will pick the hole. - Arguments, not just names. Allowing
email.forwardglobally and hoping the prompt stays on-domain is option 1 again. Checkto. - Deletes and “cover your tracks.” Escalate those to a human (
ask) or deny them. An injection that can delete the bait email is trying to remove evidence. - Log the refuse. If you only log inside the mailer, the interesting event never happened as far as your SIEM is concerned.
Sandboxes help for filesystem and network of the process. They do not shrink an OAuth token the process already holds. Vaults hide the secret from the model; the model can still spend it through the tool. Containment is the missing half of both.
When nominee is a fit (and when it is not)
nominee's policy is that wrapper, written once, applied by guard() / run() before execute. The supporting example in this repo scripts the “model” on purpose — so the proof is about the tools, not about winning a detector bake-off:
allow('email.read')
allow('email.forward', { when: ({ input }) => input.to.endsWith('@acme.com') })
deny('email.forward', { reason: 'external forwarding is exfiltration' })
ask('email.delete')
The injected forward throws before the mailer; the delete creates a pending approval that guard() / run() surfaces as an ActionPendingError for durable resume; both land on a tamper-evident receipt chain. That is containment plus evidence. It is still not detection: a polite injection that only asks for an on-domain forward will be allowed, because the policy said so.
Skip nominee when you have no write tools, when a single wrapper function already is the product, or when you wanted a hosted connector catalog. Deeper argument: Your agent logs in as you. It shouldn't get to be you.
See it for yourself
Injected forward. Tool never ran.