EGRESS DATA CONTROL

Stop agents leaking personal data and secrets to model providers

Detect on both legs, mask or tokenise before the payload leaves, and publish what the detector cannot see.

You stop an agent putting personal data and credentials into a provider payload by deciding it at the gateway rather than inside the agent: every piece of model-visible text is normalised to a fixpoint, eleven pattern-and-checksum detectors run over the normalised text, and a data-class policy either blocks the call or rewrites the payload before it is routed. Three of the eleven kinds are checksum-validated — Luhn for a card, mod-97 for an IBAN, mod-11 for an NHS number — and four of them are credential shapes that are masked irreversibly on the way back whether or not any rule asked for it. Redaction has two modes: masking replaces the value with a marker naming the kind, and tokenising replaces it with a placeholder that is stable for that value across one governed call, so the model can still tell one customer from another without receiving either. Where a rewrite would change the meaning of the request rather than the sensitivity of it — a value sitting in a JSON object key, or text found by OCR inside an image — Token Observe refuses the call instead of editing it. The limit belongs in the same breath as the claim: this is regular expressions plus checksums, so a name, an address or a described medical condition in free text is not detected at all, and neither is an identifier format outside the shipped UK and US set.
Classes detected
Eleven, three checksum-validated: Luhn, IBAN mod-97, NHS mod-11
Two modes
Mask irreversibly, or tokenise to a placeholder stable within one call
Credentials
Four secret kinds masked on the response leg whether or not a rule asks
Where it is decided
Step 7 of the request path, before the payload is routed anywhere
What the detector cannot seeFree-text personal data — a name, an address, a described condition
On this page
before the fix

The regular expression inside the agent, and the scanner reading the wrong leg

The first attempt is almost always a redaction step inside the agent: a regular expression in a try block, run over the prompt before the SDK call. It works, in the sense that it catches card numbers in the demo, and it fails as a control for a structural reason rather than a technical one. It is enforced by the thing being governed, it changes whenever somebody redeploys, it exists in one of your seven agents because one team wrote it, and nobody outside that team can say whether it ran. When the compliance question arrives, the honest answer is that a check happened somewhere in a codebase, which is not an answer.

The second attempt is the data-loss tooling you already own, pointed at the egress traffic. That gets you inspection outside the agent, which is real progress, and it leaves two specific gaps. It reads the request body as one document, so it does not distinguish the prompt a person typed from a tool result somebody outside your organisation wrote into a ticket — and the second of those is where the interesting data arrives. And it usually has no answer for the response leg at all, which is where a credential comes back out of a model that was shown one earlier in the conversation.

The third attempt is the one that gets switched off again. Somebody turns on masking across the estate on a Tuesday, and by Thursday an agent is producing subtly wrong output because the rewrite changed what the prompt said. The failure that motivated the current detector ordering is exactly this: an IBAN pattern whose match ended on the trailing separator swallowed the space after the number, and the prompt reached the model reading “<IBAN_1>for the payout”. Nothing errored. The model simply read a different sentence from the one the agent composed, and the trace recorded a successful call.

The fourth is subtler and is the one that survives longest undetected. A payload is scanned before it is normalised, so the scanner is reading a different document from the one the model will read. The Unicode tag block at U+E0000 to U+E007F encodes a complete invisible ASCII alphabet, and a value written in it is invisible to a reviewer, invisible to a naive pattern and perfectly legible to the model. Ordering the normalisation before the detection is not tidiness; it is the whole of that attack.

the procedure

How to actually do it

