Releases: ilyautov/cordon
Release list
Cordon 0.5.0
Cordon now runs inside a LangChain agent's own loop. createCordonMiddleware(options) — built on the v1 createMiddleware contract — wires the same core to two hooks, and the adapter holds no security logic, as everywhere else. beforeModel feeds the last user message to the core, so this transport keeps what the MCP gateway does not have: real user turns, with the certificate issued per turn, the exposure and unredacted marks lifted by a new message, and the user-named atoms feeding the exposure exemption. The hook fires on every agent step and the loop never appends human messages, so a turn is told from a repeated pass by position and text — object identity would not survive LangGraph rebuilding messages between steps, and the stateless identical-text case is named in the docs as the blind spot it is.
wrapToolCall carries the other two entries. Before the handler, the call goes through the gate: deny and ask return an error ToolMessage with the reason and the tool never runs; rewrite calls the handler with the rewritten arguments, leaving the model's original request object untouched. After the handler, the result's text is observed — cleaned, recorded into provenance, substituted when the source's view allows it. A result that is not a ToolMessage (a Command) or a content block without text cannot be cleaned; the session is marked rather than silence pretending a check happened, and the next consequential call escalates.
langchain and @langchain/core are peer dependencies — the first exception to the two-runtime-dependencies rule, made deliberately: the application's own agent framework is not a library Cordon chooses, and the plugin bundle (plugin/dist/cli.js) does not contain it. The tests run end to end against a real createAgent on a scripted model: the poisoned result is cleaned, the refused tool is never called, exposure escalates and is released by the user naming the destination. What a live agent run shows remains unmeasured. Usage and limits: docs/install-langchain.md.
Full changelog: v0.4.0...v0.5.0
Cordon 0.4.0
Cordon now stands in front of MCP servers, not only inside coding harnesses. cordon mcp -- npx server-x is a stdio proxy between an MCP host (Claude Desktop, Cursor, a hand-written agent) and one upstream server: JSON-RPC 2.0 over newline-delimited JSON, implemented by hand, so the dependency count does not move. Every intercepted decision is made by the same core the hooks use; the adapter holds no security logic of its own.
The interceptions, verified end to end against a real child process. Tool descriptions from tools/list are observed as the untrusted content they are — tool poisoning hides the instruction exactly there, and the hidden layer is cut before the model sees the list. A tools/call goes through the gate before the server sees it: a refused call never reaches the upstream and the model reads the reason as a CallToolResult with isError: true; a rewritten one is forwarded with the untrusted fragment cut out of the arguments. Text blocks of results, resources/read and prompts/get answers are observed and substituted with the cleaned text; a block with no text in it — an image, audio — cannot be cleaned, so the session is marked and the next consequential call escalates, rather than silence pretending a check happened.
The transport has no user turns, and two things follow honestly. The certificate is the policy profile for the whole run. And the exposure exemption — a consequential call after an untrusted read passes when the human named its destination — gets its naming from a new policy field, task: "<the assignment>": atoms are extracted by the same function that reads user messages, fed through a new facade entry that counts no turn and lifts no mark. A non-string task is a load error, not a silent default. The exposure mark stands for the life of the gateway process; the recipe is a restart per task, and the blind spots — the host's built-in tools go past MCP, one upstream per process — are named in docs/install-mcp.md.
The direction of failure is better than the hooks', by construction. A crashed hook reads as "let it through"; a dead gateway is a dead MCP server, and the host shows the error. A broken line from the upstream or a dead upstream stops the gateway loudly instead of degrading it into a proxy that no longer checks anything. The gateway has not been run against a live host yet; what is verified is the full message path against a real server process in the test suite.
Full changelog: v0.3.0...v0.4.0
Cordon 0.3.0
The data axis now answers the fact of reading, not only a match. The adversarial battery had measured the working profile at 75% attack success rate, and almost the whole tail was attacks whose arguments share no recorded byte with the page that ordered them: a paraphrase, a fourth round of percent-encoding, base64, rot13, a curl -d @file with a clean command line, a fabricated "user: yes, I confirm". String matching cannot close that class in principle — judging by meaning is the model-in-the-loop the first invariant forbids.
The mechanism follows Progent (arXiv:2504.11703) and FIDES P-T (arXiv:2505.23643). A session that read untrusted content is marked, and while the mark stands, a call acting beyond reading — any irreversible class, plus create, which carries the verbatim-republish worm — escalates unless every one of its targets was named by the user in their own message. Atoms are extracted from user prompts with the same function provenance uses, so a link or an identifier is the same token on both sides of the comparison. A new user message lifts the mark, on the same argument as for the uncleaned-layer mark: the user has seen the outcome of that turn and could stop it. Content returning to the very file it was read from stays exempt — the mark does not touch the return-to-origin path.
Measured on the regenerated battery: the wide profile drops from 79% to 7% ASR, and a fourth profile, wide-no-exposure, keeps the old world in the run so the delta is measured rather than asserted. The two rows that still succeed are documented, not hidden: exfiltration to a destination the user themselves named (the exemption is by destination, not by payload — resource bounds are the answer) and the false-kinship denial of service. The price is measured too: after any untrusted read, an autonomous agent's consequential calls escalate until the operator names the destination — docs/adversarial-report.md prices both directions.
The rule is switched off with exposure: false in the policy. The default is on, a non-boolean value is a load error rather than a silent default, and cordon doctor names the off state out loud with its price, because from the outside it is indistinguishable from a session that simply read nothing untrusted.
Full changelog: v0.2.3...v0.3.0
Cordon 0.2.3
Cordon has been run on a live harness. Claude Code 2.1.236, hooks bound to all four events, a session of its own and a home of its own; the whole path was watched from the outside rather than from a test double. All four events fire. A call outside the certificate is refused and the file is not created. A link that came out of a file that was read is refused, because a URL cannot be partly quarantined and a truncated one is a different call. The source-influence footer appears under the model's answer. The record, with the journal lines it produced, is in docs/live-run.md.
The question both READMEs named outright is answered: the harness applies updatedInput without a permission decision. Asked for a file of three lines, one of them copied verbatim out of untrusted text, what landed on disk was the other two. It applies only to a call the harness was going to allow anyway — with the tool not pre-approved the write never happened at all — so quarantine is not a second permission system and does not become one.
That measurement came with the finding that mattered most, and it was not good news. A refusal announces itself: the call did not happen and the model says so. A rewrite goes through with a piece cut out, and nothing tells the model — its account of the turn described three lines while the file had two. Every refusal in that run was journaled and this was not, which left the file itself as the only record that anything had been changed. A rewrite is now written to the journal alongside the refusals.
ToolSearch is classified as read. Where the harness defers a tool, the model reaches it only through a schema lookup, and an unclassified tool escalates — so a profile of read, summarize could not look up WebFetch, and the journal recorded a refusal against the lookup rather than against anything the model wanted to do. That is a false positive in ordinary work, which this project treats as worse than a miss. Nothing is loosened by listing it: a schema is not a call, and the call it leads to is classified on its own merits.
Gemini CLI still has no live run. Its events, their names and the shape of a rewrite are all different, and nothing measured here transfers to it.
Cordon 0.2.2
The name an argument goes by no longer decides whether it is a path. The same three lists — which argument names hold a path, which hold a URL, which hold a command — existed in three copies, in the gate and in each adapter, and they had drifted apart. The gate knew urls, webhook, baseurl and the plural spellings; the adapters knew a shorter list without them. filename was in none of the three, and it is what a tool call says when it writes a file.
What that cost is measurable rather than theoretical. Cordon's self-protection — the rule that nothing may write to Cordon's own state — reads the path out of the argument, so a write aimed at ~/.cordon/state under the name filename, absolutePath or output_path was not a path at all and went through. On the other side, a file read from a trusted directory under the name filename was not recognized as trusted, and its contents were taken for untrusted text. Four tests now cover exactly these; against the old lists all four fail.
There is one list now, in src/core/argument-keys.ts, and one folding of the name — case dropped, _ and - removed, so output_path, outputPath and output-path are one name. file and files are deliberately left out: tools use them for a record as often as for a path, and a non-string under a path key is a refusal in the gate, which would turn a naming coincidence into a blocked tool call.
Cordon 0.2.1
Two hooks at once no longer erase each other's provenance. A harness runs its hooks in parallel and each is a process of its own; both used to read the same state, add their own source to it and write it back whole, so the later write took the earlier one's memory with it and nothing said so. Measured against the built plugin before the fix: twelve runs out of twelve lost one of the two sources. Erased provenance is an empty store — precisely the permissive state an attacker is after, obtained by doing two things at once rather than by defeating anything.
Each writer now has a file of its own and reading merges them. Merging is monotone in the safe direction in every field: the taint stores unite, because a source one process saw is a source that was read; the mark is an or; the turn is the later one; a narrowing intersects, which is the only direction it ever moves in. A writer removes the pieces it read, because their contents are inside what it just wrote — and only those, since a piece that appeared afterwards belongs to a process still running. So a quiet session keeps exactly one file, and a busy one keeps as many as it has writers at that moment.
The order is write-then-remove rather than the reverse: a reader between the two sees the same state twice, which merging makes harmless, while the other order would show it neither.
Cordon 0.2.0
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.
Cordon 0.1.1
A hook can no longer block forever on a directory it cannot create. mkdirSync(path, { recursive: true }) does not always come back: under /proc on Linux it never returns and burns no CPU while not returning, where the same call without recursive gives ENOENT in twenty milliseconds. Cordon's home is a setting, CORDON_HOME, so that path is reachable from configuration, and it sits on the hot path in four places — both adapters, the session store and the notifier. By this project's own rule a hook that hangs is a hook that permits: both harnesses read a missing answer as leave to proceed. The levels are now created one at a time, which turns a silent block into a refusal with a reason.
That is also why CI never finished. The suite passed and then sat there until it was killed, and the cause was neither the pool nor the worker count nor the node version — all six combinations hung. One file never reported at all: tests/adapters/claude-code/main.test.ts asked for an unreachable home and named /proc/no-such/path, which is unreachable on Linux and simply absent on macOS, so the suite passed on a laptop and hung on a runner. The test now points at a plain file standing where a directory has to be: ENOTDIR everywhere, for every user, root included. An earlier note here credited a single-fork pool for a fix and a speed-up; poolOptions does not exist in vitest 4, that setting was ignored, and both claims were wrong.
The test step's verdict is read from the report the run writes rather than from the exit code, because a run killed at the limit exits with nothing to say, and the numbers the READMEs state are checked against that same report.
A fresh clone can run npm test. Five tests started dist/cli.js as a separate process without anything having built it, so they failed on a missing file rather than on what they were written to check. They now build it on demand, like their neighbours already did.
The check that the committed bundle still matches the sources works again. It had been disarmed by a neighbour: one test rebuilt everything in beforeAll, bundle included, so by the time the check ran the artifact had just been regenerated and matched itself. The build is now split, and what the tests build on demand is dist/ alone. Verified by changing a string in the sources without rebuilding and watching the check fail, which it did not do before.
Cordon 0.1.0
The first public version. Three axes work end to end and are covered by 860 tests: the hidden layer is stripped out of untrusted content, provenance remembers where every value came from, and a call is compared against the effect classes of the certificate issued when the human stated the intent. Two harnesses run the same bundle, Claude Code and Gemini CLI.
What this version does not claim: the wiring has not been exercised on a live harness, and one gap is named outright rather than hidden, in the README and in cordon doctor alike. Whether updatedInput applies without a permission decision is not confirmed by the harness documentation, so argument quarantine in autonomous mode is unverified while the control axis keeps working. Read "What this is NOT" before putting this between an autonomous agent and anything that matters.
Page size can no longer switch the defence off. Tree-based HTML parsing grew quadratically: 1 MB took three seconds, 2 MB took fifteen. The PostToolUse timeout on Claude Code is ten seconds, and an expired hook there means a pass: no neutralization, no provenance. Ballast on a page was therefore a way to disable Cordon entirely, and it cost the attacker nothing but traffic. Parsing moved to streaming htmlparser2: linear, 8.7 MB in 371 ms. Cleaned text is now cut out of the input by ranges instead of being re-serialized from a tree, so a document with nothing hidden comes back byte for byte identical.
A second harness: Gemini CLI, installed as an extension. The same bundle, the same four events, the same policy. Behaviour is deliberately not identical, and the difference is named in the documentation and in cordon doctor: there is nothing on this harness to replace a tool result with, so a poisoned result is rejected whole and the cleaned text travels in the rejection reason.
A third axis was added, the output axis: a footer about the influence of untrusted sources appears under the model's answer. It names the sources the answer matches verbatim and does not presume to judge paraphrase. The footer goes to the human and never enters the model's context. Switched off with output.footer in the policy.
The output axis no longer writes session state. Accumulated answer text moved into its own file (drafts/), so the footer can no longer overwrite provenance written by a neighbouring hook process between its own read and write.
Tool-result substitution is no longer applied to what the human reads as source. A file that was read and shell output reach the model exactly as they sit on disk: <style>, <script> and comments no longer disappear from them, and the next Write no longer overwrites the user's file with the stripped version. For web pages and MCP tool descriptions neutralization works as before. The rationale in one line: only what is hidden from the human, in the way the human looks at that source, is removed.
The view of a source is declared in the policy rather than inferred from a tool name. The toolsReturn table states what a tool returns: source or rendered. It exists because an MCP server that reads files did the same damage as the built-in Read did before the fix: the model received the file without <style>, <script> and comments, and writing it back destroyed its content. Guessing by tool name is not possible — the name is chosen by the MCP server.
The default for an MCP tool result is source: without a declaration, the hidden layer is not stripped from it. The choice is deliberate and in favour of data integrity: file corruption happens always, silently and irreversibly, whereas a pass requires an attacker, is audible to the human, and still runs into the two remaining axes. The price is named: the marketplace-review scenario now requires the line toolsReturn: {wb_reviews: rendered}. cordon doctor prints the effective default and every declaration; the message to the human distinguishes knowledge ("the human sees this source as source") from the default ("the view of this source is not declared"). The residual risk is named in "What Cordon does not do".
A hidden layer found in a file that was read does not vanish silently: it is named to the human in the transcript (systemMessage) and written to the notification journal as a notice record. There is no escalation — honest markup with a comment is no reason to restrict the next call. Provenance remembers such a file by its original text, so an argument assembled out of the hidden layer is still found and still goes to quarantine.
The numbers the READMEs state out loud are now checked in CI rather than trusted. scripts/check-claims.mjs compares the test count, the size of the attack corpus and the size of the loyalty corpus against the repository, in both languages. The rule this project follows is a number instead of an adjective, and a number that has quietly gone stale is worse than the adjective it replaced: the reader has no way to tell which claims on the page are still true. The check caught its first stale number immediately, the test count in both READMEs.
notify.webhook no longer exists, and a policy that still carries it is refused rather than loaded. The field was parsed, stored, and counted by cordon doctor as a notification channel, while nothing in the project ever sent anything to it: the notifier writes a file or stays silent, and there is no network in the core by design. The effect was the precise silence autonomous mode exists to prevent, an owner believing they would hear about a call blocked overnight and hearing nothing, with the one warning that would have told them switched off by the setting itself. Refusing the load is fail-closed here: an unreadable policy denies calls instead of passing them.
The repository grew the files a stranger looks for before trusting a security tool with anything: QUICKSTART.md for the five-minute path, PRIVACY_POLICY.md for what ends up on disk and for how long, SUPPORT.md for which of four channels a question belongs in, CODE_OF_CONDUCT.md, AGENTS.md for coding agents working here, and a pull-request template built around the three invariants. The privacy page is the one worth reading even if you skip the rest: the provenance store keeps links, paths, e-mail addresses and identifiers taken out of what was read, and a draft holds the answer text in the clear.
The invisible-character guard now covers prose, not only code. It reads markdown and text under docs, .github and the repository root as well as the sources, because a reader copies a policy example out of the README into their own file, and an invisible character riding along in that snippet is this project's own attack delivered by its own documentation. Writing this round of documentation broke the rule twice before the check was widened.
State sweeping was added. Session state lives for a day after the last event, accumulated display text for an hour. The sweep runs on the user's message and no more than once an hour, driven by a timestamp marker alongside: the PreToolUse hot path never walks the directory and pays nothing for the sweep. Only files Cordon wrote itself are deleted, symbolic links are not followed, and files of the session in progress are left alone. A failed sweep stays silent and breaks nothing.
Install: QUICKSTART.md