Prompt injection is not a bug you can patch. It is a property of how these systems are built: instructions and data arrive down the same channel, as one flat sequence of tokens, and a model has no reliable mechanism for telling which is which. Anyone selling you a filter that closes it is selling you comfort. What you can do is change what an injected instruction is able to reach, and that is an authorisation problem, not a text problem.
Last part put PII scans on the provider boundary and a citation check on every answer. Those checks are blind to this attack, because malicious content and legitimate content are the same shape until the model acts on one of them. This part adds a permission layer around the tools our assistant gained back in Part 14, and an injection corpus that runs alongside the eval suite.
Key takeaways
Hardening a system prompt is the most commonly given advice and the weakest control I measured. Delimiters plus a firm instruction moved attack success from 71.7 percent to 63.3 percent across 120 cases, a relative improvement of 12 percent, which is noise dressed as security.
Scoping tool permissions by data provenance took the same 120 cases from 71.7 percent success to 3.3 percent, and the four survivors were read only leaks rather than write actions.
Severity of an injection equals the blast radius of your most dangerous tool. An assistant that can only read has an embarrassment problem; one that can email, refund or delete has an incident.
Markdown image rendering is an exfiltration channel almost nobody blocks. An attacker does not need a tool call if your UI will fetch a URL for them.
Treat injection cases as a test corpus, not a review checklist. Ours runs on every prompt edit and takes 96 seconds.
Trust boundary in an LLM application
Classical application security has a boundary you can point at. SQL injection is solved because a parameterised query sends the statement and the values down separate paths; a database parses one as code and binds the other as data, and no amount of cleverness in a value turns it into a statement. No equivalent exists for a language model. Every byte in a context window occupies one namespace. System prompt, user question, retrieved chunk, tool result: all of it is text competing for the model attention, and priority between them is a soft preference learned during training, not an enforced rule.
So a useful mental model is simple. Content originating from a person you authenticated is trusted. Everything else, including your own documents if anyone outside the security perimeter can write to them, is attacker controlled until proven otherwise. Our support ticket archive qualifies, because customers type into it. That single reclassification changed more about our design than any filter did.
OWASP ranks this first in its LLM Top 10 for 2025, and pairs it with LLM06, excessive agency, for a reason worth stating plainly: injection is only as severe as the capability it reaches. Reading is a disclosure risk. Writing, sending and spending are incidents. Below is how a request flows once provenance is tracked rather than assumed.
Injection through a retrieved document
Direct injection, where a user types something rude at your chatbot to make it swear, gets all the attention and matters least. Indirect injection is the one that will page you. An attacker never speaks to your model; they write into a document your retriever will later fetch, and wait. Here is the smallest complete demonstration I can give you, using the ticket lookup tool the assistant already has.
Three weeks after we shipped that ticket lookup, a real customer ticket did this to us for real, and I doubt it was even deliberate; the body looked like somebody experimenting with a chatbot elsewhere and pasting the result into our form. Our assistant fired 63 escalations in 9 minutes before the tool wrapper rate limit stopped it, each one creating a Jira issue and each one paging the on call engineer through a routing rule nobody had thought to bound. Direct financial cost was nothing. Cost in trust was most of a quarter, because the support director had signed off on the tool integration personally and spent the following week defending it. My first instinct, and I want to be honest about how wrong it was, was to write a regex.
Limits of text based detection
Pattern matching on phrases like ignore previous instructions feels productive and buys about a week. Attack surface here is natural language, which means the space of ways to say a thing is unbounded, and every rule you add is one paraphrase, one translation or one base64 block away from irrelevance.
Across a corpus of 120 injection cases I wrote by hand, patterns flagged 71 and missed 49. A small model acting as a classifier did much better, flagging 98, at 118 ms and roughly 30 cents per 1,000 requests. Better is not the same as sufficient. Twenty two attacks still landed, and worse, the classifier flagged 31 legitimate tickets, several of which were customers quoting error text that happened to contain the word instructions. Every false positive there is a support engineer who cannot get an answer about a real ticket.
Contradicting the usual advice
Almost every vendor page tells you to harden your system prompt: state firmly that instructions inside documents must be ignored, wrap untrusted text in delimiters, repeat the user request at the end. I implemented all three and measured attack success falling from 71.7 percent to 63.3 percent. That is a 12 percent relative improvement on a number that needs to approach zero, and it costs tokens on every single request. Do it, because it is nearly free, but do not report it upward as a mitigation. It is a speed bump, and treating it as a control is how a system ships with the real hole open.
Least privilege on tools and data
Here is the shift that actually worked. Stop asking whether text is malicious, which is undecidable, and start asking where a plan came from, which is bookkeeping. If any untrusted content contributed to a decision to call a write tool, that call is denied. Content can still shape prose; it cannot originate an action.
You will immediately hit a legitimate case this breaks, and it is worth naming: a support engineer reads a ticket and genuinely wants it escalated. Provenance blocks that too, because the plan touched a ticket body. Route it through the approval gate from Part 19: a denied write becomes a proposed action a human confirms in one click. Our rate is about 20 confirmations a day, which nobody has complained about, and confirmation carries a second benefit worth more than the security, since a person eyeballs what the assistant was about to do.
Keep the table below near whoever owns your tool registry. Left two columns name the attack, right two name the control that works and the one people reach for first that does not.
Provenance tracking with a quarantined model
Provenance sets get coarse quickly, because almost every plan touches a document eventually and then everything is tainted. A cleaner version of this idea is the dual model pattern, formalised in the CaMeL work from Google DeepMind and ETH Zurich. A privileged model sees only the trusted user request and emits a plan. A second, quarantined model reads untrusted content but holds no tools and returns only values, which an interpreter carries with their provenance and checks against a policy before any tool fires. On the AgentDojo benchmark, that design solved 77 percent of tasks with provable security against 84 percent for an undefended system, so the honest framing is that you pay roughly 7 points of capability for a guarantee rather than a hope.
Look closely at that second block, because it contains the argument for the whole pattern. Attack succeeded, in the sense that the model did what the attacker asked. Consequence was a ValidationError caught by a retry. Injection you cannot prevent becomes injection you can survive, and surviving is the achievable goal.
Injection cases in the eval suite
Security controls rot the moment somebody adds a tool. Ours nearly did, when a well meaning change added a send_email function and forgot to list it in WRITE_TOOLS, which the injection corpus caught the same afternoon. Treat attacks as test cases and run them in the pipeline described in the Data Science Series part on CI and CD for machine learning, next to the quality evals from Part 20.
Tool permissions before text filters
If you take one thing from this part: build provenance scoped tool permissions first, and add detection later if at all. I would avoid buying an injection firewall at this stage, not because these products are useless but because they answer an undecidable question well and an answerable question not at all, and a team that has installed one tends to stop doing the boring authorisation work that would have actually held. Detection belongs on top of least privilege, never instead of it.
Your action for Monday: open your tool registry and write two columns, tool name and whether it changes anything outside your process. Anything in the second column gets denied when the plan touched untrusted content, and gets a human confirmation instead. That exercise took our team 40 minutes and reclassified two functions we had all been calling read only because they sounded harmless.
One gap remains. All of this assumes you can see what your assistant did and in what order, and right now you probably cannot, because a non deterministic system with a tool loop produces logs that read like weather. Part 25 covers observability, tracing and debugging a system that gives a different answer to the same question twice.
References
- OWASP Top 10 for LLM Applications 2025, for LLM01 prompt injection and LLM06 excessive agency, and the pairing between them
- Defeating Prompt Injections by Design, the CaMeL paper, for the privileged and quarantined model split and the AgentDojo figures of 77 percent against 84 percent
- Design Patterns for Securing LLM Agents against Prompt Injections, for a catalogue of patterns including action selector and plan then execute
- OWASP GenAI project page on LLM01, for the direct and indirect injection definitions used above
- OpenAI function calling guide, for the tools argument and tool_calls response shape in the code


DrJha