Each step is something you can go and do. Where a step depends on a decision somebody has to make rather than a setting somebody has to change, it says so.

  1. 01

    Route both legs of every agent through one endpoint

    Change the base URL and the credential on each agent so its model calls arrive at the gateway and its tool calls arrive at the tool endpoint. Until both legs are in one place, a data-class rule polices whichever half somebody remembered, and a tool result carrying a customer record is the half most often forgotten.
  2. 02

    Stage a data-class rule in shadow and read what it matches

    Create the rule with the kinds you care about and a direction — outbound is a prompt on its way to a provider or arguments on their way to a tool, inbound is a completion or a tool result coming back — and leave it in shadow mode. It is evaluated exactly as an enforcing rule and then skipped, and every match writes a decision event naming the policy, its mode and why it matched, so you learn the false-positive rate before it stops anyone’s work.
  3. 03

    Choose mask or tokenise per rule, deliberately

    Mask when the model has no legitimate use for the entity and you want the value irrecoverable. Tokenise when the agent has to keep customers, accounts and cards distinct across a conversation: the same value takes the same placeholder everywhere in one governed call, prompt and response alike. Credentials are the fixed exception and are always masked irreversibly, even under a tokenising rule.
  4. 04

    Promote the rule, and check the two refusals it can produce

    Switch the rule to enforce and then look for the calls that were refused rather than rewritten. A sensitive value inside a JSON object key is refused, because renaming a key changes which argument a tool receives; text found by OCR inside an image is refused, because a text redactor cannot reach pixels. Both are calls somebody has to change rather than incidents.
  5. 05

    Cover the response leg and the stream separately

    Data-class rules are evaluated again on the way back — after the full response on a buffered call, and before the first byte on a streamed one, because bytes already written cannot be recalled. Confirm on a streamed request that a value split across two chunks is still masked, and that the four credential kinds are masked whether or not your rule listed them.
  6. 06

    Write down the classes you are not covered for

    List the identifier formats your organisation actually handles and mark the ones outside the shipped detector set, then say in the same document that free-text personal data is not detected. A team that believes redaction is complete coverage will design around a control that does not exist.

What the detectors actually match, and the confidence that comes with each

Detection is regular expressions plus checksums, run over text that has already been normalised. Eleven kinds ship, and they are ordered so that more specific detectors claim their span first and later ones skip anything overlapping — which is why a card number is not also reported as a phone number. Each kind carries a confidence, and the checksum-validated kinds carry the high ones, because a twelve-to-nineteen digit run that passes Luhn is a card in a way that a nine-digit run matching a shape is not a national insurance number.

Four of the eleven are credential shapes rather than personal data, and Token Observe treats them differently at every point in the pipeline. They are masked irreversibly even under a rule that asked for tokenising, because a reversible placeholder for a live credential is a credential leak with extra steps, and they are in the response-leg mask set whether or not a policy names them. A defect found and fixed during this build was precisely that boundary: enabling card-number redaction had replaced the secret kinds in the egress plan rather than adding to them, so switching on one control switched off another.

The scan itself is inline, synchronous and running on text an attacker may control, which makes its cost a security property rather than a performance note. A JavaScript runtime cannot interrupt a running regular expression, so one pattern that backtracks super-linearly stalls every other request sharing the process. Two such patterns were found in Token Observe’s own injection scanner and are published in its defect list rather than left out of it — the reported one at 121 milliseconds on a 200 KB input, and a worse one found by auditing the rest, the markdown exfiltration pattern, at 51 seconds on the same input. Both were rewritten to be linear, a scan cap of 65,536 characters was added, and adversarial 200 to 400 KB inputs were re-measured at under 4 milliseconds each. Those figures come from one measurement exercise on one machine rather than a published benchmark; read them as the shape of the problem.

That cap is a stated trade rather than a subtlety. Text past 65,536 characters is not scanned, on the reasoning that anything worth detecting has to be read by the model to have an effect and is therefore near the start. A payload that hides a value at character 70,000 is not detected, and the layers below — grants, approvals, and what the application does with the answer — are what stand between it and a consequence.

Checksum-validated
credit_card by Luhn, iban by mod-97, uk_nhs_number by mod-11. A shape that fails its checksum is not reported at all, which is what keeps a ten-digit order reference from being masked as a patient number.
Pattern-only personal data
us_ssn, uk_nino, email and phone. Lower confidence by design, so a policy can set its own threshold rather than inheriting somebody else’s judgement about a nine-digit number.
Secret kinds
jwt, aws_access_key, api_key and private_key. Prefixed vendor key shapes are enumerated explicitly rather than caught by an entropy heuristic, because a named prefix has a near-zero false-positive rate and entropy does not.
Not detected
Names, addresses, free-text health or financial descriptions, and any national identifier outside the shipped UK and US shapes. This is a compensating control rather than a complete data-loss prevention layer, and the product’s own source says so in those words.

