PII Protector
PII Protector is a Tier 2 (sidecar HTTP call, milliseconds) guardrail that provides reversible PII tokenisation. It detects PII in the request body using a locally hosted NLP engine running within Myra's certified infrastructure, replaces each detected value with an opaque token, forwards the tokenised request to the AI provider, and restores the original values in the response before it reaches the client. When a request egresses to an external AI provider the model never processes real PII, and the engine never transmits data outside the Myra perimeter. A request that routes wholly to a first-party local (Myra/EU) model is served unmasked — the data never leaves Myra, so masking is skipped (see Local model legs are not masked below).
PII Protector editor
When to use PII Protector
Use PII Protector when the AI model needs contextual continuity — the ability to reference names, addresses, or other PII naturally — but the real values must not reach the provider. For permanent, irreversible scrubbing without restoration, use the NLP PII Detector with action: "scrub" instead.
How it works
- Request phase — The NLP PII detection engine scans the request body for PII spans. Each unique value is replaced with a token in the format
[MYRA-REDACT-{TYPE}:SALT:N], whereTYPEis the detected entity type (e.g.EMAIL_ADDRESS,US_SSN),SALTis a conversation-stable prefix, andNis a content-derived hex identifier. The same original value always maps to the same token within a single request. - Provider call — The upstream AI provider receives only the tokenised body. Real PII values are never transmitted.
- Response phase — All tokens present in the response are replaced with their original values before the response is sent to the client.
⭐ Example: A prompt containing
"My SSN is 123-45-6789"is forwarded to the provider as"My SSN is [MYRA-REDACT-US_SSN:a3f1c2:9b1d4f7a2c3e]". If the model echoes the token back, the client receives the response with123-45-6789restored.
The detection engine identifies the language of each request and applies the appropriate NLP model, so no language field is required for detection to work. English and German are fully supported; other Latin-script languages are handled on a best-effort basis. language is nonetheless not inert: it is the calibration control, and it decides the PERSON / LOCATION confidence floor. Leaving it absent is the fail-safe choice — it applies the German 0.6 floor for names and addresses. Setting it to "en" (or any other non-German locale) raises that floor to 0.9, which is correct for English text but means German names and cities scoring 0.6–0.9 — a plain "Klaus Müller" ≈ 0.79, a city ≈ 0.71 — are no longer masked. A request that ran without the floor for this reason records the pii_floor_off_by_language marker in its request log, and the Guardrail Builder asks for confirmation before applying the change. Under a PII mandate the floor is applied whatever language says, so on a mandated request the choice affects nothing but the analyzer's own model selection.
💡 Note: PII Protector does not have an
actionfield — it always tokenises rather than blocking or flagging. The same is true of the Custom PII Detector. Because the field was previously accepted and stored without ever being read, the API now enforces the truth in both directions:
- Writes are rejected when they INTRODUCE or CHANGE such a value.
POST /gateways(which is an upsert),PATCH /gateways/{id}, and the guardrail-config import and dry-run all return400for apii_protectororcustom_piidetector whoseactionis anything other than"scrub", naming the detector and the conflict. Omitting the field — the normal shape — and the explicit"scrub"are always accepted.- A value already stored is carried through, not rejected. The rule is about what a write changes: a configuration saved before this rule that carries
"action": "block"keeps working, and a later save that leaves that detector alone still succeeds — otherwise the whole Guardrails section of that gateway would become unsaveable, since there is no control that could remove the field. Changing it to another contradicting value is a new claim, and is refused. Editing the detector list in the admin console's Guardrails section drops the inert field on save, so a configuration touched there cleans itself up, and the guardrail-config export strips it too (so an exported file imports into any gateway, not only the one it came from). A save from any other surface carries the stored value through unchanged.- Reads report what the detector does, not what was stored.
GET /gateways/{id}/detectorsreports theactionof these two types as"scrub"regardless of what is stored. Previously the stored value was echoed back verbatim, so the API stated that a gateway had a blocking PII guard when it had a masking one.It does take a
targetfield, and on the non-streaming (buffered) path you must set"target": "both". Tokenisation runs on the request phase and restoration runs on the response phase, so both phases must be in scope. With the orchestrator's defaulttargetof"request", the request is tokenised but the response is never restored — the client then receives raw tokens. (Streaming restore is handled separately; the buffered path needs"target": "both".)
Configuration reference
| Field | Type | Default | Description |
|---|---|---|---|
type |
string | — | Must be "pii_protector" |
name |
string | — | Human-readable label for this guardrail instance |
target |
string | "request" |
Set to "both" so the request is tokenised and the response is restored. The default of "request" tokenises the request but leaves response tokens un-restored. |
entities |
array | null | null |
Entity types to tokenise; null tokenises all supported entity types |
score_threshold |
number | 0.7 |
Minimum confidence score for a detection to count (0.0–1.0) |
entity_score_thresholds |
object | null | null |
Per-entity confidence overrides (e.g. { "PERSON": 0.85 }) that take precedence over score_threshold for the named entity. |
de_recognizers |
boolean | false |
When true, enables the German structured-ID recognizers on this detector (gates the whole German-ID feature). |
language |
string | null | absent | The NLP calibration for this detector, and the language sent to the analyzer. It decides the PERSON / LOCATION confidence floor: absent, "", "auto", "de" or a de-/de_-prefixed locale ⇒ the German-calibrated 0.6; any other explicit locale ("en", "fr") ⇒ the English 0.9. See German names and addresses. Exposed in the Guardrail Builder as Language calibration on this card. The "" / non-string readings in this row describe what the runtime does with a value already stored; a write that introduces one is refused — see the accepted-shape note below. |
allow_list |
array | null | null |
Values that are never tokenised, regardless of confidence score |
allow_list_match |
string | "exact" |
How allow-list entries are matched: "exact" (the analyzer's default — omit the field to get it) or "regex". "partial" is not a value the analyzer serves: it returns HTTP 500, which the detector reports as an error and a fail_open: true detector then turns into an unmasked pass — measured against the live analyzer, deterministically, on every request. A write that introduces any other value is rejected with 400 (a stored one is grandfathered), and at runtime an unsupported value is treated as absent, which is the exact default: narrower allow-listing, i.e. more masking. The Guardrail Builder no longer renders a control for this field — its only non-default option was the broken one — so it is API-only. |
skip_system_messages |
boolean | true |
When true, only user-role messages are scanned; when false, system and assistant messages are also scanned. The non-message carriers prompt and input are scanned either way (they are the caller's own text); instructions — the Responses-API system prompt — is scanned only under the widened scope, alongside the native top-level system. Ignored (forced full-scope) on a tenant with pii_masking_enforced — see mandatory masking |
timeout_ms |
integer | 15000 |
Deprecated / no longer read on the analyze path. Message fields are analysed through the chunked scanner (see below), which sets its own per-chunk read timeout. Retained only as a defensive floor. Minimum 1000 ms, and a whole number — a value below that cannot complete a real call, so the detector becomes a guaranteed timeout and, on a fail_open detector, the request is forwarded unchecked. Values outside [1000, 120000], non-numeric values and an explicit null are rejected with 400; a value already stored on a detector is carried through unchanged. The runtime also clamps to [1000, 120000], so an out-of-range value that predates this rule is corrected rather than obeyed. |
fail_open |
boolean | true |
When true, sidecar errors allow the request to pass through without tokenisation; when false, they block it Overridden on a request under a PII mandate: a pii_mandatory project (or a tenant with masking enforced) refuses the request when this detector's sidecar is unavailable, even with fail_open: true — see When the masker is down. It is the one setting a mandate outranks — on the request and response phases and at the two tool seams: under a mandate an outage withholds the web-search query (the search is skipped) and withholds a tool result (placeholder), whatever this flag says; the row records pii_mandate_masker_unavailable. Outside a mandate the seams honour the flag: true forwards the query / the result with at most the offline German-ID masking applied — and records that forward as a gap (guardrail_degraded / pii_scan_degraded / guardrail_error); false withholds. A tool result bound for a wholly-local model leg is never a mandate case and its forward is not a gap. Accepted shape: a real boolean. A write that introduces null or any non-boolean is rejected with 400 on POST /tenants/{id}/gateways, PATCH /gateways/{id} and the guardrail-config import; a value already stored is carried through. A non-boolean is read as absent by every phase (its own default: proceed on request/response, withhold at the tool seams) while the card shows it as a real choice — so it is refused rather than stored |
⚠️ Accepted / rejected shape —
languageandallow_list_match; runtime coercion only forallow_list. A write that introduces or changeslanguageto anything other than a usable locale string —null, a number, a boolean, an object, whitespace only, or a string carrying a control character — is rejected with400, naming the detector; a value already stored is carried through unchanged so an existing configuration stays saveable. At runtime any non-string value — and any string carrying a control character, which is unusable rather than merely unrecognised — is treated exactly as an absent one and resolves to"auto", which is calibrated as German, so a malformed value masks more, never less. (A real but unknown locale such as"fr"is a deliberate choice and keeps its English calibration; it is passed to the analyzer verbatim.)allow_list_matchhas a write rule of its own: a write that introduces or changes it to anything other than"exact"or"regex"is rejected with400, naming the detector; a value already stored is carried through so the configuration stays saveable, and the Guardrail Builder drops it on the next save of the Guardrails section. At runtime an unsupported value is treated as absent, which is the analyzer'sexactdefault — narrower allow-listing, i.e. more masking.allow_listitself is not rejected at the write and is coerced at runtime only: a non-array, or one with no usable string element, is treated as absent, which allow-lists nothing — so a malformed value masks more. Forallow_list_matchthe rule is about the VALUE, not only the shape: anything other thanexactorregex— including the"partial"this field's editor used to offer — is treated as absent, because the analyzer answers500to it and that500fail-opens the detector. Absent is theexactdefault, i.e. narrower allow-listing.This is not cosmetic. Previously a stored
"language": null— JSONnulldecodes to a truthy value in the gateway runtime, so it survived the "or use the default" fallback — threw while the analyzer request was being built. The guardrail orchestrator recorded that as a detector error, and a detector shipping the defaultfail_open: truethen forwarded the request completely unmasked, with only a log line. A single null disabled the PII layer for every request on the gateway. On a gateway under a PII mandate the same value produced an outage instead of a leak — the turn was refused withpii_mandate_masker_unavailable— which is the correct direction, and the reason the leak went unnoticed on the tenants most likely to report it.
Large messages: chunked analysis and the scan budget
A single message can be large (a pasted document, an inline data file, a long
non-Latin body). Sending it to the analyzer in one call would exceed the sidecar's
read timeout under load and fail the whole turn closed. Instead, each field is split
into ~16 KB chunks analysed independently (30 s per chunk, up to 2 transient retries),
under one 90 s total wall-clock budget shared across the turn's fields. The chunks are
scanned concurrently (bounded fan-out), so a large document fits inside the budget instead
of summing serially past it. When the budget is exhausted, the scan fails per the field's
fail_open setting exactly as any other analyzer error (a fail_open: false gateway blocks; a
fail_open: true gateway forwards with whatever offline masking applied, flagged as a degraded
PII scan). A message field whose bytes are not valid UTF-8 is analysed in a single un-chunked call
(chunk offset arithmetic requires valid UTF-8). These bounds are configured by your operator.
Pre-scan size gating and the honest "document too large" message. Before spawning each batch
of concurrent chunks, the gateway projects whether the remaining chunks can finish before the
budget deadline — the first batch against a conservative optimistic floor, later batches against
this scan's own measured chunk latency. A scan that provably cannot complete in time (an
enormous document, or a normally-sized one against a degraded sidecar) is refused fail-closed
before it grinds the whole budget to the wall, and the refusal is surfaced as a distinct,
retryable outcome — "This document is too large to scan for personal data right now. Please try
again in a few moments." — rather than the generic "safety check temporarily unavailable". A
retry genuinely converges: the chunks that already scanned are cached (per-chunk, 1 h TTL), so a
re-upload measures faster batch times and fits. On a chat turn this is the guardrail_doc_too_large
policy bubble (with a Try again action); non-chat callers receive the typed
guardrail_doc_too_large 503, distinct from the generic
guardrail_unavailable. A healthy document against a healthy sidecar is never falsely refused —
a fast (or cache-hit) batch projects to a tiny remaining time and the scan proceeds normally.
Entity types and FP risk
PII Protector uses the same entity types as the NLP PII Detector. See that page for the full list of supported entity types and their benchmarked false-positive rates.
The 14 low-FP types are: EMAIL_ADDRESS, PHONE_NUMBER, US_SSN, CREDIT_CARD, US_BANK_NUMBER, IBAN_CODE, US_PASSPORT, PASSPORT, US_DRIVER_LICENSE, US_ITIN, CRYPTO, IP_ADDRESS, MEDICAL_LICENSE, URL.
PASSPORT covers passport numbers in any format or language via NER. US_PASSPORT covers only US-format numbers via regex. Both can be active simultaneously.
The named-entity types — ORG, PERSON, LOCATION, DATE_TIME — are useful for contextual continuity but produce more false positives on general text. The gateway automatically raises score_threshold to 0.85 for ORG and to 0.9 for DATE_TIME when they are included. PERSON and LOCATION use a 0.6 floor by default (the analyzer language defaults to German); their threshold is raised to 0.9 only when a non-German language is configured explicitly.
Token format
Tokens use the format [MYRA-REDACT-{TYPE}:SALT:N]:
| Component | Description |
|---|---|
TYPE |
Detected entity type (e.g. EMAIL_ADDRESS, US_SSN, PHONE_NUMBER, CREDIT_CARD) — letters, digits and _ only, at most 32 characters. Falls back to PII if the type is unavailable or the detection engine reports a label outside that shape (the label is untrusted input; anything else would break the token grammar the response-side restore relies on). |
SALT |
6-character hex prefix derived from the master key and the conversation identifier, so it is stable across every turn of a conversation. When there is no conversation (for example, a scrub preview or a raw compat call), the salt is derived per request instead. |
N |
12-character content-derived hex identifier, computed as a one-way digest of the original value and the salt. It is therefore identical across turns for the same value and is not a sequential counter. |
Including the entity type in the token lets the AI model respond semantically — for example, it can say "I'll contact you at your email address" rather than echoing the opaque token verbatim.
The same original value appearing multiple times in a single request always maps to the same token. On the response side, all occurrences of a given token are restored to the same original value.
Restore is one pass, one level. The response is restored in a single left-to-right sweep over the masked text: each token is looked up in the request's own token map and replaced once, and a restored value is never rescanned. A token that sits inside another token's value (which the gateway does not mint deliberately — it can only arise when a token from an earlier turn of the same conversation is pasted into a value that is then detected) is therefore rendered as a typed placeholder (for example [name]) rather than restored, deterministically. On the buffered response the Custom PII detector restores its own [MYRA-CUSTOM:…] tokens in a first pass and PII Protector restores everything in a second, so a PII Protector token inside a keyword's value is restored by that second pass. The restore cost is linear in the response size plus the number of masked values — never their product.
Deduplication and overlapping spans
Deduplication: when the same PII value appears multiple times in the request, all occurrences are replaced with the same token and all are restored identically in the response.
Overlapping spans: when the detection engine identifies overlapping entity spans (for example, a name inside an email address detected as both EMAIL and PERSON, or a numeric string matching both CREDIT_CARD and IBAN), the overlapping spans are merged and their entire combined range — the union — is masked as a single token. No character covered by any detected span is ever left unmasked, even where a lower-confidence span extends past a higher-confidence one. The token's label is taken from the highest-confidence span in the group.
Spans over a redaction token: a detected span never re-masks a redaction token this request
already produced — its own [MYRA-REDACT-…] from an earlier detector or the retry, or the
Custom PII detector's [MYRA-CUSTOM:…] (which runs first). The detection engine does tag token
text (the id part of a token can score as an e-mail address), and re-masking it would mint a
token whose value is another token, so the original value could never be restored. Every span is
therefore clipped to the prose around the request's own tokens before it is masked: a span
that straddles a token masks the text on either side of it as two tokens (clip-created
whitespace edges trimmed), a span that lies entirely on a token masks nothing, and a keyword the
Custom PII detector protected round-trips intact through a later PII Protector scan (the NLP
PII Detector's scrub action is governed separately). The protected set is
the request's own mask record, never the shape of the text — a caller-typed [MYRA-…] string is
ordinary content and is masked like any other (the same rule as the
German-ID exemption below). A partially scanned tool
result keeps a token whole even when the failed chunk's boundary fell inside it: the
placeholder covers the un-scanned prose, the token stays restorable.
fail_open behaviour
fail_open |
Sidecar unavailable |
|---|---|
true (default) |
Request passes through without tokenisation |
false |
Request is blocked — the chat shows the "Safety check temporarily unavailable" bubble with a Try again action; an attachment on the blocked request stays in that turn and is re-sent by Try again (see Guardrail blocks in the chat) |
PII in tool results (RAG, file search, web, fetched URLs)
When the gateway runs server-side tools, the text a tool returns — a knowledge-base
chunk, a file read, a fetched web page, a search result — is untrusted input and
can itself contain PII. Before that text is handed back to the model on the next step
of the turn, PII Protector scans it with the same NLP detection and reversible
tokenisation used on the request body: a name, address, or German structured ID that
appears only inside a fetched document is tokenised (and restored in the final answer)
exactly as if the user had typed it. No extra configuration is required — any enabled
pii_protector detector covers tool results automatically. Large documents are split
into chunks and scanned incrementally within one wall-clock budget (the total-scan
latency ceiling), so a big RAG result cannot time out the turn.
Per-chunk survival. A large tool result is scanned chunk by chunk. The chunks that
scan successfully are tokenised and kept; if a chunk cannot be scanned — a transient
engine error, or the budget runs out before that chunk is reached — only that chunk
is withheld in place, replaced by [content withheld: PII scan unavailable], while the
rest of the result survives. This means a large RAG turn no longer drops all of its
grounding when the PII engine is slow: it keeps as much retrieved content as the budget
and the engine can cover. Fail-closed is preserved per chunk — a withheld chunk's
bytes are dropped, never restored, so no un-scanned tool text reaches the model.
⚠️ Fail-closed by default. Unlike the request phase (which defaults to
fail_open: true), the tool-result scan fails closed: if the PII engine is unavailable while scanning, the affected content is withheld — a single failed chunk becomes[content withheld: PII scan unavailable], and a result whose every chunk fails (or a result carrying malformed UTF-8, where a chunk cannot be placed precisely) is withheld whole as[tool result withheld: PII scan unavailable]. Either way, no un-scanned tool text reaches the model. Setfail_open: trueon the detector to instead pass the tool result through on an engine outage (logged, and recorded on the request as a guardrail gap). Only do this when NLP-detected tool-result PII (names, addresses) is acceptable to leak under degradation — and note that under a PII mandate (apii_mandatoryproject, a tenant with masking enforced) the flag does not win at this seam: the result is withheld regardless, except on a wholly-local model leg where nothing leaves the estate.German structured IDs are still masked even under
fail_open. The German structured-ID recognizers (de_recognizers— Steuer-ID, USt-IdNr, KV-Nr, SV-Nr, Personalausweis) are checksum-based and have no dependency on the NLP engine, so they still run offline when the engine is down. A result that fails the engine scan is forwarded with those IDs masked (only the NLP-only PII the engine would have caught leaks). This holds whether a single chunk or the whole result failed.
fail_open |
PII engine unavailable while scanning a tool result |
|---|---|
false (default for tool results) |
Un-scannable chunks are withheld (placeholder); the scannable content survives; the turn proceeds |
true, no PII mandate |
The tool result is forwarded on outage (logged, and the request records guardrail_degraded / pii_scan_degraded / guardrail_error) — but German structured IDs (de_recognizers) are still masked offline; only NLP-detected PII (names/addresses) leaks |
true, under a PII mandate |
Withheld exactly as false (the mandate outranks the flag); the request records pii_mandate_masker_unavailable. A result bound for a wholly-local model leg is exempt (forwarded, not a gap) |
Diagnosing withholds on large-RAG turns. When a turn returns large results against a
slow PII engine, some content can be withheld. A whole-result withhold (every chunk of a
result failed) logs a cause; per-chunk withholds are counted separately. A one-line
[tool_result_scrub_summary] results=N scanned=S withheld=W chunks_withheld=C scan_ms=T
budget_ms=B is emitted per turn (scan_ms near budget_ms = the turn spent the whole
budget against a slow engine): withheld=W counts whole results dropped;
chunks_withheld=C counts individual chunks dropped from results that otherwise
survived.
cause |
Meaning | Durable fix |
|---|---|---|
analyzer_error |
This result's own PII-engine scan timed out / errored while budget remained — the engine is capacity-constrained on large payloads | Size the PII (Presidio) analyzer (workers/CPU, read timeout) — an ops change; no gateway budget can make a slow analyzer fast |
budget_exhausted |
The shared per-turn scan budget was already spent when this result was reached (an earlier detector on the same result, or earlier results, consumed it); for a presidio scrub detector, its own per-call analyzer budget ran out |
Increase the shared per-turn scan budget (a deployment setting), or (if one flaky result starved healthy ones) size the analyzer |
anonymizer_error |
A presidio scrub detector's analyzer answered but the /anonymize call failed — the result was withheld because a PII mandate is in force |
Size / restore the anonymizer sidecar; the analyzer half is healthy |
The per-turn budget bounds how long a turn will wait on the scan; it is a bound, not a cure for a chronically slow engine. Fail-closed is preserved throughout — withheld content is never emitted un-scanned regardless of cause.
💡 Note: On one tool result, detectors compose, mirroring the request phase: multiple
pii_protectordetectors each add their entity coverage (e.g. a German and an English detector — the second never re-masks the first's tokens; see spans over a redaction token), and an NLP PII Detector (action: "scrub") ordered before apii_protectordetector also composes — presidio anonymises what it catches, thenpii_protectortokenises the residual it missed (so one result can be both<EMAIL>and[MYRA-REDACT-PERSON]). The one forbidden combination is presidio-scrubrunning afterpii_protectoron the same result: anonymising over a[MYRA-REDACT-…]token would corrupt restoration, so a presidio-scrubdetector ordered after apii_protectordetector is skipped for any result already tokenised (its entities are not masked on that result — order presidio first, or rely onpii_protector's superset coverage).⚠️ Only NER-based detectors cover tool results. Tool-result scanning applies to
pii_protectorand the NLP PII Detector (action: "scrub") only. The Custom PII Blacklist (keyword masking) and the per-user personal-PII keyword detectors run on the request body but not on tool results — a registered keyword or codename inside a fetched/RAG document is not masked before it reaches the model. If keyword PII in tool results must be masked, raise it as a requirement (tracked follow-up).⚠️ Provider-native search is out of scope. This covers tool results the gateway fetches (gateway-mediated web search, URL fetch, file/RAG reads, MCP tools). A request that uses a provider's own server-side search (e.g. Anthropic
web_search, Mistral Conversations) is not tokenised: the result text originated at that provider and is re-injected to the same provider, so it is not a new egress the gateway can interpose on — and rewriting the provider'sserver_tool_use/tool_resultpairing would break the provider's own contract. If tool-result PII masking is a hard requirement, disable provider-native search so fetching is gateway-mediated (where it is scrubbed).
Manual unmask (Unmask on Demand)
The pre-send PII preview lets a user selectively unmask a specific detected value — sending it to the model in the clear despite detection — when the automatic detector was wrong (for example, a product name mistaken for a person). Enforcement is entirely server-side; the client only expresses intent.
Preview response. The preview endpoint returns, alongside the masked text, a spans
array of { token, type, value } for each [MYRA-REDACT-*] token, so the interface can show
the original value behind each pill and offer a per-value unmask toggle. (Custom-keyword
[MYRA-CUSTOM:…] tokens have no span and are not user-unmaskable.) The value is the
caller's own submitted draft — the endpoint makes no upstream call and never logs it.
Under enforced masking the preview returns no values. Whenever masking is mandatory for the
request — a project whose tier is pii_mandatory, a tenant with masking enforced, an
agent-area PII mandate, or a fail-closed indeterminate tier (the same pii_mandate_in_force
condition the send-time denial below uses) — the preview withholds the spans values
entirely — spans is empty and the raw values appear nowhere in the response, while
detected (the entity types and counts) still shows what was masked. The client is never
the authz boundary: because a manual unmask would be denied server-side at send (below), the
preview does not hand the caller material it could never use, so the raw value never reaches a
client that lacks the permission — even one that ignores the interface's locked pills.
Carrying the choice. The chosen values ride a gateway-private request body field (never a header — a header would silently drop oversized JSON and corrupt non-Latin-1 values). The same field also carries the manual mask list (see below):
{ "messages": [ ... ], "x-aig-pii-overrides": { "unmask": ["Acme Foobar"], "mask": ["Project Zeus"] } }
The gateway strips this field from the request body at the dispatch chokepoint, unconditionally on every gateway — regardless of whether PII protection is active — so it is never forwarded to any provider. (The field is only interpreted on a gateway that runs PII protection, and is only emitted by the interface there; a gateway with no PII detector does not interpret it — but it is stripped before egress either way, so a client cannot smuggle it through to the model.)
What is accepted / rejected (fail closed). The field is untrusted input, validated at the trust boundary — any deviation drops the override and the request is masked normally:
| Rule | Behaviour on violation |
|---|---|
Must be an object with unmask = array of strings |
Non-object / wrong shape → ignored |
| Each entry ≤ 512 bytes; at most 64 entries | Oversized / excess entries dropped |
| An unmask is honoured only for a value the gateway actually detected and tokenised this request | A forged / absent / mismatched value matches no token → stays masked |
| The value is spliced back exactly as tokenised | A different value can never be substituted |
Policy override is not possible. On a project whose tier is pii_mandatory,
manual unmask is denied outright — masking is enforced server-side and a per-request
field cannot weaken it. The denied attempt is logged and counted; no PII leaves masked.
Audit. Every honoured unmask writes one audit event (action: pii.unmask) recording the
entity types and count only — never the raw value. Masking telemetry is left intact,
so a fully-unmasked turn still reports PII as active to downstream delivery gates.
Manual mask (Mask on Demand)
The pre-send PII preview also lets a user select undetected sensitive text — a
project code name, an internal identifier the detectors do not recognise — and mask it
before the message leaves the gateway. The user selects the text in the preview and the
chosen substrings ride the same gateway-private body field, under mask:
Additive and fail-closed. A manual mask only ever adds redaction of the caller's own
draft text, so — unlike unmask — it is honoured on every tier, including pii_mandatory.
The selected spans are tokenised in the same single pass as automatic detection (they
become [MYRA-REDACT-MANUAL:…] tokens). Internally they carry the lowest span priority, so a
manual selection can never displace or reduce an automatic detection: if a mask value
overlaps a value the detector already found, the detection wins and stays fully masked. A
mask value not present in the message is a no-op.
What is accepted / rejected (fail closed). Same validator as unmask: mask must be an
array of strings, each ≤ 512 bytes, at most 64 entries; anything else is dropped and the
message is masked normally. A value appearing in both mask and unmask is treated as a
mask (the explicit protect wins). The field is stripped before the request reaches any
provider.
Telemetry, not audit. Manual masks surface in the masking telemetry (the MANUAL entity
type in the pii_masked event — a gateway-extension SSE frame, delivered only to a client
that opted into the side channel, see
Inference — what a plain client receives — and the X-AIG-PII-Active delivery gate) and are counted via
the pii_mask_manual metric. They are not audited — masking more of one's own text is not
a privileged action (unlike unmask, which is).
German structured IDs in spreadsheets and tables
Alongside the NLP engine, PII Protector runs gateway-side recognizers for German structured identifiers the NLP model scores too low to catch — Steuer-IdNr, USt-IdNr, Krankenversichertennummer, Sozialversicherungsnummer, and Personalausweisnummer — each validated by its official checksum. These are enabled per detector; names and email addresses remain the NLP engine's responsibility.
When an attached spreadsheet or table is analysed on a PII gateway, its extracted
text reaches the guardrail as delimiter-joined rows (tab, ;, ,, or |; one row per
line, first row the header). Because a bare 10–11-digit number passes a single-check-digit
ID checksum by coincidence about 10% of the time, redacting such numbers cell-by-cell used
to corrupt purely numeric columns (account numbers, counts, salaries) and produce wrong
metrics. PII Protector now classifies each column instead:
Accepted (redacted):
- A column whose header names a German ID (e.g. Steuer-ID, USt-IdNr, Versichertennummer,
Personalausweis) is redacted in full — every cell, including one whose value is a
typo or an odd format the checksum alone would miss (higher recall than per-cell matching).
- A column whose values are predominantly valid IDs (an unlabelled ID column) is
redacted in full.
- Any value bearing a letter (USt-IdNr, KV/SV numbers, letter-form Personalausweis) and
any ID embedded in free-text is always redacted, in any column.
Preserved (not redacted):
- A numeric non-ID column — one that is largely plain numbers with only occasional,
coincidental checksum matches — is left intact so the model can compute correct metrics.
Business-number headers (Personalnummer, Kontonummer, Mitarbeiter-Nr,
Auftragsnummer, amount/rate columns) are treated as non-ID and survive.
Residual and out of scope: - A genuine German ID sitting alone inside an otherwise-numeric, unlabelled column is indistinguishable from a coincidental match and is not redacted, so it can reach the model leg unmasked. This is a deliberate precision/recall trade-off accepted by the product owner: redacting every coincidental checksum match would corrupt legitimate numeric columns (account numbers, counts, salaries) and produce wrong metrics on every table. Give the column an ID header (above) to force full redaction. The exposure is limited to the model leg: tool, web-search, and MCP egress stay guarded — those paths run the free-text German-ID recognizer independently, so the same lone ID re-sent in a tool call or search query is still redacted. (Prose text — a Steuer-ID written in a sentence — is always redacted, unchanged.) - Tables from DOCX/PPTX (one cell per line) and space-aligned PDF tables are not column-detected; their content is scanned as prose (safe, but numeric columns there are not preserved).
What is exempt from the German-ID scan
Besides the column classification above, one other class of text is skipped: text that PII Protector has already masked on this request. A digit run that happens to sit inside a redaction token must not be masked a second time, or the value becomes unrecoverable when the answer is restored. The same rule governs the NER detections themselves — a detected span is clipped to the prose around the request's own tokens (see spans over a redaction token).
The exemption applies to the redaction tokens this request actually produced, and to nothing else. Text that merely looks like a redaction token is ordinary content and is scanned normally:
| Input | Scanned? |
|---|---|
A redaction token this request produced, e.g. [MYRA-REDACT-DE_STEUER_ID:…] |
No — already masked |
Text typed by the caller in the same shape, e.g. [MYRA- … ] |
Yes — it is ordinary content |
| A token from a different request, or a mangled/truncated one | Yes — it cannot be restored, so it is not protected |
This is why the exemption cannot be decided from the shape of the text: a caller controls what they type, so a shape-based rule would let anyone place a German ID beyond the scanner's reach. It is decided from the request's own mask record instead, which a caller cannot write to.
Comparison with NLP PII detector scrub
| PII Protector | NLP PII Detector (action: "scrub") |
|
|---|---|---|
| Request PII handling | Tokenised (reversible) | Replaced with <TYPE> placeholder (permanent) |
| Response restoration | Yes (streaming and non-streaming) | No |
| Model sees real PII | Never on an external leg¹ | Never on an external leg¹ |
| Client sees real PII | Yes | No |
| Same-value deduplication | Yes (same token per value) | N/A (same label either way) |
| HTTP calls per request | 1 (analyse only) | 2 (analyse + anonymise) |
| Typical use case | Model needs contextual continuity; client needs original values | Audit or compliance logging; no restoration needed |
¹ A wholly-local (Myra/EU) model leg is served unmasked — see Local model legs are not masked.
Limitations
💡 Note: Token restoration runs on both streaming and non-streaming responses. On the streaming path the gateway restores tokens incrementally on the wire, holding back any partial token that spans a chunk boundary so a half-token is never emitted; any token it cannot map back to a value is replaced with a neutral typed placeholder (e.g.
[SSN]) rather than leaked. Remember thetargetrequirement above: the response is only restored when the detector'stargetis"both".⚠️ Caution (nested tool-call arguments): When a restored value containing a
"or\is echoed by the model insidetool_calls[].function.arguments— a JSON string whose body is itself JSON — the value is restored correctly and the response envelope stays valid on the OpenAI-compatible chat API and the SPA. One documented residual remains on the native (non-compat) provider passthrough: an external client that speaks a provider's native streaming API (rather than the gateway's OpenAI-compatible endpoint) may receive such a value single-escaped inside the incrementally-streamed tool arguments, so the inner arguments string can be malformed for a client that re-parses it. The real PII is never leaked and the outer stream stays valid; use the OpenAI-compatible endpoint if a client re-parses tool-call arguments and needs the nested value intact.💡 Note: If the model paraphrases rather than echoing a token verbatim, restoration is skipped for that value. Privacy is maintained (the model never saw the real PII), but the response does not contain the original value in that position.
Example configurations
Protect specific entity types
{
"type": "pii_protector",
"name": "protect-pii",
"target": "both",
"entities": ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "US_SSN"],
"score_threshold": 0.75
}
Protect all entity types, blocking on sidecar failure
Combine with regex pre-filtering
Use a regex guardrail (Tier 1) to block structured PCI data before PII Protector tokenises remaining PII. The regex block prevents card numbers from ever reaching the provider; PII Protector handles names, emails, and other values that benefit from restoration.
[
{
"type": "regex",
"name": "block-pci",
"action": "block",
"target": "request",
"patterns": ["pci_pan"]
},
{
"type": "pii_protector",
"name": "tokenize-pii",
"target": "both",
"entities": ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "US_SSN", "LOCATION"]
}
]
Exempt specific values from tokenisation
Use allow_list to prevent known non-PII values from being replaced with tokens:
{
"type": "pii_protector",
"name": "protect-pii",
"target": "both",
"entities": ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER"],
"allow_list": ["Myra Security", "Myra AI Workspace"],
"allow_list_match": "exact"
}
Configuring PII Protector
PII Protector card — expanded view
Proceed as follows to configure PII Protector in the Guardrail Builder:
- Open the gateway detail page and scroll down to the Guardrails card.
- Click on the + PII Protector button.
- A collapsed PII Protector card appears at the bottom of the list.
- Click on the card to expand it.
- Enter a name in the Name text field.
- If required, select specific entity types from the Entity types list. Leave empty to tokenise all supported types.
- If required, adjust the Score threshold field.
- If required, enter comma-separated values in the Allow list field to exempt known non-PII strings. They are compared as full strings; there is no Match mode control — the field's only non-default option was
"partial", which the analyzer answers with HTTP500, failing the detector open and forwarding the request unmasked.allow_list_match: "regex"remains available through the API. - Toggle the Skip system & assistant messages (scan user turns only) switch off if system and assistant messages also require scanning.
- Toggle the Fail Open switch to
falseif the sidecar must be a hard dependency. - Click on the Save Guardrails button.
-> PII Protector is saved and appears in the execution plan.
Pipeline position
PII Protector is Tier 2 — it makes an HTTP call to the Presidio sidecar. All Tier 1 guardrails (regex, keyword) run before any Tier 2 guardrail. Any regex or keyword scrubbing runs first; PII Protector tokenises whatever remains.
Tenant-enforced mandatory masking (not end-user-deactivatable)
A tenant can make PII masking mandatory for every user with the tenant flag
pii_masking_enforced (see the configuration reference).
When it is set:
- The end user cannot disable masking. In chat, the privacy toggle ("Datenschutz")
renders locked and read-only. The client hint that would request the unmasked route
(
hint_pii_preference) is ignored server-side. - Routing forces the PII gateway — except for a first-party (Myra) model pick. A request
that would route to a non-masking gateway is redirected to its PII twin, unless the picked
model is a first-party Myra model — see Local (Myra) model picks are never
auto-masked below. For a non-Myra pick, if no
masking gateway exists the request is rejected (
pii_protection_required) rather than sent unmasked — fail-closed. A Myra pick is neither redirected nor rejected: it stays on the non-masking gateway and runs unmasked (an authorized, deliberate exception). - Manual unmask-on-demand is denied on an enforcing tenant.
- All message roles are scanned. Enforcement forces a full-scope scan (system,
assistant, and user messages) regardless of the detector's
skip_system_messagessetting — otherwise PII sitting in a system prompt, an orgsystem_instruction, or a prior assistant turn would egress unmasked. The per-detector toggle only widens the scan for a non-enforcing tenant; on an enforcing tenant the full-role scan is non-optional (fail-closed). The full-scope scan covers message text, tool results, the native top-levelsystemprompt, and the argument object of a prior assistanttool_useblock (tool_use.input) — so PII the model re-sent inside a tool-call argument is masked like any other field. It also covers the non-message text carriers:prompt,input(a string, an array of strings, or the Responses-API array of message objects), andinstructions.
⚠️ Caution — not scanned. Anthropic
thinking/redacted_thinkingblocks and the OpenAI-compatiblemessages[].tool_calls[].function.argumentsare deliberately excluded. A thinking block is replayed with a cryptographic signature that covers its text, so tokenising it would invalidate the signature and the provider would reject the next turn;argumentsis a JSON document encoded inside a string, and masking it as flat text would make the tool call forward with empty arguments. The native equivalent of the latter,tool_use.input, is scanned. Personal data in these carriers reaches the model.💡 Note: A request that carries its text in
input— for example/v1/embeddings, or the Responses API — is tokenised like any other request. A client calling those endpoints through a PII gateway therefore receives vectors computed over the masked text, so vectors produced before and after this behaviour was added are not comparable.
Local model legs are not masked
This applies to every gateway (not only enforcing tenants), even a PII gateway, and regardless of the configured detectors:
- A wholly-local (Myra/EU) model leg is never PII-masked — neither the model input nor
the model output. When the effective egress set — the resolved primary provider and
every failover fallback — is first-party local (
myra/vllm), the PII-masking detectors (pii_protector,custom_pii,presidioaction scrub,regexaction scrub) are skipped in both the request and response phases (atarget: "both"detector is skipped on each), and the inline-base64-media fail-closed block is skipped: the model receives the user's data intact and returns it intact. Masking a first-party leg protects nothing (the data never leaves Myra) and would silently corrupt extraction/analysis over the user's own text. This is not the same as the older "routing block" exemption. On a gateway that does run maskers, if any provider in the egress set is external (including a cross-vendor failover fallback), the request-phase masker still runs and the leg is masked (fail-closed) — this wholly-local skip keys on the whole egress chain. A Myra model PICK is governed by the separate, broader rule in Local (Myra) model picks are never auto-masked below: routing keeps it OFF the masking gateway entirely, so it is unmasked even when an external fallback exists — and may egress unmasked on failover. - Content-safety still applies on a local leg. Response-phase blocks (
regex/presidioaction block ontarget: response/both) still fire — the streamed answer is buffered so the block can run even though no request-phase masker forced buffering. Only the masking (scrub) detectors are skipped, never the blocks. Request-phase content-safety detectors (prompt_guard,keyword,regex/presidioaction block) and tool egress (web search / URL fetch / MCP) are likewise unaffected. - The local-only exemption from an enforcing tenant's mandate is a MODEL-leg exemption. On a
gateway whose every configured provider is first-party (
local_only_gateway), an enforcing tenant's requests are exempt from the PII mandate on the model leg. The model-generated web-search query is not exempt: its bytes leave for the search provider, so it always gets the mandate floor —PERSONandLOCATIONanalysed and masked at the German0.6bar whatever the detector'slanguagesays — before it egresses, and the request recordspii_egress_floor_forced_by_mandate. On a Presidio outage the query is withheld whatever the detector'sfail_opensays — the mandate binds at this seam even on this gateway and the request recordspii_mandate_masker_unavailable; outside a mandatefail_opendecides and a forward is recorded as a gap (see thefail_openrow above). Two seams are governed by a separate refuse-on-structured-PII gate with no NER floor and are deliberately name-permissive: URLs handed tofetch_url, and MCP tool arguments — armed on a PII-active gateway or under a PII mandate from any source. - Agent-shaped runs always mask (both phases), regardless of model locality. An agent
invoke, a scheduled prompt task and a delegated sub-agent deliver their output onward, so the
whole leg is treated as third-party egress; the workflow copilot and a draft preview run
through the same agent pipeline and are masked identically (a named ruling: one rule for
every agent-shaped run). On a local-only gateway of
an enforcing tenant this includes the PII-mandate floor (
PERSON/LOCATIONat the German0.6bar whatever the detector'slanguagesays,pii_floor_forced_by_mandaterecorded) and the mandate's fail-closed refusal on a masker outage (pii_mandate_masker_unavailable), where a chat turn on the same gateway is exempt. A masked run emitsX-AIG-PII-Active: 1, so the unattended delivery gate holds it (delivery_status: blocked_pii) exactly as on every other enforcing gateway. A masker-less such gateway still serves the invoke's primary leg (a first-party model pick is never hard-blocked); its inner legs (agentic fetch, mid-turn compaction) are refused.
The guarantee is egress-scoped: no unmasked PII leaves the gateway to an external model
leg or in a web-search query. It is not a network-level DLP boundary — a user can still
copy text out of the product manually — and it has a named residual on a local-only gateway
of an enforcing tenant: there the model's context is unmasked (first-party leg), so a
free-text name the model echoes into a fetch_url URL or an MCP tool argument is not caught
(those gates refuse checksummed identifiers only). On every other enforcing gateway the name in
the model's context is a token, so such an echo is refused. One further residual is tracked: a
masker-less such gateway still serves an agent invoke's primary leg (the first-party
carve-out; put to the CEO). The workflow persistence fence (form files, inbound
email) binds the mandate on the strict leg — see the egress table below.
Local (Myra) model picks are never auto-masked
Authorized, deliberate exception (CEO ruling, 2026-08-22). A first-party Myra model pick never auto-enables PII masking — including on an enforcing (
pii_masking_enforced) tenant or apii_mandatoryproject. This is a compliance-relevant behavior that was chosen with full knowledge of its consequence; it is not a defect. An auditor reading a request that egressed unmasked to a third-party fallback under an active mandate should treat it as the expected behavior of this rule, traceable via the audit marker below.
Distinct from — and broader than — the wholly-local skip above: this rule is about which gateway the pick routes to, not about the masking-skip flag. When the user picks (or Auto resolves to, or a project/user default pins) a Myra model:
- The pick is never redirected to a masking (PII) gateway. Under a mandate a non-Myra pick is routed to its PII twin; a Myra pick instead stays on the non-masking gateway and runs unmasked — the model input and output are left intact. This holds across every routing path (Auto default, an explicit model pick, a project- or user-pinned model, and the admin-config write-guard), so what you can save and what actually sends always agree.
- Cloud failover egresses UNMASKED (accepted risk). If the gateway carries an external fallback and the Myra primary is unavailable, the request fails over to the external provider and egresses the body UNMASKED to that third party. The failover leg is intentionally not re-masked, and the turn is not blocked. This is the deliberate trade-off: the turn succeeds instead of failing, at the cost that — for a Myra pick that has to fail over — real PII may reach the external fallback provider under an active mandate.
- The one retained exception (fail-closed): when the tenant's only gateway is a masking gateway (no non-masking gateway exists to route to), the Myra pick stays on the masking gateway — there is nowhere unmasked to route it. Masking then runs whenever the egress chain includes an external leg (the usual reason a masking gateway exists); a wholly-local Myra turn on it is still served unmasked by the wholly-local skip above (no third-party egress → nothing to protect).
- Audit / observability. Every unmasked external egress under an active mandate is recorded
on the request log:
request_log.meta.pii_unmasked_egress_under_mandate = true, plus a[pii_unmasked_egress]gateway WARN log naming the external provider. In practice this fires exactly on the Myra-primary → external-fallback leg (an external primary or inner leg on a non-masking gateway under a mandate is hard-blocked before it can dispatch). The marker is observe-only — it never blocks, so the authorized egress stays fully traceable.
Rationale: a first-party Myra model runs inside Myra's EU perimeter, so masking its input
corrupts the user's own data for no third-party-egress benefit; and the derailment the masking
tokens caused on local turns (the model reasoning over [MYRA-REDACT-…] placeholders and asking
cryptic clarifying questions) is removed. The failover-egress risk was weighed against turn
availability and accepted.
Third-party egress — what is scrubbed vs blocked
Masking a value only works where the downstream sink tolerates a pseudonym. Where the real
value is structurally required (a tool argument, a URL) a token would break the call, so the
policy there is block, not scrub. On a PII-active gateway (one carrying a
pii_protector) — or under a PII mandate from any source: an enforcing tenant, a
pii_mandatory project, a forced agent invoke — every third-party egress point is
covered:
| Egress point | Destination | Policy |
|---|---|---|
| Model prompt / request | external model | Scrub (reversible tokenisation), or refuse the request if no request-phase masker under a mandate |
| Web-search query | Brave / weather (US) | Scrub the query under the mandate floor — including on a local-only gateway, whose model leg is exempt; under a mandate with no pii_protector to scrub with, the search is withheld |
| Fetched document for the agentic inner agent | the inner model (a model leg, not third-party egress) | Under a mandate with no pii_protector, the document is withheld from a non-local inner model; a first-party local inner model is not a mandate case — except inside an agent-shaped run, whose inner leg is never treated as local, so the document is withheld there too |
fetch_url / agentic-fetch URL |
arbitrary web host | Block the fetch if the URL carries a protected structured identifier |
| MCP tool-call arguments | the connector's MCP server | Block the call if an argument carries a protected structured identifier — unless the connector is flagged a trusted EU/DPA subprocessor (trusted_subprocessor) |
| Conversation summarisation (compaction) | external model | Scrub before the summary leg egresses |
| Workflow persisted content — a form's uploaded file, an inbound email body / attachment | the workflow run state (templated into every later step; a Deliver step emits it) | Scrub under the mandate floor on the strict leg — a local-only gateway's model-leg exemption does not apply, and a masker outage withholds whatever fail_open says; under a mandate with no pii_protector the content is withheld. Read from the gateway's effective configuration (the tenant mandate folded in; coherent within an instance, bounded by config_cache_ttl across instances) |
| Image generation | Myra-hosted EU model | First-party; no third-party egress |
| Code interpreter | Myra sandbox | First-party; no third-party egress |
| RAG / knowledge search / file read-write | in-tenant vector store | No third-party egress |
Block signal (value-dependent sinks). A block fires on a structured, high-confidence
identifier only: a checksum-valid German ID (Steuer-ID, USt-IdNr, SV-Nr, KV-Nr,
Personalausweis), an IBAN (mod-97), a credit-card number (Luhn), an email address, a JWT, an
API key, a [MYRA-REDACT-…] token echo, or a configured custom_pii keyword. Free-text
names and places pass — the tool needs the real value to function, and blocking every
person/location would disable the tool. When an argument object cannot be fully inspected
(unparseable / excessively nested), the call is blocked (fail-closed). A blocked tool call
returns a model-facing message so the model can retry without the protected data.
See also
- Guardrail pipeline overview
- NLP PII detector — permanent scrubbing without restoration
- Regex guardrail — in-process pattern matching for structured data
- Configuration reference —
pii_masking_enforced,trusted_subprocessor