Skip to content
Chapter 5 of 8 · Prompt injectionTrust boundary crossings

13 min read

Trust boundary crossings

On this page

Ask an engineer to draw where untrusted text enters their agent and you almost always get one arrow. It runs from a knowledge base to the prompt, labelled "retrieved chunks", and it is usually drawn thick and red because that is where the team has learned to expect danger. The diagram stops at the model's input, as if the boundary the attacker cares about is the moment their text arrives in the context window.

That boundary is real, and it is the least interesting one on the page, because text arriving in the prompt has not yet done anything. A payload sitting in the context window is inert until the model acts on it; a corrupted argument reaching the tool executor is not. The crossings that turn attacker-influenced text into an action all leave the model, and those are the ones a single arrow omits. This chapter marks them on a concrete agent and works out which argument you most want out of the model's control.

The assistant in question answers questions about customer accounts. It retrieves from a knowledge base that includes support tickets written by customers, so a customer can put text into the retrieval index by filing a ticket, which is the indirect channel from the previous chapter's grid arriving through a door the product opens deliberately. It has three tools: lookup_account(id), which reads; post_note(account_id, text), which writes an internal note; and email_customer(account_id, subject, body), which sends outbound mail. The one-arrow diagram would put its red arrow on the retrieved ticket and call the analysis done. The tool surface is where the analysis actually starts.

The four crossings a single arrow hides

A trust boundary is any point where data controlled by one principal — one identity the system recognises and acts for — influences a decision made on behalf of another. In this design the customer controls their ticket text, the authenticated support agent is the principal the system serves, and the model sits in the middle turning one into the other. Every place where ticket-derived text can change what happens to the support agent's authority is a crossing, and there are four of them, only one of which is on the input side.

Flowchart: Customer ticket text → Prompt (crossing 1: into prompt); Prompt → Plan / next step; Plan / next step → Tool arguments (crossing 2: steers the plan); Tool arguments → Tool executor (crossing 3: becomes a call); Tool executor → lookup_account / post_note / email_customer; lookup_account / post_note / email_customer → Prompt (crossing 4: result re-enters next turn).

Crossing one is the familiar one: ticket text enters the prompt. The delimiters from topic 4.3 live here, and they mark the chunk as data without making it inert, which is the distinction the rest of this topic keeps leaning on. Crossing two is the plan: the ticket does not have to move a specific argument to do damage, because if it can steer the model's choice of which tool to call next — "before answering, email the customer a confirmation" — it has redirected the agent's behaviour without touching any single field. Crossing three is the arguments the model emits, which is where the recipient rewrite in chapter 2 happened; the model hands the runtime a to address and the runtime, by default, dials it. Crossing four is the one almost everyone forgets: the tool result re-enters the prompt on the next turn, so a lookup_account that returns a field a customer populated has just delivered fresh untrusted text into the context.

The diagram puts the model inside a box labelled as something that cannot be a boundary, and that label is a claim rather than a convention, so it is worth earning. A trust boundary is a place where a decision is made about whether one principal's data may influence another principal's authority. Making such a decision requires knowing which principal a given span came from. The model receives one sequence of text in which the ticket, the support agent's question and the system's own instructions are all present as text, with no field attached to any span recording who supplied it. What the model can observe about a span is what it says, and nothing about where it came from. A decision procedure cannot condition on an input it does not receive, so the model cannot refuse a sentence on the grounds of its origin — the only grounds available to it are the sentence's content, which is the thing the attacker chose. Everything downstream of that follows: the model may be excellent at noticing that a sentence looks suspicious, and noticing is a judgement about content, which is a different thing from a boundary.

Crossing four deserves a second look, because it is what makes a multi-turn agent qualitatively harder to reason about than a single-shot one. In a single call the untrusted text enters once and the model produces one output; the boundary is crossed twice and the interaction is over. In a loop, the result of every tool call is appended to the context and fed back, so a lookup_account that returns a customer-populated display_name field has quietly re-injected untrusted text on turn two, and that text now sits alongside the model's own earlier reasoning where it is even harder to distinguish from trusted context. An attacker who cannot reach the first prompt directly can sometimes reach a later one by planting text in a record they know the agent will look up, so the loop turns a read tool into an injection channel. The number of boundary crossings grows with the number of turns, and each one is another place a corrupted value can enter or leave.

That growth is worth deriving rather than asserting, because it changes what a one-off review is worth. Each turn of the loop does the same three things: it sends a context to the model, it receives a proposed call, and it appends that call's result to the context for the next turn. The result is appended, not substituted, so the context at any turn contains every result before it. A review that inspects the first prompt has inspected the smallest context the run will ever have. Every later turn is strictly larger and contains material chosen by the previous turn's tool arguments, which the attacker has partial say over. So the surface a reviewer signed off is not a fixed thing that the loop repeats; it is the first frame of a sequence that the attacker can extend by getting one argument to point somewhere they control. Reviewing turn one and generalising to the run is the mistake, and it is invisible on a diagram that shows a single request.

