# PrivAiTe, guide for AI agents (read this before changing code) PrivAiTe is a self-hosted, OpenAI-compatible proxy that **detects PII, replaces it with reversible placeholders before forwarding to the LLM provider, and restores the real values in the response.** It is a privacy tool: a single leak defeats its purpose. The rules below each exist because breaking them caused a real bug. Treat them as invariants, not suggestions. ## Non-negotiable invariants 1. **Fail closed.** If detection/anonymization raises, the request is **blocked**, never forwarded. `pii.on_error` defaults to `"block"`; detector exceptions re-raise (`engine._detect_all`). Do not add a fallback that forwards raw text on error. Startup refuses unsafe configs (see #6). 2. **One choke point.** Every piece of user text reaches the provider only through `PIIEngine._anonymize_text`. The `block_entities` gate lives there. If you add a new field/path that carries user text, route it through the engine, never forward it directly. 3. **Restore parity.** Whatever is anonymized must be restored on the way back, in **both streaming and non-streaming**: `content`, `tool_calls[].function.arguments`, legacy `function_call.arguments`, `reasoning_content`/`reasoning`, `refusal`, and the audio `transcript`. 4. **`mask` and `redact` are irreversible.** They must NOT enter the reversible mapping (`mapping.note()`, not `mapping.add()`). Two values that mask to the same string must never cross-restore. Only `placeholder` and `fake_replacement` are reversible. 5. **Never log or echo PII.** Errors name entity **types**, never values (`PIIBlockedError`, `UnsupportedContentError`). No logger call may emit message content or entity values. The reversible map is per-request, in-memory only. The opt-in detection cache (`privaite/pii/cache.py`) may hold only salted hashes and span metadata (offsets, types, scores, sources): never text, values, anonymized output, or mapping state. Its privacy delta is documented in `docs/threat-model.md` (the README threat model is the short version and links to it); keep that page in sync if you touch it. 6. **Startup fails fast on unsafe config**, it never degrades silently: `pii.enabled` with 0 detectors, `merge_strategy: intersection` with <2 detectors, a Presidio language with no spaCy model, duplicate provider `model_name` aliases, and a `block_entities` type no enabled detector can emit (conservative check: skipped when a detector's producible set is unknown) all raise at boot. 7. **Config defaults are the safety posture, do not flip them casually.** `preset: onnx` (72.7% of direct identifiers on the README benchmark), `on_error: block`, `deanonymization.fuzzy_matching: false` (fuzzy can mis-substitute), detector `trust_remote_code: false`, `block_entities: []`. 8. **An issued placeholder never equals a string already in the request.** The restore replaces every occurrence, so a pre-existing `` (a fixture, a template, a planted `?to=`) would receive a real value. `PIIMapping.reserve` runs over the whole request before the first placeholder is issued (`_process_request` with `other_fields`, both gateway scrubs), the anonymizer swaps a literal that shows up too late (`_shield_literals`), and the restore is single-pass. A new path that shares one mapping across several calls must reserve the whole body first. 9. **Nothing phones home.** The only outbound request is the one to the provider. Dependencies do not respect that by default: ONNX Runtime 1.29+ uploads telemetry to Microsoft, LiteLLM fetches a price table from GitHub, the Hub is asked about cached files, tldextract downloads the public suffix list. `privaite/__init__.py` switches the first three off before they load, the ONNX and Presidio detectors handle the rest, and the Dockerfile repeats the switches. `tests/test_outbound.py` pins each one, but only a packet capture on the built image sees a new one: run it when a dependency floor moves (a container sharing the image's network namespace, `tcpdump` on port 53 and on outgoing SYNs, then `privaite verify` and a proxy start). ## What is and isn't scanned (know the surface) Scanned: `messages[].content` (string, multimodal text parts, or bare strings in a list); plaintext `messages[].reasoning_content`, `reasoning`, `refusal` and `audio.transcript` (clients can echo restored response fields on the next turn); `tool_calls`/`function_call` arguments parsed as JSON and scrubbed value by value **including numeric leaves with >=7 digits** (a card number sent as a bare number is caught); `/v1/completions` `prompt` and `suffix`; `/v1/embeddings` `input`; chat `prediction.content` and `web_search_options.user_location` (auxiliary request fields, scrubbed input-side via `process_request_value`, no restore path needed). A credential the structured secret rules matched is `SECRET` in every text scrubbed after it in the same request (`PIIEngine._with_known_values`, at the choke point, so both scrub surfaces get it). Re-detecting a credential the client echoes back is not reliable: an agent repeats a restored password bare, in its reasoning or in the next command. The values live in `PIIMapping.remember`, apart from the per-value type, which a later detection of the same string under another type overwrites (that overwrite silently stopped the propagation once). A copy goes through the normal overlap resolution, so a blocked type still blocks and an irreversible one stays irreversible. Keep it narrow: rule-matched values only (propagating what the ONNX model labels `SECRET` copied a plugin name 24 times in one Codex session), 8 characters minimum with a digit, exact copies, forward only. NOT scanned (documented limitation, keep it documented if you change it): `messages[].name`, top-level `user`/`metadata`, `tools`/`functions` definitions, JSON object keys, and tokenized (integer-array) inputs. Forwarded at all: only the OpenAI parameters of each endpoint (`CHAT_PARAMS`, `COMPLETION_PARAMS`, `EMBEDDING_PARAMS` in `privaite/api/pipeline.py`) plus a provider's `forward_params`; anything else is a 400 before the scan. Every forwarded field becomes a litellm keyword argument, and litellm reads call settings from those: a client `api_base` sent the operator's key to the client's server, `extra_body.messages` replaced the anonymized messages. Never forward the body wholesale, and never let `forward_params` take a call setting (the schema refuses them at load). `POST /v1/pii/inspect` (off by default, `pii.inspect.enabled`) is a dry run: it returns the caller's own detections + anonymized preview and forwards NOTHING. It is the one deliberate exception to "never echo values" (the caller sent the text; invariant #5 still fully applies to logs, errors and /stats, and the endpoint module deliberately has no logger). Keep it excluded from /stats. ## Gateway: the SECOND scrub surface (`privaite/gateway/`) Opt-in agent CLI routes (`gateway.enabled`, off by default): `/v1/messages`, `/v1/messages/count_tokens` (Anthropic Messages, Claude Code) and `/v1/responses` (OpenAI Responses, Codex, beta). They are a **parallel request path** to `privaite/api/`: a core-side fix (a new scanned field, a new restored field, a gate) is NOT automatically true here. Check both. - **Scrub (`gateway/scrub.py`)** still goes through the one choke point: every scanned string reaches `PIIEngine.scrub_document` (hence `_anonymize_text`), so `block_entities` and fail-closed apply unchanged. Rules that exist because breaking them leaked: scrub coverage must match restore coverage (restore rewrites every string leaf, so the client holds REAL values inside `server_tool_use`/`mcp_tool_use` blocks and echoes them back next turn: scrub them); tool OUTPUT carriers are where a file the agent read arrives (`custom_tool_call_output` and friends, a list of `{type,text}` parts, not just a string); an Anthropic block type this build does not know is scanned through an allowlist of plaintext field names, never relayed raw. Relayed byte-for-byte: `thinking`/`redacted_thinking`, binary and opaque payloads, and the Responses opaque item types. The agent's own prompt (`system`, `instructions`) is the one field read but not rewritten: it goes through `PIIEngine.gate_document`, so `block_entities` still rejects the request while the prompt reaches the provider verbatim. The exact frozensets are documented in `docs/gateway.md` and pinned by `tests/test_gateway/test_gateway_docs.py`: change one, change the other. - **Restore (`gateway/restore.py`)** is NOT the core restore. `restore_tree` rewrites **every string leaf** of the response except blocks whose type is in the protocol's `skip_restore_types` (thinking, encrypted reasoning/compaction), and an `arguments` string is restored on its PARSED tree and re-encoded, never by plain substitution (splicing a raw quote/backslash/newline back into a JSON string literal breaks the client's `json.loads`). - **A separate SSE restorer** lives here too (`_SSERestorer`), driven by the declarative event plans in `gateway/protocols.py`: one `StreamingDeAnonymizer` per text channel so a placeholder split across two events still restores, a flush at channel end and at `[DONE]` so held-back text is never dropped, and `json_escaped_mapping` on JSON-fragment channels (streamed tool arguments) so the spliced original stays a valid piece of the JSON string literal it lands in. Upstream framing (event names, comments, `[DONE]`) is preserved verbatim. - **Relay (`gateway/relay.py`)**: `accept-encoding` is never forwarded (the proxy must be able to decode what comes back), duplicate header names are relayed verbatim as a list of pairs, an upstream timeout maps to 504 and a transport failure to 502. - **Auth**: gateway paths are deliberately skipped by `AuthMiddleware` when gateway mode is on (the only credential in the request is the client's own upstream token). With the default `server.host: 0.0.0.0` and no rate limit, an exposed port plus gateway mode is an open endpoint. Documented in `docs/gateway.md`; do not "fix" it silently in either direction. `GET /v1/models` is shared with the core proxy: in gateway mode the middleware only flags whether the caller holds a valid PrivAiTe key, and the route serves PrivAiTe's list to that caller and relays everyone else to the Responses upstream (Codex needs its upstream catalog). A valid PrivAiTe key never leaves; with auth disabled the route always relays. ## Streaming handler (`privaite/streaming/handler.py`) Forward the provider's own chunks (preserve `id`, `usage`, `logprobs`, and the real finish chunk: do not synthesize a duplicate finish). One restore buffer **per choice index** (n>1 must not share). Flush held-back text onto the finish chunk and again after the stream ends without a `finish_reason`. Never suppress a chunk that carries any payload other than fully-held-back content. ## Before you push or release - Run in the repo venv (`.venv`): `pytest`, `ruff check`, `ruff format --check`, and **`mypy privaite/ integrations/openwebui/privaite_filter.py integrations/litellm/privaite_guardrail.py`**. Both the push CI and the publish workflow type-check the integrations: skipping them locally once broke a release. - Version: bump **both** `pyproject.toml` and `privaite/__init__.py`; date the `CHANGELOG.md` section; bump the integration pins (`privaite>=X`). - Install instructions: every docker tag, pip floor and sentence describing a behaviour as not yet shipped moves with the release, or the quick start installs the previous version. So does the tag in the README's config download (`raw.githubusercontent.com/.../vX.Y.Z/config/...`): on `main` or on an older tag, a pip user gets a config that is not the one of the version they installed. `tests/test_docs.py` fails on all three. - Docs: `python scripts/gen_llms_full.py` after ANY documentation change (`--check` fails when stale). `llms-full.txt` is a concatenation and went stale for three releases once; `llms.txt` and `docs/llms.txt` are the same file and must stay identical. - Docker base: refresh the digest of `python:3.13-slim` in the Dockerfile (`docker buildx imagetools inspect python:3.13-slim`) and move the date above the FROM line, or the image ships months of Debian security fixes behind. Dependabot no longer does it; `tests/test_docker_base.py` fails once a dated release is more than 14 days past that check. - Benchmark: if you touched detection, re-run `privaite-bench` (`python -m pbench.run` then `python -m pbench.report` from that repo root) and update the README table (`tests/test_startup_warnings.py` reads the `onnx` and `light` figures the light-preset warning quotes from it) and the presets table in `docs/detection.md`. The published numbers must match the shipped code. - Release: `gh release create vX.Y.Z` triggers `publish.yml` → PyPI (trusted publisher; its Environment field must stay empty). A PyPI version can never be reused, so verify green CI first. ## Recognizer vocabulary follows the language The recognizers PrivAiTe registers itself (`privaite/pii/recognizer_*.py`) are registered on **every** configured language, so any human-language word inside their regexes reaches text written in another language. A cue made of words belongs to the language those words come from: declare it in a `LanguagePatterns` map (`privaite/pii/recognizer_vocab.py`), never as a union of every language's vocabulary. Only patterns with no vocabulary at all (digits, punctuation, code identifiers) go in the `neutral` bucket that applies everywhere. Unioning them is how issue #31 happened, and the same shape then reported "configured via Terraform" as a location, `via` being an Italian street. Stop-word lists are the deliberate exception: they only ever shorten a match, so a union of languages there stays on the safe side. A cue that merely means "I am" ("je suis", "I'm", "ik ben") introduces an adjective as often as a name, so it requires a capitalised name; a cue that announces a name ("my name is") accepts any casing. These recognizers are also **exempt from the `entities` allowlist** by design, so removing a type from that list does not disable them: the knob is `pii.detectors.presidio.disabled_recognizers`, whose names are validated at config load. Anything reasoning about what Presidio can emit must call `builtin_recognizer_entity_types()` rather than restate the list. ## Span boundaries give back structure, never a value `privaite/pii/boundaries.py` fits a detected span to the value it belongs to. `refine_boundary` runs on Presidio results and never drops anything; `refine_spans` runs on the model's spans, which on `.env` files run across lines, start inside a variable name or cover a fragment of one. A rule there may only give back characters that are structure by construction: an upper-case name assigned at the start of a line, the `:` and the last `@` of a URI userinfo, a line break before another assignment. "Start of a line" includes what agent tools put there (`ENV_LINE_PREFIX`: the line numbers of a file read, a diff marker, a compose list item, `export`), and the secret rules share that prefix: Claude Code sends a file it read as `12NAME=value`. When the structure is uncertain the span stays whole: lower-case names (`user=tag@example.com` is also one valid address), a name that looks like a token, a URI tail with a query string. A new rule needs a test for what it gives back and one for what it must leave covered. These rules run once per span, so a lookup never reads the whole line: a name is searched 512 characters back, a URI 2048 around the span, and beyond that the span stays whole. A minified file is one line with thousands of spans, and an unbounded lookup there took minutes. The structured secret rules feed the propagation of known credentials, so a false positive there is rewritten across the whole request: `TOKEN`, `SECRET` and `KEY` are trusted as names only upper-case at the start of a line with an opaque value, and a provider key format needs its prefix and its length. ## The allow list is the one place a detection is given back `pii.allow_list` (`privaite/pii/allow_list.py`) is operator config, never request data: nothing in a message, header or tool output may widen it. It runs once, in `_detect_all_detailed`, after merging and before the cache, the propagation of known credentials, the block gate and the anonymizer, so every scanned surface sees the same result. A merged span is let through only when EVERY raw detection under it is listed on its own type and text, in full: a span that contains a listed value, or that another detector saw as something else, stays whole (the detections that stood in the way are reported as `not_allowed`, never acted on). Entries without `entity_types` never touch SECRET or a blocked type. The cache stores the let-through spans next to the redacted ones so `/v1/pii/inspect` stays exact on a hit; all of it value-free. A change here needs a test for what it lets through and one for what must stay covered, and the check that an empty list changes nothing. ## Integrations must stay in sync with the core `integrations/litellm/privaite_guardrail.py` and `integrations/openwebui/privaite_filter.py` run the **same engine in-process**. Any core behavior change (fuzzy, block gate, a new restored/scanned field) must be mirrored in both. Each integration ships as ONE self-contained file (the filter is pasted verbatim onto the Open WebUI hub), so helpers they share exist as **copies**, not imports: `_restore_json_tree` and `_restore_arguments` today. The copies are the accepted price of that constraint, and the failure mode is real: a fix landing in one copy and not the other (it happened to the filter's declared privaite floor, which stayed behind while the file's content moved on). When you touch a shared helper or a mirrored behavior, apply it to BOTH files in the same change, then run `tests/test_integrations/test_parity.py`: it compares the copies' ASTs and fails on drift, and its `SHARED_HELPERS` list must grow when a new copied helper appears. If the change is deliberately one-sided, say why in the commit message. - OpenWebUI filter: `outlet` must **pop** the reversible map (Open WebUI may persist message metadata); do not stash it when `deanonymize` is off; clear any client-supplied incoming map in `inlet`. - LiteLLM guardrail: scan **every** Responses `input` item (role message, tool output, tool-call arguments, `input_text` part, bare string), not just a homogeneous list. Scan every leaf of `extra_body` too: LiteLLM merges it over the request it sends, and `extra_body.messages` replaced the scrubbed messages on the wire (its keys are provider specific, so no field list covers it). With `block_entities` set, a prompt or input sent as token IDs is refused. Every other top-level field is NOT scanned, and LiteLLM forwards a field it does not know to the provider as it is: keep that stated in the README. - The guardrail also lives upstream in the fork `crp4222/litellm`, branch `feat/privaite-guardrail` (PR #31530 to BerriAI/litellm). Sync fixes there too. Fork PRs target BerriAI's default branch, `main` since `litellm_oss_staging` was deleted (`gh api repos/BerriAI/litellm --jq .default_branch`; litellm's `scripts/default_branch.py` reads `origin`, which in the fork clone is the fork). The fork must pass litellm's gates: ruff-strict (`UP006` lowercase `list`, `C901` complexity <=10, no net-new `Any`), type-discipline (`LIT007`: no `TypeGuard`/`TypeIs`), line length 120, and `ui-api-types`: a change to the guardrail's config model needs `python -m litellm.proxy._lazy_openapi_snapshot` and `npm run gen:api` (in `ui/litellm-dashboard`), both files committed. Its tests fake the `privaite` package because it is absent in litellm CI. ## Verify, don't assume Reproduce a suspected bug before fixing it, and re-run the exact command the CI/gate runs (not a subset). For provider/streaming behavior, test against a real model through the local proxy (`python -m privaite --config ` then curl with `stream: true`); running with `deanonymization.enabled: false` proves the provider only ever received placeholders.