PII Redaction
Most gateways can only inspect an AI response after it finishes (buffering) or log what they saw (monitoring). KnoxCall redacts inside the live stream: as SSE frames flow from the provider to your app, sensitive spans are rewritten before they leave the gateway.How it works
The detector stack
Each candidate text span is screened by a layered detector stack, fastest first:- Aho-Corasick multi-pattern scan for known literals and keywords.
- Regex + checksum validators for structured identifiers — credit cards (Luhn), IBAN, US SSN, email, E.164 phone numbers, JWTs, and known provider API-key shapes.
- Presidio (optional) — a named-entity recognizer sidecar for names, locations, and other NER-based entities.
Streaming hold-back
Streaming redaction uses a sliding hold-back buffer: the gateway holds back the last N characters of the stream (default 96) so a sensitive token that straddles two SSE frames is still caught before any part of it is emitted. The buffer is SSE-frame-aware for both Anthropic and OpenAI event shapes, so redaction never corrupts the event framing.The two directions
Redaction is two independent settings, because they answer two different questions. Attaching a PII policy chooses what is detected; these choose what happens to it.pii_request_mode: "off" is the only configuration in which the upstream
provider receives a detected value. It exists for agents whose payload has to
arrive byte-for-byte — structured tool-use JSON, for instance, where rewriting a
field would corrupt the tool arguments — and it is a per-agent column so the
choice is visible in the API, the dashboard and a compliance export.
pii_response_mode: "redact" still runs the full response-side detector stack:
personal data the model produced is redacted, and the canary check still fires.
It differs from detokenize only in leaving KC_* placeholders in place rather
than restoring your originals.
Knowing whether the answer was changed
When the response-side detectors change a non-streamed answer, the response carriesX-Knox-AI-Pii-Rewritten: <n>, where <n> is the number of
replacements that actually altered the text. Its presence means the body you
received is not the provider’s verbatim answer.
- A match that leaves the text unchanged — a monitor-only or allow-listed detection — is not counted, so detection can find something and the header still be absent.
- On a non-streamed response, absence means no detector replacement changed the answer.
Streaming: what has a mid-stream redactor, and what refuses
The hold-back rewriter can only splice a stream it can parse. KnoxCall ships one for the Anthropic, OpenAI and Gemini SSE shapes. It has none for Cohere’s native SSE shape, and none for Bedrock, which streams length-prefixedapplication/vnd.amazon.eventstream binary frames rather than SSE at all.
A streamed request on one of those two is refused with 400
streaming_redaction_unsupported, not served. Streaming it would hand the
client a response with no response-side redaction, no detokenization and no
canary scan — the exact controls the buffered path applies on every 2xx. Send the
request without stream: true, or set streaming_enabled: false on the agent so
the choice is recorded there.
A body the gateway cannot read is refused
Tokenization walks the prompt fields of a JSON body. A non-JSON body has no prompt fields to walk, so on an agent withpii_request_mode: "tokenize" it is
refused with 400 pii_unscannable_body rather than forwarded. “We found no
personal data” and “we could not look” are different answers, and only one of
them belongs in a compliance record. Set pii_request_mode: "off" if the agent
genuinely needs to forward opaque payloads.
Reversible tokenization
When a policy uses reversible tokenization, each detected span is replaced with a stable placeholder token, and the original value is stored in a per-conversation encrypted token map (ai_gateway_pii_token_map). On the response path, the gateway can detokenize — restoring the original values for the end user while keeping them out of the provider’s logs and out of any cache.
Scope the map across turns with the X-KC-Conversation-Id request header, so a value tokenized on turn 1 restores consistently on turn 5.
Because reversible tokenization keeps a per-conversation map, it interacts with caching: semantic caching is disabled while reversible tokenization is active for a conversation, to avoid leaking one conversation’s values into another.
Measured detection quality
We measure the detector stack against a committed labeled corpus rather than describing it. The corpus (tests/fixtures/guardrail-eval/) holds 38 documents
carrying 70 labeled identifiers, plus 28 hard negatives that contain no
personal data at all - UUIDs, git object ids, non-Luhn order numbers, stack
traces, Kubernetes manifests, an IBAN with a wrong check digit. The numbers below
are produced by npm run ai-gateway:eval, pinned in
tests/coverage/baselines/guardrail-eval.json, and enforced on every CI run by a
ratchet: recall may only rise, and the false-positive rate may only fall.
Measured 2026-08-25, in-process stack (Aho-Corasick + regex/checksum), no Presidio:
Every detected span had exact boundaries: nothing was over- or under-redacted.
False positives on clean text: 1 of 28 hard-negative documents (3.6%). The
one failure is worth naming, because it is the failure mode that costs you
something: the email pattern matches machine identities, so
git@github.com and
deploy@build-runner-04.internal in a pasted shell command are redacted. If you
send code through an agent with redaction on, expect that.
What this stack does not detect
The in-process tiers cover structured identifiers with a deterministic shape. They do not detect names, street addresses, medical record numbers, national provider or DEA identifiers, passport numbers, driver licences, IP addresses or crypto wallet addresses. Those entities are in the corpus too, and score 0% against this stack - deliberately, so the gap is a published number rather than an omission. Names and locations need the Presidio tier; the rest are covered by compliance packs once a pack is installed.Prompt-injection firewall
The firewall heuristics are measured the same way, over 24 injection attempts and 16 benign prompts. Measured 2026-08-25: recall 58%, precision 67%. The built-in rules catch direct override phrasings (“ignore all previous instructions”, “your new instructions are”, named jailbreaks) and miss obfuscated, translated, and indirectly-framed attacks. Its false-positive rate on benign prompts is 44% - the built-in rules are keyword-shaped, so an ordinary prompt about enabling developer mode on a phone, or a colleague named Dan, matches. Treat the built-ins as a signal to tag or warn on; scope ablock-action policy to rules you have measured against your own traffic.
Presidio (optional)
For NER-based entities, add a Presidio sidecar as a tenant Integration:- Deploy the Presidio sidecar (see
docker/presidio-sidecar/). - In Settings → Integrations → Presidio, set the sidecar URL, timeout, and score threshold.
allow_private_host on a self-hosted install), or its circuit breaker is open after five consecutive failures. In both:
- With a PII policy attached —
503 pii_policy_degraded. Requests are refused rather than forwarded, for the same reason a recognizer that will not compile refuses: the policy you attached is only partly in force. The in-process tiers do not detect names, addresses or medical record numbers, so losing the analyzer is losing most of what the policy was for. - With no policy attached — the request is served, but carries
X-Knox-AI-Pii-Degraded: presidio_url_refused(orpresidio_circuit_open) and writes adetector_unavailablerow to the PII events ledger. Nothing was asked to be enforced, so it is not a refusal — but you find out here, not later.
A single mid-scan failure that has not yet tripped the breaker is still absorbed (the sync tiers cover the request). Only the two cases above are surfaced today.
Where the sidecar may live
The analyzer receives the prompt text being inspected, so its URL is treated as an egress destination and validated on every call: the hostname is resolved, every returned address is checked against the private/reserved ranges, and the request is sent to the address that was checked (so a name that flips after validation cannot be reached). Non-http(s) schemes are refused.
- On the cloud service the analyzer must be a publicly-routable host. A private-network URL is refused when you save it (
presidio_url_blocked); an existing one stops being used, and the stack runs on its built-in tiers. - On a self-hosted deployment, tick “Analyzer is on this deployment’s private network” on the same Integration to allow a sidecar on
10.x,192.168.x,172.16.x, an IPv6 ULA, or127.0.0.1. It is off by default and ignored on the cloud service. - Link-local addresses (
169.254.0.0/16,fe80::/10— the cloud metadata range) are never permitted, with or without that setting.
PII events
Every redaction (and every skipped detection in monitor mode) is written to theai_gateway_pii_events ledger with the entity type, detector, direction (request/response), and action. Compliance-pack alert rules can count these — e.g. “alert when an SSN appears in a response” — and they feed evidence exports.
Batch requests
Prompt inspection follows the request shape, not the endpoint. A batch submit is an ordinary POST whose body nests each real request one level down, so the gateway walks into it:src/ai-gateway/prompt-shapes.ts), so they can never disagree about
what the prompt is:
- Anthropic
system(string and block-array) andmessages[].content - OpenAI
input,instructionsand legacyprompt - Gemini
contents[].parts[].textandsystemInstruction - Cohere
message,preambleandchat_history[].message - RAG grounding
documents[](bare strings, andtext/snippet/title) - Tool descriptions, on all four declaration shapes — a description is prompt text the model reads verbatim on every turn, and it is where a real example (“look up a patient by SSN, e.g. …”) tends to get pasted
parameters, input_schema) are deliberately not rewritten:
the provider validates against them, they carry field names rather than values,
and corrupting one breaks the tool call without protecting anything. If one
sub-request names a model the agent may not use, the whole batch is refused:
accepting it partially would hand you a batch id that does not describe what you
submitted.
OpenAI’s file-based batches
OpenAI’s Batch API does not carry your prompts. You upload them to the Files API first and submit{"input_file_id": "file-…"}, so the gateway never sees the
text.
If your agent has PII tokenization or a prompt firewall configured, that submit
is refused with 422 batch_input_not_inspectable. The alternative would be
to forward it with both controls silently inert — a request that looks protected
and is not.
Two ways forward:
- Use the Anthropic Message Batches API, which carries its requests inline and is inspected in full.
- Turn the control off for that agent, if the batch genuinely carries no sensitive data — an explicit choice, recorded on the agent.
Configure a policy
PII policies and recognizers are managed on the PII tab of the gateway detail page. A policy selects which entity types to detect and the default action for a match. Attaching a policy to an agent selects what is detected. It does not by itself decide what happens to a match — that ispii_request_mode and pii_response_mode on the agent, which default to tokenize and detokenize. An agent with no policy still runs the built-in detector tier in both directions.
Attaching a policy also makes the gateway fail closed on it: if the policy cannot be loaded, does not resolve for your tenant, or can only be partly applied (a recognizer that will not compile, a presidio_* rule with no sidecar, more recognizers than the active limit), the request is refused with 503 pii_policy_unavailable or pii_policy_degraded rather than forwarded under a policy that is not fully in force.
They are also a first-class part of the API, so the whole thing is scriptable.
Scripting it end to end
A policy is a named bundle of recognizers plus adefault_action. An agent
points at one through pii_redact_policy_id. Everything below is on
/v1/ai-gateway/* and needs only a management API key — no browser step.
Four refusals worth knowing before you script against them
Test the pattern on the server, not locally. Patterns are compiled with a linear-time engine. Lookahead, lookbehind and backreferences — which every client-side regex engine accepts — are rejected. A local preview would show you matches for a recognizer that can never run, and the recognizer would then be skipped at scan time. A skipped detector fails open. An emptyrecognizer_ids means every enabled recognizer, not none. It is
the “use everything I own” setting. Passing [] on an update is therefore a
widening, not a clearing.
A recognizer id you do not own is a 400 recognizer_not_found at write
time, on create and on update. The column has no foreign key, and the loader
intersects with the recognizer’s own tenant — so a stored foreign id would give
you a policy that lists three recognizers and runs zero. That is
indistinguishable from working redaction right up until the PII reaches the
provider, which is why it is refused rather than accepted.
Deletes are refused while something still points at the row. DELETE a
policy an agent still references and you get 409 policy_in_use: the foreign
key is ON DELETE SET NULL, so the delete would quietly detach every bound
agent and turn redaction off for each of them. DELETE a recognizer a policy
still lists and you get 409 recognizer_in_use — dropping the id would widen
the policy, per the rule above. Detach first, deliberately.