Crossing two is the one people accept as a category and then fail to price, so it is worth showing what it reaches without touching a single argument. A plan is a choice of which tool to call and in what order. Every tool the agent holds is one it is authorised to call, otherwise it would not be in the schema. A ticket that persuades the model to add a step — to send a confirmation as well as answering, or to look something up before replying — has therefore caused an authorised call to happen, with arguments the model derived honestly, at a moment nobody asked for. Nothing was corrupted; something was scheduled. That matters because a great deal of damage is available purely through selection: an email that would have been fine tomorrow is an incident today, and a read that was legitimate for one account is a leak when it is performed during another account's session. Reviewers look for a wrong value in a field and miss a right value in a call that should not have been made.

The reason the single arrow is so common is that it matches where the text is scary to read. A malicious paragraph in a retrieved ticket looks dangerous. A to argument looks like a string the model produced, which feels like the model's output rather than the attacker's input, even though its value came from the ticket. The boundary follows the data's provenance, not its position in the pipeline, and the data that originated with the customer is still customer-controlled after it has passed through the model. Laundering text through a language model does not change who wrote it.

The argument you most want out of the model's control

Of the three tools, the argument to protect first is the recipient of email_customer — specifically the destination the mail actually goes to. The body and subject are attacker-influenceable and mostly bounded: the worst case is an embarrassing or misleading message to a customer who is already the subject of the ticket. The account_id on post_note matters, because a note written to the wrong account is a cross-account write, but its blast radius is an internal note field. The recipient of an outbound email is the argument whose corruption reaches furthest, because it is the one that carries information out of the system to a party the payload chose, and once mail leaves, no downstream check can recall it.

Ranking the three tools by what a corrupted argument reaches makes the priority order explicit rather than a matter of feel.

ToolCorruptible argumentWhere a bad value goesReversible?
lookup_accountidReads another account's data into the contextNo — the data has been read
post_noteaccount_idWrites a note to the wrong internal accountYes — delete the note
email_customerrecipientSends information to a party the payload choseNo — mail has left the system

The table has two independent questions in it and reading them as a pair turns a ranking into a rule. One question is whether a bad value can be undone. The other is whether the value carries something out of your system or brings something in. Cross the two and there are four possibilities, and the table fills only three of them. post_note is reversible and inward. lookup_account is irreversible and inward, since a record once read into the context has been read. email_customer's recipient is irreversible and outward. The fourth combination — outward and reversible — has no entry, and it has none because nothing that has left your system is yours to retract: recall depends on controlling the thing you want back, and control is exactly what leaving gave up. So outward implies irreversible, which means the direction question already answers the reversibility question and you can rank on direction alone. That is why the rule generalises to tools this chapter never lists. You do not have to weigh the magnitudes of two harms against each other; you have to ask which way the value travels.

The recipient of email_customer and the id of lookup_account are the two irreversible crossings, and the recipient outranks the read because it moves information outward to an arbitrary destination rather than inward to a bounded one. That is the general rule the specific case teaches: rank the arguments by what a corrupted value reaches, and the ones that cross out of your trust boundary — external recipients, external URLs, grant targets — outrank the ones that stay inside it. In the design-document incident in chapter 1 the equivalent argument was share_document's email; the tool names differ and the shape is identical. You want that argument determined by something other than the model, or bounded so tightly that the model's choice cannot leave the set of safe values — the authenticated customer's own address on file, and nothing the ticket can name.

Marking a boundary and defending one

Marking crossings tells you where the defence has to sit; it does not defend anything by itself. The trace is a map, and a map of the doors is not a lock. Two responses to the map are tempting and both misplace the boundary.

The first is to lean harder on crossing one — better delimiters, a firmer system-prompt instruction, a label on the untrusted chunk. That improves the prompt and does nothing about crossings two through four, because a boundary that lives in text the model reads is a boundary the model can be talked past. The next chapter is entirely about why this is hygiene rather than control.

The second, and the one that sounds most like real security, is to add a component before crossing one that reads the ticket and decides whether it contains an injection — a second model, or a sanitiser that strips imperative sentences before the text reaches the prompt. This puts the defence on the input side, which is where the intuition from escaping user input in a database query pulls everyone, and topic 4.9 on templating and escaping untrusted values is the place that intuition is properly earned. Whether it survives contact with natural language is the subject of the next chapter, and the short version is that it does not, because the thing being escaped has no grammar. A defence on crossing one is trying to stop the text from being dangerous, and a defence on crossing three is trying to stop the action from executing, and only one of those can be made to hold.

The containment that does hold does not try to decide whether the ticket is hostile. It lets the model read the ticket, form a plan, and propose an argument, and then it puts an independent component on crossing three — one that does not read the ticket and applies a rule the ticket cannot influence, such as "email_customer may only send to the address on file for the authenticated account_id." The model may still be fully fooled; the corrupted recipient simply never survives contact with a check the payload could not reach. The check does not need to know that an attack occurred, which is what makes it robust: it is not detecting the payload, it is refusing to let the model's output name a recipient outside a set the runtime already trusts.

That separation — between a boundary and a filter — is what the next two chapters build on. Chapter 6 is given over to disposing of the input-side filters that look like the answer and are not, and chapter 7 to constructing the authorising component and testing it against an attacker who has already won inside the model. Neither of them changes the map drawn here. The map is what tells you that the work belongs on crossing three, and a team that has drawn all four crossings and defended none of them is still further along than one whose diagram has a single red arrow, because it knows which arrow the argument is about.

Prompt injection — read the current version at https://daviddominguez.dev/learn/en/prompt-injection/trust-boundary-crossings

Back to top