Skip to content

Known limits

Stated here rather than discovered later. If you are writing a risk assessment, this page and the re-identification analysis are the two you need.

Channel 2: what "not reversible" actually means

It is easy to read this section as "your shell output leaks". It does not, and the distinction decides whether the tool is usable at all.

What the model reads is protected. A kubectl command and its output come back to the model through the API — channel 1 — and are pseudonymised there like everything else. Measured:

10.1.2.3                              → 172.22.20.25
db-master-01-prod.acmecorp.internal   → vale-glacier-01-prod.litware-contoso.internal

What is not reversible is the EXECUTION. kubectl has to reach the real cluster; there is no fictional cluster to talk to. So on that path the hook blocks rather than substitutes — its job is to stop the agent sending data OUT (a curl to a third party, a vault read, an environment dump), not to hide your infrastructure from the model. That second job belongs to channel 1, and channel 1 does it.

That is inherent, and it will not change.

For remote MCP it is no longer the case: task forward -- <agent> runs any agent behind a forward proxy that terminates TLS, pseudonymises JSON-RPC bodies on the way out and restores them on the way back. Destinations come from a list the operator writes in the state directory, and an unlisted one is refused.

Proven by a real session

tests/forward_e2e.sh runs a real Claude Code session under the launcher. The agent works, its model traffic is seen by the proxy — and the destinations Phase 0 measured as escaping are refused, with no socket opened:

api.githubcopilot.com:443 -> refuse (destination non déclarée)
registry.npmjs.org:443    -> refuse (destination non déclarée)
mcp.context7.com:443      -> refuse (destination non déclarée)
api.anthropic.com:443     -> tunnel

And the session still completes. A chokepoint that stopped the agent working would be a wall, not a control.

D9 is not met on a workstation

On the default path, the egress harness detects; it does not prevent. Under task forward, it does prevent — measured above — but only for what honours HTTPS_PROXY. A process opening a raw socket ignores it, so the deployment shape (an internal network, the proxy alone straddling both sides) remains the only enforcement. Say exactly that to a DPO.

Measured on one real session: four destinations out of five escape ANTHROPIC_BASE_URLmcp-proxy.anthropic.com (×12), mcp.context7.com (×11), registry.npmjs.org (×4), api.githubcopilot.com (×2), against api.anthropic.com (×5). That counts destinations and connections, not volume: the traffic carrying your infrastructure is the model's, and it does go through the proxy.

They escape ANTHROPIC_BASE_URL, not a proxy

Those same four were captured through mitmproxy by the Phase 0 harness, with HTTPS_PROXY and NODE_EXTRA_CA_CERTS. So they honour an explicit forward proxy; what they ignore is one API client's base-URL setting. A forward-proxy mode brings remote MCP — JSON-RPC, which the walker already knows how to traverse — into the reversible channel. That is what task forward now does.

A local firewall cannot fix it either way, because api.anthropic.com and mcp-proxy.anthropic.com resolve to the same address.

The shape that does close it is deployment: an internal network for the agent, with the proxy alone straddling both sides. That is not a rule to maintain but an absence of route. See Network isolation.

The five preserved attributes are leaks

Environment, /24 co-membership, human vs service, internal vs external, and the interval between two dates survive substitution on purpose (D1 usability). They are also exactly what a re-identification attempt would correlate on.

Detection gaps

Person names, dates and postal addresses were the whole of this section until a real session put three people — the reporter, the on-call engineer and a customer — in front of a model that has no class for them. The infrastructure detector is a cyber-security NER: 33 labels, MALWARE, THREAT_ACTOR, CVE_ID, LOCATION, ORGANIZATION, and no PERSON. Barack Obama met Angela Merkel in Berlin returned one span: Berlin.

All three are now covered by a second detector, in its own process, on the Apache-2.0 side. It must be running: if it is unreachable the proxy returns 503, exactly as for the other one. ANONPROXY_PII=off disables it — an operator decision, printed at startup — because an outage must not decide that for you.

A date is shifted, not drawn: one constant per scope, so every interval survives and an incident still reads as a sequence. The end-to-end proof that asserted this gap inverted has been turned the right way round — it failed the day the gap closed, which is what it was written to do.

A partial date is shifted at its own granularity, and the step is converted rather than drawn again: a month-year moves by whole months, a quarter by quarters, a month-day within the year. Two reasons, and neither is cosmetic. Shifting a month-year by DAYS is not injective — February and March of one year are 28 days apart, which fits inside a 31-day month, so both could land in the same month and two real dates would share one surrogate. And drawing a separate step per granularity broke the chronology the module exists to keep: measured before the fix, 3 février 2026 and février 2026 — the same month in the source — landed fifty-five years apart. What is never invented is the missing field: August 2026 becomes another month-year, never a full date.

