Dev

The injection you can't review your way out of

Code injection gets stopped at the seam where data becomes code, by refusing a whole syntactic class rather than weighing each case. It holds wherever that boundary is decidable, which is the exact property natural-language prompt injection never has.

There's a comforting story about keeping AI agents safe: put a human in the loop. The agent proposes, a person approves, and anything malicious gets caught at the gate. I build pipelines shaped exactly like that: an agent pulls from the web, assembles a draft, and hands it to a person before anything ships. And the longer I look at what actually keeps it safe, the less of that safety the human approval turns out to account for. The parts that stop code from running settle themselves, before the draft reaches a reviewer's eyes, and none of them is judging anything.

Where the injection actually lives

The injection that matters isn't in the instruction a human reviews. It's in the data. The sources are user-generated (Reddit threads, Hacker News comments), untrusted by definition. And the format those drafts are written in, MDX, treats curly braces as code: an expression like {something} is a JavaScript expression node. Untrusted text plus a format that reads braces as code is a data-to-code seam sitting inside the content.

The pipeline has a rule against feeding that seam: raw forum text never enters a draft verbatim, agents take only topics from it, and any real quote has to trace to a vetted source and sit inside a code fence. But a rule is an instruction to an agent, and an instruction is the exact kind of guarantee this essay is about not trusting. If a brace ever reaches the MDX anyway (an agent that ignored the rule, a quote fenced wrong), the safety shouldn't have depended on the rule holding. Which is why the parts that actually stop it sit further down, and don't care how the brace got there.

This isn't a hazard I invented to justify a lint. In July 2026 Hugging Face published a first-party timeline of an intrusion of exactly this shape: a dataset config, plain data, carrying a Jinja2 template injection and an HDF5 file read, processed by an autonomous agent with no human anywhere in the loop. The file was the payload; rendering it was the execution. Data became code, and there was no reviewer to bypass because a reviewer was never on the path.

The seam is decidable

What matters about the MDX case is that the boundary is syntactic. MDX has a grammar. A parser can walk the tree and point at every expression node with certainty (this is code, that is prose) without guessing, because the distinction is written into the language, not inferred from meaning. Every real defense downstream is built on that.

A renderer can already exploit it. Take next-mdx-remote, one MDX renderer, whose blockJS option is on by default. That option is not a scanner deciding which expressions look dangerous. It walks the parsed tree and deletes every expression node, wholesale, before anything renders. A malicious {fetch(evilUrl)} and a harmless {1 + 1} meet the same end: removed. Not judged, not sandboxed, just refused, because at a syntactic seam you don't have to judge. You can ban the whole class.

Two checks, neither one judging

The pipeline puts a second check in front of that one, blunter still. Before the preview server starts, a lint strips the frontmatter, strips fenced code blocks, strips inline code, and fails the moment a single curly brace survives anywhere else. It doesn't parse and it doesn't reason about intent. Any unfenced brace, open or close, and the draft goes no further.

Why bother, if the framework already strips expressions? Because the two catch different failures. A well-formed expression, blockJS deletes it; there the lint is redundant for safety, though not for the writing: without it, a brace lifted from a quote would just vanish from the published post, silently, with nobody flagging the missing sentence. A malformed brace, a lone { with no partner, never reaches the deletion step at all, because it fails to parse as MDX first, and that surfaces as a compile error several layers deep when the preview route is requested. The lint turns both into a named failure before the dev server is even running.

I won't oversell it, because the two are not the same kind of thing. blockJS runs inside the render path; nothing renders without it. The lint is a step the pipeline is told to run first, and a step can be skipped; nothing in the code forces it. So it is less a second wall than an earlier, cheaper one, plus a guard for the day someone flips blockJS off and the render path stops protecting itself. It is also blunt on its own terms: a regex, not a parser, with a mismatched-backtick gap the real MDX parser doesn't have, and a short blocklist of injection phrases that is exactly the porous language-side filter the rest of this essay tells you not to trust, kept only because it is nearly free. Which leaves one rule worth memorizing: mediate a seam with the parser that will actually render it, never a lookalike, because the gap between the two is where a smuggled payload lives.

The natural-language seam has no such gate

Simon Willison has spent years on the same problem from the language side, and his conclusion is the mirror image of mine: there is no reliable detector for prompt injection. Not because nobody is clever enough, but because that boundary is not syntactic. A malicious instruction and a legitimate one are made of the same substance, words, and no parser can walk that tree and rule "this token is data, that one is code." You can filter, you will catch some, and the adversary edits around the filter. His "lethal trifecta" (untrusted content, access to private data, and a way to send that data back out) names the conditions under which that seam turns costly.

That is the divide. Where the code-data boundary is syntactic, you get a decidable seam, and you can ban the class outright with no detector and no reviewer. Where it is semantic, inside the model's own context, you can't, and a human in the loop does not close the gap. It relocates the same failure to a slower, more confident checkpoint.

The move, and where it stops

So the principle isn't "review harder," and it isn't even "fail the build." It is: separate code from data by construction, at the seam where one becomes the other, before anything evaluates. There are two ways to do it. Either the grammar labels the sides for you (MDX marks its expressions, and you delete the labelled class), or the interface carries them on separate channels, the way a parameterized query sends the statement down one path and the values down another, so nothing downstream ever has to decide which is which. Banning a class is the cheap special case, available only when the dangerous form has no legitimate use in the untrusted channel: braces qualify, because I never need one in prose; string-concatenated SQL doesn't, which is why the fix there is separate channels, not a ban. A safe deserializer and a template sandbox reach for the same seam from further back, with an allowlist of types or a walled render; they cost more and they can be escaped, because they arbitrate where the grammar won't label. The cleanest gate needs no intelligence at all; the rest buy safety with judgment, and pay for it in escapes.

And when the seam isn't decidable, when the "code" is instructions living in a model's context, you can't label the sides and you can't split the channel, because reading instructions out of text is the whole product. The toolkit doesn't vanish, though. It moves. You stop trying to gate the instruction and gate the capability instead: what the agent can call, what it can reach, what it can send back out. Cut one leg off the trifecta and the undecidable seam stops mattering. You didn't detect the injection; you took away the thing it was reaching for.

Which returns me to that review gate. The code-execution class was settled before anyone opened the draft: two checks that don't negotiate had already refused every brace, at a seam where the answer was never a matter of opinion. But that class is not the whole of danger. A malicious link renders fine, raw markup passes untouched, and a false claim reads as clean as a true one. None of it is a curly brace, so no gate catches it, and all of it is exactly what a human read is for. The machine takes what's decidable and a person takes what isn't, which makes the reviewer not the least important line, just the only one left once the decidable part is settled.

Discussion

No comment section here — all discussions happen on X.

Max Nardit

Max Nardit

@mnardit

More articles

Put the more agentic model in the reviewer's seat

The traits that make a model more agentic (it expands scope, verifies itself unprompted, writes longer) are a liability in a bounded unattended worker and an asset in a reviewer. So the fleet's workers run on Claude Opus 4.8 while Opus 5 does the reviewing. A field note on matching a model's disposition to its role, not its benchmark.

Claude told me to go to sleep at 10:47 in the morning

Anthropic calls it a character tic. I went looking for what was actually producing it and ended up reading the published system prompt, the character training paper, and the emotion-concepts paper. The behavior is what the stack makes likely.

Anthropic put a meter on the rest of the agent

On June 15, programmatic Claude usage moves to a separate $20–$200 API-priced credit. The email arrived this morning. Here is what it actually means and what it teaches about building on someone else's compute.