← Blog

Contain prompt-injection blast radius in the tools, not in the prompt.

I work at Auth0. nominee does not detect prompt injection. It can refuse a tool call the injected instruction asked for. Those are different jobs; this page is about the second one.

Assume the model will obey the injected instruction. Your job is to make that obedience cheap: the forward to attacker@evil.top 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:

  1. 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.
  2. 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.
  3. 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 forwardEmail is guarded but run_sql is not, the injection will pick the hole.
  • Arguments, not just names. Allowing email.forward globally and hoping the prompt stays on-domain is option 1 again. Check to.
  • 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.

$ npx nominee-cli no signup
Star on GitHub The scripted proof → Read the docs →