Skip to main content
A web page can tell your agent to email your files to a stranger, and a plain permission check will let it, because the agent is allowed to send mail. This is the confused deputy problem for AI agents: the agent holds your authority and takes orders from text that has none. In this tutorial you build a send_email tool that refuses to send to an address copied out of another tool’s output unless a person approves. It runs in memory with no model and no API key, so you can watch the whole flow in a minute. Terminal output of the taint guard example holding an injected email for approval

Prerequisites

  • Node 20 or newer and TypeScript
  • A scratch project with "type": "module" in package.json
Put everything below in one index.ts. It is the same code as the taint guard example.

Step 1: Create the instance with a taint rule

A rule says which labels may not reach which argument of which tool. Here, the recipient of send_email may not carry untrusted_web or any tool_output:* label.

Step 2: Create a user and an agent

The agent is allowed to run both tools. Permissions alone would let the attack through, which is the point.

Step 3: Wrap the tools

guardTool wraps each tool. The shared ProvenanceTracker records every result as tool_output:<name> and remembers the strings in it. When the model later copies an address from that result into another tool’s argument, the tracker finds it.

Step 4: Fetch the poisoned page and attempt the send

In a real agent the model makes these calls. Here we make them by hand.
Then send to an address you typed yourself, which has no forbidden label:

Step 5: Approve and retry

A human reviews the request. The retry spends the approval once.
Run it with npx tsx index.ts.

What the output means

This is the real output of the example, with the steps numbered:
Step 1 shows the page result carries a tool_output:fetch_page label (the user label comes from the URL argument, which you labeled when you wrapped the tool). In step 2 the tracker found the attacker’s address in that result, the rule forbade it on recipient, and the guard opened an approval request instead of sending. Step 3 had no forbidden label, so it went through untouched. Step 4 shows the approval working once. The approval ID will differ on your run.

Limits

  • Labels are only as good as your tagging. An untagged argument looks clean. Add unlabeled: "violate" to rules where a missing label should fail closed.
  • Nothing here reads the model. If the model rewrites, summarizes or splits a value, the label may not follow it. Substring matching in the tracker is a heuristic and can miss or match by coincidence.
  • The approval is bound to the agent, action and resource, not the argument values. An approver who does not read the recipient defeats the control.
  • This cuts one class of prompt injection harm. It does not replace sandboxing or least privilege permissions.

Where to go next

Last modified on October 16, 2026