A fictional city can name a real commune, and a city with no postcode in front of it is not recognised as one. A postal address keeps its shape — a street number of at least one, the street type verbatim, a five-digit postcode in a department that exists, and a city drawn from a city lexicon. What designates nobody is the address as a WHOLE: the postcode is drawn independently of the city, so the pair cannot be anyone's. France has thirty-four thousand communes, so an invented French name may well be one of them; the arbitration is the same as a fictional domain under a real TLD. And it is the POSITION that identifies a city — Baker Street, London, with no postcode before it, still draws from the person lexicon.

Two people can still be given the same surname, in about one document in a hundred and twenty. The surrogate must not INVENT an attribute: the four preserved attributes are accepted because they are TRUE, whereas a fabricated family tie reads exactly like a fact of the document. The family name is composed from two lexicon words, which took the measured rate from 15.5% of four-person documents to 0.8%. What remains is stated rather than hidden, and a test holds it under 3%.

dates=cote_du_present preserves whether a date is past or future — and that is a LEAK, plus a guarantee with an expiry date. By default (libre) all dates move by one constant, so intervals survive but a past date can appear in the future; measured in a real session, the model reported an anomaly that does not exist. Under cote_du_present each date rotates within ITS side of today, so the side survives too. jo arbitrated both prices on 2026-08-13:

  • "past or future" joins environment, /24 co-membership, human vs service and internal vs external as an attribute that survives substitution on purpose, and it is therefore something a re-identification attempt can correlate on;
  • the PAST half is provable and definitive — moving a past date backwards keeps it past forever. The FUTURE half expires by itself: today advances, the vault freezes the surrogate, so a date shifted forward this morning can find itself in the past in a few years, with nothing to signal it.

Two more things it does not do, stated rather than discovered. A month-day (Feb 28) carries no year, so it has no side and the setting does not apply to it. And changing the setting only affects values substituted AFTERWARDS: the vault keeps what it sealed, so one document can mix both regimes and nothing counts it — jo's call, against the project's usual rule that an accepted residual is counted.

A substituted path segment keeps its file extension, and only that. Measured in a real session: nginx.conf came back willow-xenon, so the model could no longer tell a configuration file from a log or from a directory — the same loss of nature as a date returned as a word. The stem stays substituted, because the stem is what identifies: tenant-acme-nda.md becomes <word>.md.

What leaves in the clear is therefore the FORMAT, never the name. That is an opening, small as it is, and it was arbitrated by jo (2026-08-13) rather than decided in the code. The extension is not read off the last dot — db-01.acme.internal has one, and keeping .internal would put the zone in the clear. It is asked of the curated list the allowlist already holds, on a neutral stem, and a name with several labels is not a file name. The engine's own default keeps nothing: no default opens anything, and it is the launcher that wires the list.

What still falls to the generic substitution, and therefore comes back as a word: a season (hiver 1998), a bare year, and a numeric day-month whose order is ambiguous (03/04). The last one follows the rule this module already applies to 3 jui — what cannot be decided is not guessed. Consequence, stated: for those forms the model reads a hostname where the document says a date. A shared abbreviation (sept, oct, nov, dec) belongs to both languages, and a partial form carries no syntax to tell them apart, so the preferred table decides; a full month name resolves itself.

Recall was measured, and it decided the model

The first model shipped here returned two of the three people in a 1.1 KB incident file. The third was found at 0.96 when its line was submitted alone, so the miss was context, not length — and chunking bought it back at 1527 ms against 315 ms.

Measuring an alternative was cheaper than paying that. GLiNER takes its types in natural language, and asking for address rather than postal address is the difference between finding the address and not: 3/3 people, both dates and the address in one span, 249 ms, and zero spans on a pure infrastructure file. The chunking arbitration disappeared with the measurement that made it unnecessary.

Formulating those labels is now a detection decision. Changing them without measuring is changing the detector.

Why this gap deserved its own service

A value nobody detects produces no vault entry, no unresolved surrogate, and no public_by_shape line — that list counts what a form rule opened, not what was never seen. Nothing in the logs distinguished "there was no name in that file" from "three names went out in the clear". The only way to find it was to ask the detector.

That is the shape to look for in whatever remains: not the errors, the silences.

Two shapes that cannot be told apart

