Threat model
Compression is an attack surface. An attacker who controls untrusted input — a fetched web page, a file in a repository, an MCP tool's output, an issue comment — can perturb it so the compressor discards the one line that mattered. The request still succeeds, the savings still look good, and the answer is wrong. This page states what Distil mitigates by construction, what it merely detects, and what is out of scope.
The reference is Context Manipulation Attacks on LLM Agents (COMA, arXiv 2510.22963, ASE 2026). Its validated mitigation is isolating trusted from untrusted content into separate compression budgets. The decision record is ADR 0009.
Why this is sharper for Distil than for a summariser
Distil's keep policy is legible. tier1.py and keep_policy.py state exactly what survives: DECISION: markers, verdict lines, error and failure lines, traceback frames, diff hunk headers, head and tail context. An attacker who reads the source knows precisely what to imitate. A heuristic anyone can read is a heuristic anyone can bait.
So Distil does not claim its keep policy resists an adaptive attacker, and this page will not make that claim. The keep policy is a savings heuristic. The security property sits one level below it.
Mitigated by construction
Nothing folded is irrecoverable
Every fold announces itself. A stub reads << +N lines, handle=… >>, the original sits in the RestoreStore keyed by its content address, and distil_expand returns it byte-for-byte. So the invariant Distil asserts against hostile input is a disjunction, and both branches are acceptable:
The genuine load-bearing line survives in what we forward — or it was folded, the block is reversible through a handle Distil issued, and the stub declares that lines were elided.
An attacker can push a line from the first branch into the second. They cannot push it out of both, because the second does not depend on any judgement they can influence.
Trusted and untrusted content never share a budget
The paper's mitigation is already Distil's shape, and the reason is worth stating precisely rather than claiming as a design win. There is no global keep budget anywhere in the pipeline. tier1.digest decides head, tail, and must-keep lines from a single block's own text; compress_messages walks blocks one at a time. No counter, cap, or ranking is shared. An untrusted block cannot starve a trusted one, whatever its size or position.
That is a claim about an absence — and an absence is what a plausible future optimisation quietly fills in. A “keep the top N lines per request” cap would read as a sensible improvement and would construct exactly the shared budget COMA attacks. So it is asserted as an equality: a trusted block must compress to exactly the same bytes whether or not a 4000-line attacker block sits beside it, in either order. Any shared budget, however generous, breaks that equality.
A forged handle is never vouched for
A tool result can print anything, including Distil's own stub syntax. Handles are content addresses of blocks Distil actually folded, so a fabricated handle simply does not resolve — it is absent from the store and expand raises rather than returning something plausible.
The battery, and what it measures
distil validate --adversarial drives seven hostile cases through the same public compression path the proxy uses. Each carries the specific line an attacker is trying to get discarded, and is checked against the five existing invariants plus the new load-bearing one.
| Case | What it baits | Measured result |
|---|---|---|
decoy_verdict_flood | 400 fake DECISION: lines crowding the real one | survives verbatim; savings drop to 0.0% |
dedup_baiting | 300 lines sharing the real error's numeric shape | folded — recoverable and declared |
salience_baiting | junk stuffed with the agent's own query terms | survives verbatim |
handle_forging | a tool result printing Distil's stub syntax | survives; forged handle never resolves |
budget_starvation | one 3000-line untrusted block | survives verbatim |
expand_prompt_injection | text instructing the model to call distil_expand | survives; treated as ordinary text |
cross_block_starvation | a huge untrusted block beside a trusted one | survives verbatim |
The one case that lands in the second branch
dedup_baiting is the genuine COMA-class hit, and it is named here rather than smoothed over. Outcome-aware noise dedup normalises digits away to compute a line's shape, so 300 attacker lines differing from the real error only in a shard number collapse together with it — and the real line is folded. It stays recoverable and its stub declares the elision, so the invariant holds. But the keep policy did lose it. It has a regression test that fails if the behaviour changes in either direction, so an improvement has to be deliberate rather than accidental.
Real, and not mitigated: denial of savings
decoy_verdict_flood drives a block's savings to exactly 0.0%. Every fabricated verdict is kept, because verdict lines are exempt from dedup and always kept. That is the correct trade — correctness over savings — but an attacker who can write into a tool's output can make compression stop paying for that block. Distil is not a security control and cannot be spent down into one, so this is stated as a cost rather than fixed.
Detected, not prevented
Shadow mode replays the real decision against the uncompressed context and flags divergence. It is sampled, so it is a detector rather than a gate: it will notice a systematic attack across many requests, and will usually miss a single targeted one. Read it as monitoring, not as a control.
Out of scope
- Provenance. Distil compresses a request; it does not authenticate its contents. It cannot distinguish a genuine
DECISION:line from one an attacker wrote into a fetched page, because by the time Distil sees it, both are bytes in a tool result. Provenance belongs to the agent and its tools. - Prompt injection aimed at the model. Compression neither introduces it nor defends against it. Distil treats such text as ordinary content, and the battery confirms it changes nothing about how the surrounding block is handled.
- A compromised client or host. The
RestoreStoreis local and owner-only; an attacker who already has the machine has the originals regardless.
For the byte-level guarantees across turns, see the Cache Contract. For deployment hardening, see Deploy & Security.