Mask, tokenise, and the two rewrites Token Observe will not make

Masking replaces the matched value with a marker naming the kind it was, and it is irreversible: nothing in Token Observe stores the original, so a masked prompt cannot be reconstructed from the record. Tokenising replaces it with a stable placeholder — the same value takes the same placeholder everywhere within one governed call, the outbound payload and the response leg sharing a single map — so an agent reasoning about three customers and two cards keeps them distinct without ever being given any of them.

The map is built per call rather than stored, which has a consequence worth understanding before you rely on it. Numbering is assigned by order of first appearance within the payload, and because a chat request carries the whole prior transcript with it, placeholders stay coherent across a conversation as the client replays it. Nothing is remembered between calls, so two independent requests about the same customer will not necessarily agree on the number. That is a deliberate trade against holding a persistent mapping table of every sensitive value your organisation has ever sent, which would be a far more attractive target than the thing it protects.

Two rewrites are refused rather than performed, and both refusals exist because the alternative is a silent change to meaning. A sensitive value sitting in a JSON object key is refused, because a key is an executable contract identifier and renaming it changes which argument the tool receives. Text found by OCR inside an image is refused, because a text redactor cannot reach pixels and declaring it masked would be a claim about something never touched. On the tool path this is unconditional for credentials: since the four secret kinds are in the plan whether or not a policy asked, a credential in a key is always a refusal rather than a repair.

The redaction event that lands on the trace carries the direction, the mode, the kinds hit, the counts and how many placeholders were issued. It never carries the matched value. Recording the value would move the leak from the provider into the flight recorder, which is the one store where it must not land — and the refusal paths on the tool gateway used to record raw arguments, so a call blocked for containing a secret wrote that secret into the trace store and its full-text index. Arguments on a refused call are now masked irreversibly before anything is written, because there is no conversation to keep coherent for a call that never ran.

The response leg, the stream, and the excerpt you keep afterwards

The return leg is governed as well as the outbound one, and only the data-shaped rules run there — data class and injection. Re-running the whole set would fire a rate rule or an approval requirement a second time for one logical call, including an approval that had just been satisfied. On a buffered response the evaluation happens over the complete text. On a stream the plan has to be resolved before the first byte, because bytes already written cannot be recalled, and the hold-back buffer has to be deep enough that a value split across two chunks cannot escape half-masked.

The bound that makes streaming honest is a declared maximum for an unbroken value: 4,096 characters. Two of the detectors — the JSON web token and the prefixed key shape — state no maximum length in their patterns, so a scanner asked never to split a run would otherwise buffer a base64 blob without limit. A run longer than the bound is replaced wholesale and suppressed to its delimiter: Token Observe stops claiming to identify the exact kind past that point, and it never emits either half of the ambiguous value. A buffered scan sees the whole text at once and needs no such fallback.

What the flight recorder keeps afterwards is the part most reviews miss, and it follows the same rule in the same order. The prompt is stored as a post-redaction excerpt capped at 4,000 characters, read back off the outbound payload rather than off the original, so the search index cannot contain what the redactor has just removed. The model’s answer text is not stored at all — the response event records the stop reason, the upstream request id, the token counts split by cache bucket, the number of placeholders issued and the types of the content blocks. A trace is therefore evidence of what was decided and what it cost, and not a transcript you could replay.

One payload-adjacent exception is worth knowing before somebody finds it. Where a policy requires approval for a tool call the model proposed, the approval record’s summary is the tool name plus up to 160 characters of that proposal’s arguments, built before egress redaction has run on the response. So an approval record and an approval webhook can carry that much unredacted model-generated text, and both should be treated at the sensitivity of trace content. Approvals raised on the request path and on the tool gateway carry no arguments at all.

Sanitise first, always
The Unicode tag block, zero-width characters, bidirectional overrides and isolates, the soft hyphen, the invisible mathematical operators, the supplementary private-use planes and orphaned surrogate halves, looped to a fixpoint up to four times. The zero-width joiner is deliberately kept, because emoji sequences need it.
Detection is not the last line
A value the detector missed is bounded by what the receiving agent may do with it — its grants, its approvals and its tool scope — rather than by the detector. Design accordingly, because the detector has a false-negative rate by construction.
Redaction does not reach your application
Masking on the response leg governs what leaves and what returns. Once the calling application renders that text into a page or passes it to a shell, the failure is in the application, and no gateway control reaches it.
the bit that remains