A line number and a business identifier are the same string. Digits, a tab, at the start of a line, sometimes right-aligned by spaces. Tool output numbers its lines that way; a padded TSV numbers its rows that way. Two attempts at a local discriminant both let a real identifier out in the clear, so the entity a span covers is no longer trimmed at its head: on the FIRST line of a numbered output, the number is substituted along with the entity, and the numbering is wrong by one line there.

Damaged numbering is visible — the model says so, we have watched it do it. An identifier that leaves is not.

An upstream that goes silent mid-body has no deadline. Distinct from one that truncates, which answers 502 since round 8. A stalled exchange waits.

Residuals that are counted

Residual Why it stays
a single-label domain under a ccTLD used as a file extension (acme.pl) removing those extensions turns main.py and lib.rs into fake domains, which has already broken a real session
a fictional external host on a real TLD (alpine-relecloud.net) — only under domaines_fictifs=tld_reels the default is now the RFC 2606 reserved space, which is provably nobody's; the real-TLD space stays reachable, but the operator has to declare it
a package under a third-party prefix (sigs.k8s.io/tenant-acme) indistinguishable from a real module without an inventory
a vendor media type (application/vnd.acme.x+json) dotted by nature; only an inventory can separate it
a query parameter name without a dot, at-sign or colon (?jdoe=) indistinguishable from an API parameter name
tools[].name, mcp_servers[].name, allowed_tools routing keys — substituting them breaks the tool silently

Every one of these is a question of inventory, not of shape. Filling config/inventory.txt closes them for your environment; no form rule can.

Residuals that are not counted

A surrogate truncated by the model is not restored, and nothing counts it. It is not a leak — fiction stays fiction — but the operator is shown a fictional value with no way to tell, and an unresolved surrogate is counted while one nobody recognises as such is not.

The same name written two ways gets two identities. Fragments are now joined across any horizontal whitespace, so Marguerite<nbsp>Vasseur is one entity rather than two — but the vault key keeps the exact spacing, so the ordinary-space and non-breaking-space spellings are two vault entries and two surrogates. Nothing leaks; the model sees two people across two documents. Normalising the key would fix it and would also orphan every entry already sealed under the old key, so what has been sent could no longer be restored.

That stopped being theoretical: a date field detected as 3 février 2026 à 14h32 made the whole string the vault key, so the model quoting the date alone matched nothing and the operator read a date that never existed. Spans are now narrowed to the entity before they reach the vault — the key is the date, not the field carrying it. The class remains for anything the model paraphrases rather than quotes; the announcement asks for whole identifiers, which mitigates without measuring.

Only gzip is decompressed on an inspected MCP channel. A deflate or brotli body is treated as unreadable and the exchange is refused. Adding a codec is small, but every decompression path needs the output bound and the adversarial pass that gzip got; until then the failure is a loud 502 rather than something relayed unread.

Two dates glued through a shared year lose the second one's day and month. March 15, 2020/04/16 shifts the first and leaves 04/16 verbatim: the two matches overlap on the year, and the longer one wins. The year — the only part that could date the incident on its own — is substituted; a day and month without it remain. Reachable only if a detector spans such a concatenation.

A version-shaped string can be read as a date. v3.14.2020 shifts to v11.6.2022. Nothing real leaves; something that was never a date comes back changed, and only if a detector marked it.

A date within about three years of 9999-12-31 loses its interval. The shift rotates within the representable range rather than overflowing, because overflowing meant copying the real date VERBATIM into the surrogate — and 9999-12-31 is the ordinary "no end date" of a contract, not a laboratory case. Rotating keeps the surrogate a date and keeps the mapping injective; what it costs is the gap to the other dates of the document, for those few values only. The alternative — shifting them the other way — would let two distinct real dates land on the same surrogate.

A readable fragment shorter than 16 bytes, surrounded by bytes that are not readable, is not submitted. A base64 payload is now read in PIECES — what decodes as text is protected, what does not is handed back byte for byte — which is what closed the aligned-prefix leak and the binary-header family. The floor is a COST decision, measured, not a judgement about what a value looks like: the decode of ordinary prose is dense noise, thousands of one-to-three byte fragments, and submitting them cost one detector call each — on ordinary text, so permanently, and protecting nothing. At three bytes the noise is turned away by the per-payload cap but a binary header over 64 bytes is turned away with it; at eight, noise qualifies just often enough to DOUBLE detector traffic; at sixteen, no noise qualifies and headers of any length stay read. Measured over three hundred one-kilobyte prose strings: 2.09 detector calls per string against 2.00 before.

