Stop prompt injection from making an AI agent send email
Label where tool arguments came from, then hold any email whose recipient was copied from a web page for human approval. Runnable TypeScript, no API key.
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.
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.
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.
This is the real output of the example, with the steps numbered:
[1] Fetch a page. Its output is labeled tool_output:fetch_page [ 'tool_output:fetch_page', 'user' ][2] The model copies an address from the page into send_email Tool "send_email" needs human approval (tier taint). Approval request apr_b995d842-ca2a-4718-a48a-88ed6ba34773 was opened. Do not retry until it is granted; tell the user it is waiting.[3] A user typed address passes { sent: 'me@example.com' }[4] A human approves, the retry spends the approval once { sent: 'attacker@example.com' }
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.
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.