What this still does not solve

Doing everything above leaves a residue. It is smaller than what you started with and it is not nothing, and knowing its shape in advance is the difference between a control you trust correctly and one you trust too much.

  • Free-text personal data is not detected. A name, a postal address, a described medical condition or an account narrative in prose matches no pattern and passes through, and so does any national identifier format outside the shipped UK and US set. Adding one is a code change rather than a setting.
  • What your application does with the returned text is outside the boundary. Output masking governs the response on its way back; the moment your own code renders it into HTML or passes it to a shell, that is improper output handling in your application and no gateway control reaches it.
  • An agent that calls a provider directly is not redacted at all. Nothing here inspects a payload that never arrives, which makes coverage a discovery question before it is a redaction question — and a coverage claim with no denominator is not a claim.
  • The prompt excerpt kept on the trace is post-redaction, which means anything the detector missed on the way out is now also in your evidence store. Trace retention is unset by default and unset means keep forever, so that copy persists until you set a window.

If one of those residues is the thing that actually worries you, that is the conversation worth having rather than the one about the steps above it.

Talk it through

What personal data classes does Token Observe actually detect?

Eleven kinds, by regular expression plus checksum where a checksum exists: credit card by Luhn, IBAN by mod-97, NHS number by mod-11, plus US social security numbers, UK national insurance numbers, email addresses and phone numbers by pattern, and four credential shapes — JSON web tokens, AWS access keys, prefixed vendor API keys and PEM private-key headers. Checksum-validated kinds carry high confidence and pattern-only kinds carry lower confidence, so a policy can set its own threshold. Free-text personal data is not detected, and neither is any identifier format outside those shipped shapes.

What is the difference between masking and tokenising?

Masking replaces the value with an irreversible marker naming the kind it was. Tokenising replaces it with a stable placeholder that stays the same for that value everywhere in one governed call — the outbound payload and the response leg share one map — so a model can still treat a customer, an account and a card as distinct entities without ever receiving any of them. The map is per call rather than persisted, so coherence across a conversation comes from the transcript being resent rather than from anything Token Observe remembers. Credentials are always masked irreversibly, even under a tokenising rule.

Does redaction slow the request down or break the prompt?

It runs inline, and the cost is bounded rather than assumed: sanitisation loops to a fixpoint up to four times, scanning is capped at 65,536 characters, and every pattern is required to be linear in the length of its input after two super-linear ones were found and rewritten. Breaking the prompt is the failure that shaped the detectors: an IBAN match that ended on its trailing separator once swallowed the following space, so the model read a different sentence from the one the agent wrote. Where a rewrite would change meaning rather than sensitivity — a value in a JSON key, or text inside an image — the call is refused instead of edited.

Does this cover data coming back from the model, and from tools?

Yes, on both, with one narrowing. Data-class rules are re-evaluated on the response leg — after the full response on a buffered call and before the first byte on a stream — and tool results returning through the tool gateway are sanitised, scanned as tool results and evaluated against data-class and injection rules only, so a rate rule or a satisfied approval does not fire twice for one logical call. The four credential kinds are masked on the way back whether or not a policy asks. On a stream, an unbroken value longer than 4,096 characters is replaced wholesale rather than split.

Is this enough to satisfy a data protection officer?

It is a compensating control and should be described as one. What it gives you is an enforcement point outside the agent, a record of the kinds found and the counts on every governed call with the values never stored, and a refusal rather than a silent rewrite where masking cannot be done honestly. What it does not give you is complete coverage of personal data, because the detection is pattern-and-checksum based and free-text personal data matches nothing. State the detected set, state the gap beside it, and decide retention deliberately — trace retention is unset by default and unset means keep forever.

get in touch

Describe the version of this you actually have.

The steps above are the general shape. Which of them matter, and in what order, depends on what your agents do and which of them worries you — say that and you will get a straight answer, including when the answer is that Token Observe is not what you need for it.

no form · no qualification step · no sales desk · the other three ways in