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:
| Type | Placeholder tag | Example of what it catches |
|---|---|---|
EMAIL_ADDRESS | EMAIL | john.doe@acme.example |
PHONE_NUMBER | PHONE | +1 512 555 0117 |
CREDIT_CARD | CARD | A payment card number, checksum-validated |
IBAN_CODE | IBAN | GB29NWBK60161331926819 |
US_SSN | SSN | A US social security number |
PERSON | PERSON | John Doe |
LOCATION | LOCATION | Austin, Texas |
IP_ADDRESS | IP | 203.0.113.42 |
URL | URL | https://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}>
TAGis the short tag from the table above -EMAIL, notEMAIL_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.Nis 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:
| Type | Normalisation |
|---|---|
| lower-cased | |
| Phone, card | digits only |
| Everything else | trimmed 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.
| Value | The agent's reply | When 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. |
never | The 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
- curl
- Python
- platformctl
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.
import os
import httpx
API = os.environ["CAI_API"]
PROJECT = os.environ["CAI_PROJECT"]
AUTH = {"authorization": f"Bearer {os.environ['CAI_TOKEN']}"}
PROJECT_BASE = f"{API}/v1/projects/{PROJECT}"
BASE = f"{PROJECT_BASE}/guard"
SESSION = "s-9f2"
def guard_turn(message: str) -> str:
with httpx.Client(headers=AUTH, timeout=10.0) as http:
# 1. Mask on the way in. Keep the reference: it is not stored anywhere
# and cannot be reissued.
incoming = http.post(
f"{BASE}/input",
json={
"text": message,
"direction": "input",
"context": {"agent": "research-buddy", "session_id": SESSION, "request_id": "r-1"},
},
).json()
if incoming["decision"] == "block":
# text is null on a block; show the refusal, not an error.
return "I'm sorry - my security policy doesn't allow me to respond to that request."
mapping_ref = incoming.get("mapping_ref") or None
reply = my_model(incoming["text"]) # the model only ever sees placeholders
# 2. Check the reply and restore in one pass by sending the reference back.
outgoing = http.post(
f"{BASE}/output",
json={
"text": reply,
"direction": "output",
"context": {"agent": "research-buddy", "session_id": SESSION, "request_id": "r-1"},
"mapping_ref": mapping_ref,
},
).json()
if outgoing["decision"] == "block":
return "I'm sorry - my security policy doesn't allow me to respond to that request."
return outgoing["text"]
Pass the same session_id on both calls. The reference is bound to it; a mismatch is refused rather than partly applied.
platformctl guard scan --agent research-buddy \
--text "Email john.doe@acme.example the invoice."
decision mask
reason Personal data was replaced with placeholders before the agent saw it.
checks reached semantic
policy agent:research-buddy, standard, enforce, version 3
took 258ms
verdict gv_01J9X1M4S6ZQ8K3T
CHECK TIER DECISION TOOK DETAIL
pii pattern mask 34ms EMAIL_ADDRESS x1
text to use next:
Email <PII_EMAIL_1> the invoice.
mapping reference - keep it to restore the real values later ('guard unmask'),
it is not stored anywhere and cannot be reissued:
pm1.cDdmMmFiOTE.k4Zt3QqX9r2mB1wY...
platformctl guard unmask \
--mapping-ref 'pm1.cDdmMmFiOTE.k4Zt3QqX9r2mB1wY...' \
--text "I have emailed <PII_EMAIL_1> about the invoice."
I have emailed john.doe@acme.example about the invoice.
restored 1 value(s)
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:
403with codemapping_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.enabledon, 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
blockor aflagcarries 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
restoreisresponse- 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.