Ctrl K
ComunidadGuías…

AGENTS.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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 `<PERSON_1>` (a fixture,
   a template, a planted `?to=<EMAIL_ADDRESS_1>`) 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 `12<tab>NAME=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 <yaml>` then curl with
`stream: true`); running with `deanonymization.enabled: false` proves the provider
only ever received placeholders.