This floor is not the minimum length this project condemned: that one was sixteen characters applied to the WHOLE string, so every encoded IPv4 travelled intact, and it DISABLED an existing protection. This one applies only to a fragment drowned in unreadable bytes — where nothing at all was read before this round. What it leaves is therefore the previous state, not a regression.

Beyond 1024 unreadable regions, the sweep stops reading and the rest goes through verbatim. Without that bound a megabyte of prose cost TWO SECONDS — nothing stopped the restart scan before the end of a buffer in which no fragment would ever qualify. It leaves two kilobytes of binary header readable, well past what real formats write. Both bounds are pinned by tests that assert the residual, so they go red the day either moves, and by a cost test that reddens on the two-second regression rather than just above it.

A JWT that carries a detected value comes back broken. Its three parts are still not read one by one, but the concatenated decode now yields readable fragments, so a real value in the payload (iss, aud) no longer leaves in the clear. The price: the token is re-encoded as a single base64 run and loses its dots, so it no longer validates. That is the project's arbitration — a call that fails is VISIBLE, a value that leaves is silent — and it applies only to a token that carried a detected value: otherwise the round trip is the identity and the token passes through untouched. Both halves are pinned.

A date-shaped string that is not a valid date stays verbatim. 2020-02-30, common in an export, is not parsed, so it is carried through as ordinary text inside a larger value, like any word the module preserves around a date. Its year is real. Closing it would mean substituting anything with the SHAPE of a date, which would take v3.14.2020 above with it.

Two keys that denote the same entity abort the exchange, loudly. If a JSON object carries Alice.Dupont@acme.internal and alice.dupont@acme.internal as two distinct keys, canonicalisation gives them one surrogate — correctly, since they are one address — and there is then no way to render both. The two real options are to refuse noisily or to drop one silently; the second is the exact failure this guard was added to close. Merging the values, or tagging the collision, would change the schema the server expects and let the exchange continue while the tool receives something it cannot read.

A base64 payload that decodes as UTF-8 is treated as text, whatever it is declared to be — and nothing is layered on top of that decision. A guard that asked "does this look like text?" was tried and removed: it refused on a NUL byte, so the sender only had to slip one in to switch the substitution off, and nothing counted it. Residual, stated: a binary made only of bytes that form valid UTF-8 gets traversed, so it may come out modified. That failure is visible; the other one is not.

Two places have no deadline, on purpose. An inspected exchange gives up on an upstream that stops sending — a silence is not a truncation: it produces no error, no close, no byte, and the agent waits for a response that never comes. The deadline is an inactivity one (a slow body that keeps arriving is licit, and cutting it would read as an upstream failure).

It does not apply to a tunnelled destination, where a silence and a long-lived stream are indistinguishable — that is what inspection buys: it knows what it is waiting for. Nor to a client holding its connection open between two turns, where no upstream socket is held and closing would cost a handshake per pause.

What an inactivity deadline cannot catch, by construction: an upstream that sends one byte just under the deadline holds an exchange indefinitely. A total ceiling would catch it and would also cut a large body arriving slowly — the twin defect, and the reason the deadline is written this way. It stays bounded by what an attacker needs first: the destination must be declared inspectable, and every request is one the agent itself made. Likewise, a response that stays silent for over two minutes is given up on even if it would have arrived — on an inspected destination the whole body is buffered anyway, so a genuine stream is already refused there for a different reason.

Operational

  • Policy files written before 2026-08-12 are no longer read. Their names came from substituting characters (:-, /_), which cannot be injective: distinct scopes wrote the same file, so a reveal decision taken in one applied to another. Names now carry a fingerprint of the exact scope. A rule that is no longer found falls back to anonymise, never to reveal — the safe direction — but reveal decisions already taken must be made again.
  • The detector does not reload its lists. Change config/ and restart it, or you will debug a fix that is not loaded — that has produced three false diagnostics in one day.
  • The vault and the master secret live in the same directory. One wrong move takes both, and then nothing already sent can be restored.
  • No KMS envelope encryption, no key rotation, no immutable access log, no enumeration protection. Out of MVP scope, and named.

What has been proven

Claim Proof
zero real values leave on channel 1 a real Claude Code session captured through mitmproxy: 0 real values across 427 KB, restoration 3/3
a forbidden command is stopped before execution traced, with the reason quoted back by the model
injectivity and determinism 10 000 values, 0 collisions, byte-for-byte reproducible
detection latency P95 100.6 ms against a 150 ms budget
the GPL boundary a test that fails on any import crossing it

None of those prove the absence of a leak in general. They prove specific claims, which is the most any of them can do.