Skip to main content

Personal data masking

Masking is a rewrite, not a refusal. Personal data in an incoming message is replaced with a placeholder, the agent and the model work on the rewritten text, and the real values are put back into the reply on the way out. From the end user's side nothing looks different. From the model's side the address was never there.

That is the default under standard and strict, and it is the default on purpose: refusing a request because the person typed their own phone number is a worse product than quietly not showing it to the model.

Entity types​

Nine types are detected by default:

TypePlaceholder tagExample of what it catches
EMAIL_ADDRESSEMAILjohn.doe@acme.example
PHONE_NUMBERPHONE+1 512 555 0117
CREDIT_CARDCARDA payment card number, checksum-validated
IBAN_CODEIBANGB29NWBK60161331926819
US_SSNSSNA US social security number
PERSONPERSONJohn Doe
LOCATIONLOCATIONAustin, Texas
IP_ADDRESSIP203.0.113.42
URLURLhttps://acme.example/invoices/4471

Change the list with entities. Anything you remove is not looked for at all:

# Financial data only - no names, no locations, no URLs.
platformctl agents security set billing-bot \
--pii-entities EMAIL_ADDRESS,CREDIT_CARD,IBAN_CODE,US_SSN

There is a second, different list. allow_entities names types that are detected and deliberately left alone, which is how "mask everything except names" is written:

cat > leave-names-alone.json <<'JSON'
{
"input": { "pii": { "allow_entities": ["PERSON"] } },
"output": { "pii": { "allow_entities": ["PERSON"] } }
}
JSON

platformctl agents security set support-desk --policy-file leave-names-alone.json

The difference matters. A type removed from entities is invisible - it will not appear in a verdict's entity counts and you will never know how often it occurs. A type in allow_entities is still found and still counted; it just is not rewritten. Use allow_entities when you want the visibility without the rewrite.

threshold (default 0.6, range 0–1) is the detection confidence below which a candidate is ignored. Names and locations are the types where this matters: lowering it catches more unusual names and more ordinary words mistaken for them.

The placeholder format​

<PII_{TAG}_{N}>
  • TAG is the short tag from the table above - EMAIL, not EMAIL_ADDRESS. Short because the placeholder is text a model reads and copies around a prompt, and every extra token is another chance for it to get helpfully reformatted.
  • N is a 1-based ordinal per type, counted within the session.

So a message mentioning two addresses and a phone number becomes:

Forward this to john.doe@acme.example and cc jane@acme.example, or call +1 512 555 0117.
Forward this to <PII_EMAIL_1> and cc <PII_EMAIL_2>, or call <PII_PHONE_1>.

If your policy names an entity type outside the nine above, the tag is derived from it deterministically - so it still gets a stable placeholder rather than being left unmasked.

Ordinals are stable for the whole session​

<PII_EMAIL_1> means the same address on turn 9 as it did on turn 1. Without that, an agent summarising a five-turn conversation would produce a reply in which one person appears to be three different people.

Stability survives text that is not spelled identically, because values are normalised before the ordinal is looked up:

TypeNormalisation
Emaillower-cased
Phone, carddigits only
Everything elsetrimmed and case-folded

So John.Doe@Acme.example in turn 1 and john.doe@acme.example in turn 4 are one person and get one placeholder. (512) 555-0117 and +15125550117 are one phone number.

It also survives a restart of the agent: the mapping lives beside the session, with the session's own lifetime, so a version rolling out mid-conversation does not renumber anybody.

Restore, or keep the placeholders​

restore decides what the person reading the reply sees.

ValueThe agent's replyWhen to use it
response (default)Placeholders are swapped back for the real values before the reply leaves. The user sees john.doe@acme.example.Almost always. The model never saw the address; the human always needed it.
neverThe reply keeps <PII_EMAIL_1>.When the reply is going somewhere that must not hold personal data - a log, a data warehouse, a third party, a training set.
platformctl agents security set analytics-bot --pii-restore never

Restore replaces only placeholders the platform actually minted for this session. A reply that happens to contain a placeholder-shaped string of its own is left exactly as it is and counted as unknown - so the agent's text is never corrupted by a coincidence.

The mapping reference, for API callers​

An agent gets restore for free: the harness keeps the mapping with the session and applies it. If you call the standalone API yourself, you hold the mapping instead, and it arrives as one opaque string.

mapping_ref: "pm1.cDdmMmFiOTE.k4Zt3QqX9r2mB1wY..."

It is a sealed envelope, encrypted to your project. Nothing in it is readable by you, by us, or by anyone who intercepts it without your project's key.

Its lifecycle, across two calls​

Call one - mask the incoming text. You get back the rewritten text and a fresh reference.

curl -s -X POST "$CAI_API/v1/projects/$CAI_PROJECT/guard/input" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'content-type: application/json' \
-d '{
"text": "Email john.doe@acme.example the invoice.",
"direction": "input",
"context": { "agent": "research-buddy", "session_id": "s-9f2", "request_id": "r-1" }
}'
{
"id": "gv_01J9X1M4S6ZQ8K3T",
"request_id": "r-1",
"direction": "input",
"decision": "mask",
"reason": "Personal data was replaced with placeholders before the agent saw it.",
"risk": { "score": 0.0, "severity": "safe", "categories": [] },
"tier_reached": "semantic",
"short_circuited": false,
"scanners": [
{ "name": "pii", "tier": "pattern", "decision": "mask",
"entities": [{ "type": "EMAIL_ADDRESS", "count": 1 }], "latency_ms": 34, "error": null }
],
"text": "Email <PII_EMAIL_1> the invoice.",
"mapping_ref": "pm1.cDdmMmFiOTE.k4Zt3QqX9r2mB1wY...",
"policy": { "source": "agent:research-buddy", "version": 3, "preset": "standard", "mode": "enforce" },
"latency_ms": 258,
"created_at": "2026-09-16T10:00:00Z"
}

