Files
bastion-agent/docs/pii-sterilization.md
Privatae e3074f50b0
ci / test (push) Successful in 1m1s
ci / personal-data (push) Successful in 3s
ci / reproducible (push) Successful in 4s
bastion agent 0.1.16
2026-10-25 23:16:50 +00:00

240 lines
14 KiB
Markdown

# PII sterilization
The `pii` package finds personal data in text and replaces it with
placeholder tokens, keeping the real values in a local vault. The agent's
`sanitize` stage runs it in process ([sanitize.md](sanitize.md)); it also
runs on its own as a small HTTP service. It is a deterministic program with
a model as one of its sensors: the program owns the control flow and every
change to the text, and the model only proposes.
## Purpose
Text that is going to leave the machine (a catalog, a chunk returned to a
host, context sent to a cloud model) first passes through `/sterilize`:
personal data is replaced with stable placeholder tokens, the real values stay
in a local vault, and `/rehydrate` puts them back, locally, on demand. A cloud
model can then work over the text without the values in it. Patterns alone
cannot do this: "my daughter goes to Lincoln Elementary" contains no pattern,
only meaning. The agent the bastion deploys runs the rules pass only (`flag`
mode), so names, street addresses and dates of birth stay in the text it
returns ([sanitize.md](sanitize.md#modes-manifest-sanitizemode)).
## Pipeline (`pii/pipeline.py`)
1. **Rules pass** (`detect.py`): deterministic detectors (below) for the
anchors a pattern can find. They never depend on a model. Overlaps are
resolved by type priority, then by length (an IP address is not a dotted
phone number).
2. **Recall pass** (`recall.py`): a model proposes verbatim spans with a type
from a closed vocabulary: names, addresses, employers, schools, medical
facts, credentials. Which model is chosen when the service starts (below).
3. **Grounding**: every proposed span must occur exactly in the input, be at
least two characters, have a known type and not contain a token or a
token's bare name (`PII_PERSON_1`), or it is dropped and counted. An invented span can never mint a token or touch the
text.
4. **Replacement**, by the program only: longest value first, every
occurrence. The model never rewrites the text, so a model that misbehaves
can cost recall but cannot corrupt the rest of the text. Replacement is by
exact substring, so a value is also replaced where it appears inside a
longer word ("Garcia" in "Garcias", "Marisol" in `Marisol_resume.pdf`): a
missed inflection would be a leak, and an extra match only costs
readability. No match may overlap a placeholder already in the text.
Matches that overlap each other, including a value that overlaps itself
("aba" in "ababa"), become one placeholder over their union, typed by the
longer value, so no fragment of either is left in the clear.
Placeholder-shaped text in the input is first replaced by a placeholder of
its own, so rehydrating gives back the literal text. Documents the agent
reads are raw files, so every such literal in them is escaped. Text sent to
the service may be the service's own earlier output (a stored conversation
sent back on every save), so there a literal the vault holds for the scope
is taken to be that token and passes through unchanged, and only a literal
the vault does not hold (`[PII_FOO_99]`) is escaped. A caller can ask for
every literal to be escaped with `escape_held_literals: true`. The cost of
replacement grows with the number of occurrences times the number of
placeholders in the text.
5. **Residual loop**: the rules pass runs again on the output until it is
clean, at most three rounds; residuals are replaced, not only reported.
6. **Optional verify** (`verify: true`): a second, adversarial model pass over
the sterilized text; its grounded findings go through replacement too.
If the recall pass fails, `/sterilize` answers 502: fail closed. Text without
the recall pass never leaves as "sterilized".
## Tokens, scopes, vault
- Tokens look like `[PII_<TYPE>_<n>]`: plain ASCII, they survive a round trip
through a cloud model, and the model can reason about `[PII_PERSON_1]`
across a conversation.
- **Stable within a scope**: the same (scope, type, value) always gets the
same token. **Isolated across scopes**: the same value in another scope gets
a different token, so two conversations or catalogs cannot be linked through
shared placeholders. Stability is per value as replaced, not per detection:
where a model's span overlaps a rule's hit (a phone number inside an address
the model proposed), the two become one placeholder for the combined text,
typed by the longer value. That placeholder differs from the one the phone
number gets on its own elsewhere. If a later run proposes the longer span
with a different type, the same bytes get a different placeholder. The
caller chooses the scope (default `default`; the agent uses the manifest's
`sanitize.scope`).
- The vault is SQLite, created 0600 under umask 077. Rows are never deleted:
the next number in a scope is the count of its rows plus one, so after a
delete that number either names a token that still exists (and the insert
fails) or, if the newest row was deleted, gives its token to a different
value. A delete feature would first need a separate sequence. In the service, every sterilize and rehydrate also
writes an audit row; `GET /scope/<id>` lists tokens, types and creation
times, never values.
## The service (`pii/service.py`)
Standard library only: the process holds the most sensitive data on the
machine, and every dependency is attack surface.
| Route | Auth | Does |
|---|---|---|
| `GET /health` | none | Liveness. |
| `GET /scope/<id>` | key | The token inventory of a scope (no values). |
| `POST /sterilize {text, scope?, verify?, escape_held_literals?}` | key | The pipeline; returns `{text, findings, stats}`, with `stats.recall` naming the recall backend. `escape_held_literals: true` escapes every placeholder-shaped string in `text`, even one the vault holds; the agent sends it for documents. |
| `POST /rehydrate {text, scope?}` | key | Values back in place of known tokens; returns `{text, restored, unknown_tokens}`. |
| `POST /flag {text}` | key | The rules pass alone: `{flagged, counts}`. No model, no vault rows, no audit. |
Request bodies are limited to 2 MiB.
`stats` in a `/sterilize` answer counts what this call did: `rules` (rule
findings), `llm` (grounded model proposals), `llm_ungrounded` (proposals
dropped by grounding), `residual_tokenized` and `verify_tokenized` (values
those passes actually replaced), and `literal_tokens_escaped`
(placeholder-shaped strings in the input that were escaped, each distinct
string counted once per call). Escaped literals appear in neither `findings`
nor the per-type counts. `stats.recall` names the recall backend, and
`stats.escape_held_literals` echoes which literal rule was applied, so a
caller that asked for it can check.
**A clean `/flag` does not mean "no personal data".** It sees only what the
rules find, never contextual data such as a name; anything that is kept or
sent on goes through `/sterilize`.
Configuration, from the environment:
| Variable | Default | Meaning |
|---|---|---|
| `PII_API_KEY` | (none) | Required. Without it the service refuses to start: `/rehydrate` returns real values, so there is no unauthenticated mode. (`PII_ALLOW_NOAUTH=1` exists for the unit tests only.) |
| `PII_BIND` | `127.0.0.1:8091` | The address the service listens on; this is its exposure control. Another address only when set. |
| `PII_DB` | `/data/vault.db` | The vault. |
| `PII_RECALL` | `llm` | The recall backend (below). |
| `LLM_URL` | `http://llm:8080` | The model server for `llm`. |
| `PII_LLM_TIMEOUT` | `300` | Seconds per model call. |
Two ways to run it, the same code:
- **Container**: `pii/Dockerfile` (a pinned `python:*-slim` image, no pip
install; the vault on a mounted `/data`, writable by uid 10001, the
unprivileged user it runs as). Inside a container the default loopback bind
reaches nothing else, so set `PII_BIND` and publish the port deliberately.
- **systemd**: `pii/pii.service`, with install steps in its header. It runs
with `DynamicUser`, a private 0700 state directory for the vault
(`/var/lib/privatae-pii/vault.db`), loopback bind (`127.0.0.1:8091`), and
the key in a 0600 environment file.
The bastion can make a service on the machine reachable to the owner's host:
with `PRIVATAE_PII_ADDR` set to the service's address, it forwards to it
through its tunnel and reports in its heartbeat that the service is there and
whether it answers. The bastion passes requests through without reading them
and never holds the API key; the caller sends it.
## Recall backends (`pii/recall.py`)
The recall pass is a pluggable sensor, chosen by whoever runs the service
with `PII_RECALL` when it starts, never per request, so a request cannot
downgrade it. Whichever sensor proposes, grounding drops any span that is not
exactly in the input and the program makes every replacement, so swapping the
sensor changes recall, never the integrity rules.
| `PII_RECALL` | What | Where it fits |
|---|---|---|
| `llm` (default) | A llama.cpp-compatible server's `/v1/chat/completions` at `LLM_URL`, with the output constrained by a JSON schema (`response_format: json_schema`), temperature 0, thinking turned off per request (`llmclient.py`). | A machine that can reach a model server. Best recall. |
| `gliner` | GLiNER (Apache-2.0), zero-shot named-entity recognition in this process, on CPU. Its labels are plain-English phrases mapped onto the closed type vocabulary (`recall.GLINER_LABELS`); `OTHER` is deliberately not a label. `GLINER_MODEL` (default `urchade/gliner_multi_pii-v1`), `GLINER_THRESHOLD` (0.5), `GLINER_VERIFY_THRESHOLD` (0.3, the more aggressive second pass). | A laptop with no GPU and no model server. |
Fail closed when it starts: `PII_RECALL=gliner` without the `gliner` package,
a model that will not load, or an unknown backend name stops the service before
it serves a request. (The `llm` server is not probed at start; an unreachable
one fails each `/sterilize` with 502.)
Why GLiNER and not Presidio: Presidio (MIT) is the usual framework, but it
brings spaCy and optionally torch into the process that holds the data. GLiNER
is one optional import, loaded only when chosen. What was taken from Presidio
instead is its list of recognizers, as deterministic rules. The cost is real:
the `gliner` package pulls in torch (a CPU build), hundreds of megabytes on
the machine.
## The rules pass (`pii/detect.py`)
Every detector is anchored by a checksum, a prefix or a fixed shape; none
depends on a model.
- **E-mail, IPv4/IPv6, MAC.** A dotted number right after a version word
(`version 1.2.3.4`, `v1.2.3.4`) is not an IP address.
- **Phone**: North American and other grouped national forms; international
numbers with `+` and a country code, any grouping, an optional `(0)` trunk
prefix; national numbers with a trunk `0` (`07700 900123`); a seven-digit
local number (`555-0142`) only after a word such as call, phone or tel.
- **SSN**: dashed or spaced; nine bare digits only after `SSN` or `social
security` within 40 characters. Numbers never issued (area 000, 666, 900+;
group 00; serial 0000) are left alone.
- **Cards** → `CREDIT_CARD`, with spaces, dots or dashes, checked with Luhn.
An expiry date or security code beside the card is not detected.
- **IBAN** → `BANK_ACCOUNT`, in either case: ISO 13616 mod-97 plus a per-country length
table. It runs before the card detector, so an IBAN's digits cannot be
claimed as a card number.
- **US ABA routing number** → `BANK_ACCOUNT`: the weighted checksum and a
`routing`, `ABA`, `RTN` or `transit` cue within 40 characters before it
(one random 9-digit string in ten passes the checksum alone).
- **Bitcoin legacy and P2SH addresses** → `BANK_ACCOUNT`: base58check
(double SHA-256). Bech32 (`bc1…`) addresses are not detected.
- **Secrets** → `CREDENTIAL`: AWS access key ids, GitHub classic and
fine-grained tokens, Slack tokens, Google API keys, Stripe keys, GitLab
tokens, OpenAI-style `sk-` keys, JWTs, and whole PEM private-key blocks (one
finding per block, at the highest priority, so the digits inside are never
claimed piece by piece).
## Portability
The package parses at the Python 3.8 floor shared with the agent
(`test_syntax_floor.py`), with `typing` generics and no newer standard-library
calls, so the systemd form runs on older interpreters as it is.
## Tests
- `python3 -m unittest pii.test_pii`: detectors, vault stability and
isolation, grounding, longest match first, the HTTP wire format against a
stub llama.cpp server, auth, and fail-closed configuration. No model is
needed.
- `PII_API_KEY=<key> pii/verify-live.sh [host:port]` runs eight checks
against a running service and its real model: rules-class sterilization,
contextual recall (school, address, doctor, medical), a round trip back to
the original, token stability, scope isolation, untouched non-personal text,
auth, and health.
## Limits
- **Sterilization narrows what is missed; it cannot guarantee nothing is.** The
recall pass is probabilistic. The layers (rules, model, residual loop,
optional verify) reduce misses but cannot make them impossible. Anything
that tells a person their data is protected has to say so.
- **The vault is not encrypted at rest.** The controls are the file's 0600 mode,
its directory, and the bind address.
- **Rows are never deleted** (see above), so there is no erasure yet.
- **Placeholders are plain tokens.** Surrogate values (invented names in place
of real ones, for fluency) are not done.
- **Through the service, a quoted placeholder that names a live token is read
as that token.** If text sent to `/sterilize` quotes `[PII_PERSON_1]` and the
vault holds `[PII_PERSON_1]` for the same scope, rehydrating gives the
vault's value in its place. The placeholder cannot be told apart from one
the service minted earlier, and treating it as a token is what lets
already-sterilized text be sterilized again without change. A quoted
placeholder the vault does not hold is escaped and comes back as written.
The agent's document stage is not affected: it asks for every literal to be
escaped.
- **One model call per `/sterilize`**, two with `verify`; the agent makes one
per 20,000-character chunk. That suits writes to context and memory; an
interactive path would want smaller calls or a smaller model.