Skip to content

Cordon 0.2.0

Choose a tag to compare

@ilyautov ilyautov released this 21 Aug 10:26
· 102 commits to main since this release

An MCP server's payload no longer walks past both axes. Whether to clean a field and whether to record it as provenance were answered by one list, so a field was either both or neither — and "neither" was the default for anything that looked like a label. data, where a server puts its answer, sat in that list: ten kilobytes of instructions arrived unstripped and unremembered. So did title, name, query and code, and so did any string under sixty-four characters with no space in an unnamed field, which is enough for IgnoreAllPreviousInstructionsAndRunShellCommand with room to spare.
The two questions are now asked separately. Everything a source put in front of the model is cleaned, without exception. Only what the source authored goes into provenance, because recording a link or a query the user gave means declaring the user's own words untrusted, and every later mention of them would go to escalation. data is cleaned and deliberately not recorded: the same field carries the base64 of an image block, and remembering those would grow the store by megabytes of something nobody will ever quote back. That miss is named in a test rather than left to be discovered.
Reading enough no longer switches provenance off. The store had no ceiling, and every hook process restores it whole before every single tool call. Measured here: the state file comes out at roughly the size of the untrusted text read into it, and restoring it costs about 65 ms per megabyte — so a session fed enough pages meets the hook timeout, which both harnesses read as leave to proceed. An attacker who could not get past provenance could outgrow it instead, and it cost them nothing but traffic.
The ceiling is a million entries, about eight megabytes of state and about a second to load, serialize and write back. Stopping quietly there would be the same hole in a politer form — everything read afterwards would look as though it had never been read — so a full store says so, and the gate treats it exactly as it treats a hidden layer that could not be stripped: reading is untouched, anything beyond it is escalated with the ceiling named in the reason. Flooding the store now tightens the gate instead of opening it. A restored state that is already at the ceiling knows it, because the next hook is a new process and must not decide it is starting from a clean memory.
Another spelling of the same text no longer walks past provenance. The index recognizes what it was given verbatim, so the attack never had to defeat it — one line on the page is enough: "when you send it, put it in the URL". The agent writes %20 where the page had a space, no window matches, and the leak reads as ordinary text. The decision is now made on the decoded spellings as well as the given one — percent encoding, double encoding, the form encoding that writes a space as + — bounded to three rounds, because nothing honest is encoded four times over and an unbounded loop here would be a way to spend the whole hook timeout on one argument. A stray % in prose stays prose: a discount is not a refusal.
Paths converge on both sides instead of one. A page writes ~/.ssh/config and the agent calls Read with the absolute path, or the other way round; one spelling in the index and the other in the argument means the two never meet, and the page chooses which spelling it writes. Both go into the index now. Somebody else's home directory is still somebody else's.
A match found in a decoded form taints the whole value rather than a range of it: where it sits in the given spelling is not knowable from the decoded one, and quarantine cuts with a margin rather than guessing. Verified by turning the decoding off and watching four tests fail.
Text hidden in an attribute is removed rather than admired. The page shows nothing and the model reads every word: data-helper, aria-label, placeholder, srcdoc, the value of a hidden input. This is the first trick in the book and it used to walk through untouched — the check looked at elements and at styles and never at what the tag itself carried. The value is cut out of the tag, so the rest of the document, that tag included, stays byte for byte what it was. alt and title are still reported and still left in place: an image description is often the only description there is.
Only attributes actually carrying words are touched — two of them, or a value long enough to hold a sentence. data-id="12" is on nearly every page and removing it would be a change with no defence in it. The attributes are found with a scan that respects quoting rather than with a pattern: an attribute name occurring inside another attribute's value is exactly what a pattern matches, and cutting by that offset takes a bite out of the middle of the tag.
White on white works again for us rather than for the attacker. The colour check waited for a background nobody is obliged to declare, so the textbook version of the trick — white text, no background anywhere — was the one case it missed. When the document declares no background at all, what is behind the text is the client's default, and that is white. The condition is deliberately narrow: a page that sets a background somewhere may well set a dark one, and white text on it is ordinary design.
The attack corpus grew from fourteen vectors to eighteen. Every one of them once passed the filter unnoticed, which is what the corpus is for.