Skip to content

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).

Screenshot: PII Protector editor in the Guardrail Builder 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

  1. 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], where TYPE is the detected entity type (e.g. EMAIL_ADDRESS, US_SSN), SALT is a conversation-stable prefix, and N is a content-derived hex identifier. The same original value always maps to the same token within a single request.
  2. Provider call — The upstream AI provider receives only the tokenised body. Real PII values are never transmitted.
  3. 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 with 123-45-6789 restored.

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 action field — 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 return 400 for a pii_protector or custom_pii detector whose action is 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}/detectors reports the action of 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 target field, 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 default target of "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 — language and allow_list_match; runtime coercion only for allow_list. A write that introduces or changes language to 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 with 400, 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_match has a write rule of its own: a write that introduces or changes it to anything other than "exact" or "regex" is rejected with 400, 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's exact default — narrower allow-listing, i.e. more masking. allow_list itself 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. For allow_list_match the rule is about the VALUE, not only the shape: anything other than exact or regex — including the "partial" this field's editor used to offer — is treated as absent, because the analyzer answers 500 to it and that 500 fail-opens the detector. Absent is the exact default, i.e. narrower allow-listing.

This is not cosmetic. Previously a stored "language": null — JSON null decodes 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 default fail_open: true then 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 with pii_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. Set fail_open: true on 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 (a pii_mandatory project, 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_protector detectors 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 a pii_protector detector also composes — presidio anonymises what it catches, then pii_protector tokenises the residual it missed (so one result can be both <EMAIL> and [MYRA-REDACT-PERSON]). The one forbidden combination is presidio-scrub running after pii_protector on the same result: anonymising over a [MYRA-REDACT-…] token would corrupt restoration, so a presidio-scrub detector ordered after a pii_protector detector is skipped for any result already tokenised (its entities are not masked on that result — order presidio first, or rely on pii_protector's superset coverage).

⚠️ Only NER-based detectors cover tool results. Tool-result scanning applies to pii_protector and 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's server_tool_use/tool_result pairing 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:

{ "messages": [ ... ], "x-aig-pii-overrides": { "mask": ["Project Zeus"] } }

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 the target requirement above: the response is only restored when the detector's target is "both".

⚠️ Caution (nested tool-call arguments): When a restored value containing a " or \ is echoed by the model inside tool_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

{
  "type": "pii_protector",
  "name": "protect-all-pii",
  "target": "both",
  "fail_open": false
}

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

Screenshot: PII Protector card in the Guardrail Builder PII Protector card — expanded view

Proceed as follows to configure PII Protector in the Guardrail Builder:

  1. Open the gateway detail page and scroll down to the Guardrails card.
  2. Click on the + PII Protector button.
  3. A collapsed PII Protector card appears at the bottom of the list.
  4. Click on the card to expand it.
  5. Enter a name in the Name text field.
  6. If required, select specific entity types from the Entity types list. Leave empty to tokenise all supported types.
  7. If required, adjust the Score threshold field.
  8. 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 HTTP 500, failing the detector open and forwarding the request unmasked. allow_list_match: "regex" remains available through the API.
  9. Toggle the Skip system & assistant messages (scan user turns only) switch off if system and assistant messages also require scanning.
  10. Toggle the Fail Open switch to false if the sidecar must be a hard dependency.
  11. 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_messages setting — otherwise PII sitting in a system prompt, an org system_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-level system prompt, and the argument object of a prior assistant tool_use block (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), and instructions.

⚠️ Caution — not scanned. Anthropic thinking / redacted_thinking blocks and the OpenAI-compatible messages[].tool_calls[].function.arguments are 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; arguments is 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, presidio action scrub, regex action scrub) are skipped in both the request and response phases (a target: "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 / presidio action block on target: 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/presidio action 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 — PERSON and LOCATION analysed and masked at the German 0.6 bar whatever the detector's language says — before it egresses, and the request records pii_egress_floor_forced_by_mandate. On a Presidio outage the query is withheld whatever the detector's fail_open says — the mandate binds at this seam even on this gateway and the request records pii_mandate_masker_unavailable; outside a mandate fail_open decides and a forward is recorded as a gap (see the fail_open row above). Two seams are governed by a separate refuse-on-structured-PII gate with no NER floor and are deliberately name-permissive: URLs handed to fetch_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/LOCATION at the German 0.6 bar whatever the detector's language says, pii_floor_forced_by_mandate recorded) 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 emits X-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 a pii_mandatory project. 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