Call two - check the reply and restore it in the same pass. Send the reference back:

curl -s -X POST "$CAI_API/v1/projects/$CAI_PROJECT/guard/output" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'content-type: application/json' \
-d '{
"text": "I have emailed <PII_EMAIL_1> about the invoice.",
"direction": "output",
"context": { "agent": "research-buddy", "session_id": "s-9f2", "request_id": "r-1" },
"mapping_ref": "pm1.cDdmMmFiOTE.k4Zt3QqX9r2mB1wY..."
}'
{
"id": "gv_01J9X1M4S6ZQ8K3V",
"request_id": "r-1",
"direction": "output",
"decision": "allow",
"reason": "No security checks fired.",
"risk": { "score": 0.0, "severity": "safe", "categories": [] },
"tier_reached": "pattern",
"short_circuited": false,
"scanners": [],
"text": "I have emailed john.doe@acme.example about the invoice.",
"restore": { "restored": 1, "unknown": 0 },
"policy": { "source": "agent:research-buddy", "version": 3, "preset": "standard", "mode": "enforce" },
"latency_ms": 41,
"created_at": "2026-09-16T10:00:02Z"
}

restore appears on a verdict only when something was restored or something could not be. Its absence means there were no placeholders in the text.

If you only want the restore and no checks, use /guard/unmask:

curl -s -X POST "$CAI_API/v1/projects/$CAI_PROJECT/guard/unmask" \
-H "Authorization: Bearer $CAI_TOKEN" -H 'content-type: application/json' \
-d '{
"text": "I have emailed <PII_EMAIL_1> about the invoice.",
"mapping_ref": "pm1.cDdmMmFiOTE.k4Zt3QqX9r2mB1wY...",
"context": { "session_id": "s-9f2" }
}'
{
"text": "I have emailed john.doe@acme.example about the invoice.",
"restore": { "restored": 1, "unknown": 0 },
"request_id": "01J9Z6M3XK7QW2H4"
}

Send the same session_id the masking call used. The reference is sealed to it, and a mismatch is refused outright rather than partly applied.

Rules the reference obeys​

  • Keep it. The real values are stored nowhere on the platform. A reference you lose cannot be reissued, and the masked text is then permanently masked.
  • It is bound to the session. A reference presented with a different session is refused.
  • It expires. A reference past its expiry is refused. It is sized for a conversation, not for a week; mint a new one by masking again.
  • It is bound to the project. A reference from another project cannot be opened - not by policy, by construction: it is encrypted with a key derived for that project and simply fails to decrypt.
  • Any of those failures is one answer: 403 with code mapping_invalid.
{
"error": "that mapping reference cannot be used here: it was issued for a different session, or it has expired. Mask the text again to get a new one.",
"code": "mapping_invalid",
"request_id": "01J9Z6M3XK7QW2H4"
}
  • Unknown placeholders are left alone and counted. The unknown count on the response says how many. A non-zero count means the reply carries placeholders this reference knows nothing about - masked in a different session, or past the cap below - and those will reach your end user looking like a bug in the agent, so it is worth alerting on.

The per-session cap​

A session may hold 200 distinct values. Past that, new values are masked to <PII_{TAG}_OTHER>, which is not restorable, and the verdict says so:

Personal data was replaced with placeholders; this session reached its limit, so later values cannot be restored.

The cap exists because the reference travels in every request and response of the conversation, and an unbounded one would grow until the conversation stopped working. 200 distinct people, addresses and numbers in one conversation is a batch job wearing a chat interface; if that is genuinely your workload, use the standalone API with one session per document rather than one session per run.

Watch for it in a verdict's reason, or in the unknown count on a restore coming back non-zero.

Exactly what never sees the real value​

This is the part worth being precise about, because "it is masked" is a claim about a chain, and a chain is only as good as its weakest link.

Never sees the real value:

  • Your agent's code. The text handed to your framework is the masked text.
  • The model. Whatever chat model your agent calls receives the masked text. So does the safety model that performs the semantic judgement.
  • Your tools. With tools.enabled on, tool arguments are masked before the tool runs.
  • The verdict record. Verdicts store counts per entity type and nothing else: no text, no value. [{"type":"EMAIL_ADDRESS","count":1}] is the whole of what is kept.
  • The audit log. An audit entry for a block or a flag carries the verdict id, the tier, the categories and the check name. No content.
  • Platform logs. Log lines carry a text length and a short hash of the text, never the text.

Does see the real value:

  • The guardrail service, in memory, for the duration of the request. It has to: something must read the text to find the personal data in it. Nothing is written down.
  • The mapping reference. Encrypted, in your caller's hands, opened only by the service and only for your project.
  • The end user, in the reply, when restore is response - which is the point.

If you need the reply to hold no personal data either, set restore: never and the chain ends with the placeholder.

Next steps​

  • Policies - every field around these, and the tighten-only rule.
  • The standalone API - every route, with request and response bodies.
  • Verdicts and audit - what a verdict contains and what it deliberately does not.
  • Data protection - encryption at rest and in transit across the rest of the platform.