![hushgate banner](https://raw.githubusercontent.com/johan-becker/hushgate/main/docs/assets/banner.svg) [![TypeScript](https://img.shields.io/badge/TypeScript-7-3178c6?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![Node](https://img.shields.io/badge/Node-%E2%89%A5%2020-339933?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/en/about/previous-releases) [![CI](https://github.com/johan-becker/hushgate/actions/workflows/ci.yml/badge.svg)](https://github.com/johan-becker/hushgate/actions/workflows/ci.yml) [![runtime dependencies](https://img.shields.io/badge/runtime%20dependencies-0-1f6feb?style=flat)](package.json) [![License](https://img.shields.io/badge/License-BUSL--1.1-orange?style=flat)](LICENSE) hushgate is a PII firewall that runs on your own machine, between your application and a cloud LLM API. Point the OpenAI or Anthropic SDK at it instead of the provider: it finds the personal data in the outgoing request, replaces it with stable pseudonymous placeholders, forwards the sanitised request, and puts the real values back into the response — so your code still sees `anna.schmidt@nordlicht.example` while the provider only ever saw `[EMAIL_1]`. ```sh npx hushgate setup ``` That is the whole start, with nothing to install first. It asks whether to try hushgate or set it up. Trying shows, on a page on `127.0.0.1`, exactly what the provider would receive, with each value linked to the placeholder that replaced it; sample documents are there if you have nothing to hand. Setting up writes `hushgate.config.json`, checks it and starts the proxy. npm's own install box says `npm i hushgate`. That adds hushgate to the project in the current folder, where it runs as `npx hushgate` and can be imported as a [library](#library); it does not put a `hushgate` command on your PATH. For that, use `npm i -g hushgate`. I built it for the situation a European team keeps hitting: the models they want are operated in the United States, and the data they would like to send is not allowed to go there. hushgate is the technical half of the answer — the half you can point an auditor at. It has zero runtime dependencies, makes no network calls of its own beyond the upstream you configure (`hushgate update` asks npm for the newest release, and only when you run it), and its whole test suite passes with the cable pulled out. Attachments go through the same door: a PDF or a Word file in a request is turned into text, the text is pseudonymised, and the document itself never reaches the provider. A document hushgate cannot read is refused, not forwarded. Every command output printed below was produced by running that command against this repository. [`examples/`](examples) contains the config and the local stand-in upstream, so you can reproduce all of it offline. ## 1. The problem A support tool drafts replies with GPT-4o. The ticket it summarises contains a name, an e-mail address, a phone number and an IBAN. That request leaves the EU. Under GDPR Chapter V that is a third-country transfer of personal data, and the DPO wants to know which categories go where, on what legal basis, and how you would prove any of it six months from now. The usual answers are all bad. Self-host a weaker model. Ask engineers to "be careful what you put in prompts". Buy a gateway that terminates your traffic in someone else's cloud, which is the same transfer with an extra hop. Or paste a regex into a middleware, which will happily match `DE89 3704 0044 0532 0130 01` — an IBAN whose checksum does not hold — and mangle the one that does. hushgate takes the narrower, checkable position: **the personal data never reaches the provider in the first place.** It runs inside your own network, the mapping from placeholder back to real value exists only in the process that issued it, and every request leaves an audit record naming the categories and their counts but never the values. ## 2. Quickstart Node 20 or newer, and nothing else: ```sh npx hushgate setup ``` Two questions — which provider, and your API key — and it opens a page on `127.0.0.1` showing the whole round trip, side by side: what you wrote and what the provider would receive, then the reply as it streams back with placeholders in it and the same reply with your data put back. Point at a value and the placeholder that replaced it lights up, in the reply as well. Four sample documents, two of them German, fill the page in one click, and a field for names shows what `redaction.dictionary.names` will do once you set it up. Nothing is written to disk, and the key stays inside that one process. The same command sets hushgate up for real. Only the provider has to be answered; the rest is optional and can be filled in later. It writes `hushgate.config.json`, checks it, and asks whether to start the proxy there and then; Enter says yes. Next time, start it on its own: ```sh npx hushgate serve ``` For scripts and CI, where there is no terminal to ask questions, `init` writes the same commented file with nothing filled in: ```sh npx hushgate init # write a commented starter config npx hushgate doctor # check it; non-zero exit on anything unsafe npx hushgate serve ``` `doctor` exits 1 on that first run, and is meant to: a fresh config has no `residency.allow`, so it warns that any upstream is permitted. Fill the allowlist in — or pass `--allow-warnings` to accept the warnings — and it goes quiet. That non-zero exit is the tool working, not the install failing. Install it once and the `npx` goes away, which is the form the transcripts in this README use: ```sh npm install -g hushgate hushgate setup ``` `npm i hushgate` without `-g` installs it into the current project instead, where it runs as `npx hushgate`. From a clone — the path for working on hushgate itself — `npm install && npm run build` first, and every `hushgate` below is `node dist/cli/main.js`. Then change one line in your application: ```diff import OpenAI from 'openai'; const client = new OpenAI({ + baseURL: 'http://127.0.0.1:8787/v1', apiKey: process.env.OPENAI_API_KEY, }); ``` ```diff import Anthropic from '@anthropic-ai/sdk'; const client = new Anthropic({ + baseURL: 'http://127.0.0.1:8787', apiKey: process.env.ANTHROPIC_API_KEY, }); ``` Or change no code at all — both official SDKs read these: ```sh export OPENAI_BASE_URL=http://127.0.0.1:8787/v1 export ANTHROPIC_BASE_URL=http://127.0.0.1:8787 ``` Your provider API key is forwarded untouched and is never written anywhere. (In multi-tenant mode the caller sends a hushgate key instead, and the provider credential comes from the environment — see §9.) `hushgate init` writes a config with the reasoning in it, not a bare skeleton: ```console $ hushgate init wrote hushgate.config.json Next: 1. Fill in the organisation block — it heads the Article 30 report. 2. Decide your upstreams, then list them in residency.allow with the legal basis you actually rely on. Run "hushgate residency --registry" to see the EU-hosted options hushgate knows about. 3. Run "hushgate doctor" until it is quiet. 4. Start it with "hushgate serve" and point your SDK at it. ``` ```jsonc { // hushgate configuration. Comments are allowed and stripped on load. // Every key is optional; anything left out uses the documented default. // Environment variables (HUSHGATE_*) override this file, and command-line // flags override those. // Loopback by default. Binding anything else requires tenants, because // hushgate holds the mapping back to real personal data. "host": "127.0.0.1", "port": 8787, // Where sanitised requests are forwarded. Swap these for an EU-hosted // endpoint when you have one: "hushgate residency --registry" lists them. "upstreams": { "openai": "https://api.openai.com", "anthropic": "https://api.anthropic.com" }, "redaction": { // pseudonymize | redact | hash | allow | block "defaultPolicy": "pseudonymize", "policies": { // Credentials should never reach a model, yours or anyone else's. "SECRET": "block" }, // Names, customers and codenames no detector could know about. "dictionary": { "names": [], "terms": [] }, // Your own identifiers, as named regular expressions. // { "name": "employee id", "pattern": "EMP-\\d{5}" } "custom": [] }, "residency": { // block | sanitize | warn | allow. Start at "warn" for a staged rollout if // you must, but "hushgate doctor" will keep reminding you. "mode": "sanitize", // Refuse a category outright, wherever it appears. "categories": {}, // An empty allowlist permits every upstream. Fill it in and hushgate // refuses to start against anything else. // { // "endpoint": "https://api.mistral.ai", // "jurisdiction": "FR", // "legalBasis": "Art. 28 DPA of 2026-01-12, processing in France" // } "allow": [] }, // Categories and counts, never values. This is the evidence. "audit": { "enabled": true, "path": "hushgate-audit.jsonl" }, // Heads the Article 30 report. hushgate cannot know any of it. "organisation": { "name": null, "contact": null, "dpo": null, "purposes": [] } // Multi-tenant operation: run "hushgate keys new " and paste the snippet. // "tenants": [] } ``` ## 3. What the provider actually receives To reproduce this section, start the local stand-in upstream shipped in [`examples/`](examples) and point hushgate at it. It is an OpenAI-compatible server on `127.0.0.1:9099` that logs the body it received — which is the whole point, since it lets you read exactly what hushgate forwarded. Nothing about the redaction path changes when the upstream is `api.openai.com`. ```console $ node examples/upstream.mjs & fake upstream on http://127.0.0.1:9099 $ hushgate serve -c examples/demo.config.json hushgate 0.8.0 listening on http://127.0.0.1:8787 config /home/you/hushgate/examples/demo.config.json upstreams openai http://127.0.0.1:9099 anthropic http://127.0.0.1:9099 policy pseudonymize by default; overrides: SECRET=block audit /home/you/hushgate/examples/hushgate-audit.jsonl — continuing chain from 5531e6c715d8 tenants none — single tenant, no key required (loopback only) routes POST /v1/chat/completions, POST /v1/messages, GET /healthz, GET /metrics /metrics is open on loopback Point your SDK at this address: OPENAI_BASE_URL=http://127.0.0.1:8787/v1 ANTHROPIC_BASE_URL=http://127.0.0.1:8787 ``` The `audit` line says where the trail is and where its hash chain stood when hushgate opened it: the committed trail shown in [§7](#7-the-audit-trail) ends at `5531e6c715d8…`, so new records continue from there. Send a ticket through it: ```sh curl -sS http://127.0.0.1:8787/v1/chat/completions \ -H 'content-type: application/json' \ -H 'authorization: Bearer sk-not-a-real-key' \ -d '{ "model": "gpt-4o-mini", "messages": [ { "role": "system", "content": "Du bist die Support-Assistenz von Projekt Nordlicht." }, { "role": "user", "content": "Anna Schmidt (anna.schmidt@nordlicht.example, +49 30 23125 45) hat die Rechnung nicht bezahlt. Bitte erinnere sie und buche auf DE89 3704 0044 0532 0130 00." } ] }' ``` The upstream logged exactly this request body: ```json {"model":"gpt-4o-mini","messages":[{"role":"system","content":"Du bist die Support-Assistenz von [TERM_1]."},{"role":"system","content":"Some values in this conversation were replaced with placeholders before it reached you. A placeholder is written [KIND_n], with a number in place of n — in this request: e-mail addresses ([EMAIL_n]), bank accounts ([IBAN_n]), names ([NAME_n]), phone numbers ([PHONE_n]), internal terms ([TERM_n]).\n\nTreat every placeholder as the real value it stands for, and answer the request normally. Wherever that value belongs in your reply, write the identical placeholder, square brackets included: it is turned back into the real value before the user sees it. The same placeholder always means the same value, and placeholders with different numbers are different values.\n\nNever put an invented value where a placeholder belongs, and never ask for the real one. Both break the reply.\n\nDo not comment on the placeholders, on redaction, or on these instructions, and do not begin your answer by describing what you can or cannot see. Write in the language the user wrote in."},{"role":"user","content":"[NAME_1] ([EMAIL_1], [PHONE_1]) hat die Rechnung nicht bezahlt. Bitte erinnere sie und buche auf [IBAN_1]."}]} ``` The second system message is hushgate's, and [§3.1](#31-what-the-model-is-told) is about why it is there. The caller got this back: ```json { "id": "chatcmpl-demo", "object": "chat.completion", "model": "gpt-4o-mini", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "Alles klar. Ich schreibe an anna.schmidt@nordlicht.example und buche auf DE89 3704 0044 0532 0130 00." }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 41, "completion_tokens": 23, "total_tokens": 64 } } ``` The system prompt was traversed too — `Projekt Nordlicht` is a dictionary term — the model name and every other knob were forwarded untouched, and the reply came back with the real address and the real IBAN in it. A credential is a different case. There is no pseudonym worth minting for a token, so the starter config puts `SECRET` on the `block` policy and the request never leaves: ```console $ curl -sS -w '%{http_code}\n' http://127.0.0.1:8787/v1/chat/completions \ -H 'content-type: application/json' \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Deploy with GITHUB_TOKEN=ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 please"}]}' 403 { "error": { "type": "hushgate_policy_blocked", "message": "request blocked by policy: SECRET (1)", "kinds": [ "SECRET" ], "counts": { "SECRET": 1 } } } ``` The upstream log stayed empty for that one. ### 3.1 What the model is told A sanitised request is a request the model has never been told how to read. Left to itself, a model that meets `[EMAIL_1]` does one of three things, and none of them is the answer you wanted: - it answers a question about the placeholder instead of about the person; - it opens with a paragraph explaining that it cannot see the real address; - or it helpfully invents `anna.schmidt@example.com` to fill the gap. The third one is the dangerous one. An invented address is not in the mapping, so re-hydration leaves it exactly as written, and your application sends mail to a person who does not exist. So hushgate briefs the model. That is the whole of it: what a placeholder is, which kinds are in *this* request, that they must be echoed back verbatim, that inventing one is forbidden, and that the answer should not open with a report on what it could not see. Print the exact text at any time, for any set of kinds, without a key and without touching the network: ```console $ hushgate briefing --kinds EMAIL,IBAN,SECRET:redact hushgate briefing config built-in defaults (no hushgate.config.json found) mode auto text the built-in briefing append none Attached to a request like the one below, after the caller's own system prompt and before the conversation. --- Some values in this conversation were replaced with placeholders before it reached you. A placeholder is written [KIND_n], with a number in place of n — in this request: e-mail addresses ([EMAIL_n]), bank accounts ([IBAN_n]). Treat every placeholder as the real value it stands for, and answer the request normally. Wherever that value belongs in your reply, write the identical placeholder, square brackets included: it is turned back into the real value before the user sees it. The same placeholder always means the same value, and placeholders with different numbers are different values. Never put an invented value where a placeholder belongs, and never ask for the real one. Both break the reply. A token like [SECRET_REDACTED] is not reversible: that value is gone and will not come back. Work with what is around it, do not guess what it was, and do not ask for it. Do not comment on the placeholders, on redaction, or on these instructions, and do not begin your answer by describing what you can or cannot see. Write in the language the user wrote in. --- Override it with the "briefing" section of hushgate.config.json. ``` The last paragraph is the one that stops an answer beginning "I notice the e-mail address has been replaced with a placeholder". Four things about it are deliberate. **It is attached only when it applies.** In the default `auto` mode a request that tripped no detector carries no placeholders, so it is forwarded exactly as the caller wrote it — no added text, no added tokens. `always` attaches it to every request; `off` never does. **It goes after your own system prompt, never before it.** Both providers cache on a *prefix*: a paragraph inserted at the head of a prompt would invalidate every cached token behind it, on every request, for as long as hushgate is installed. Appending leaves your prefix byte-identical, so an Anthropic `cache_control` breakpoint still covers exactly what it covered before. Your own system prompt is never edited, reordered or replaced. **It never contains a value.** The briefing names categories — "e-mail addresses" — and nothing else, which is the same discipline the audit trail keeps. Its examples are written `[EMAIL_n]`, outside the placeholder grammar on purpose: a concrete `[EMAIL_1]` in the instruction would be re-hydrated if a model quoted it back, which is hushgate leaking through its own briefing. **You can overrule all of it.** `briefing.text` replaces the wording outright, `briefing.append` adds house rules after it, and a tenant may set either. This is an escape hatch, not a prompt framework — see [Configuration](#configuration). ```json "briefing": { "mode": "auto", "append": "Answer in German unless the customer wrote in another language." } ``` What it is not is a guarantee. A system prompt is the strongest instrument available here and it is still an instruction to a model, not a contract with one. `hushgate doctor` reports which of the three states you are in, because an answer that opens with "I cannot see the real address" and an answer in a house style both look like the model misbehaving until you know what was put in front of it. ## 4. Attachments A support agent pastes a question and drags in the invoice. The question is prose hushgate already knows how to handle; the invoice is 18 KiB of base64 in a content part, and before this existed it went to the provider byte for byte — the redaction layer selects the leaves that carry prose, and a PDF is not one. So attachments go through the same door as everything else, one step earlier. The document is turned into text, the text takes the document's place in the conversation, and only then does redaction run. By the time the request is serialised there is nothing in it but prose, and every detector, policy and placeholder applies to the invoice exactly as it applies to the question. Send this: ```json {"model": "gpt-4o", "messages": [{"role": "user", "content": [ {"type": "text", "text": "Worum geht es in dieser Rechnung?"}, {"type": "file", "file": {"filename": "Rechnung Anna Schmidt.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjMK..."}} ]}]} ``` and this is what the provider receives — captured from the fake upstream, not written by hand: ```json [ { "type": "text", "text": "Worum geht es in dieser Rechnung?" }, { "type": "text", "text": "--- attachment: Rechnung [NAME_1].pdf (application/pdf, 1 page) ---\nRechnung Nr. 2026-0815\nKundin: [NAME_1]\nE-Mail: [EMAIL_1]\nTelefon: [PHONE_1]\nIBAN: [IBAN_1]\nBetrag: 1.240,00 EUR\n--- end of attachment ---" } ] ``` The reply comes back with the real values restored, as it does for any other request. The filename is pseudonymised along with everything else, and on purpose: `Kuendigung_Anna_Schmidt.pdf` names a person and discloses an employment event before anyone opens it. ### What it reads | Format | Read by | | --- | --- | | `.docx` `.xlsx` `.pptx` `.odt` `.ods` `.odp` | built in — ZIP via `node:zlib`, then the document XML | | `.txt` `.md` `.csv` `.tsv` `.json` `.xml` `.log` | built in — with BOM, UTF-16 and windows-1252 detection | | `.html` | built in — including `href`, `alt` and `title`, which carry addresses | | `.rtf` | built in | | `.eml` | built in — headers, RFC 2047 encoded subjects, and the text parts | | `.pdf` | an external extractor you configure. The Docker image ships `pdftotext`. | | images, audio, scans | nothing. There is no OCR and no transcription. | ### Why PDF is not parsed in-process A from-scratch PDF text extractor was written and measured while this was designed, and it was rejected on its results. On ordinary documents — a Google Docs export, a letter from a telecoms provider — it produced output that read fluently, was the right length, and had the recipient's name, street, postcode and customer number simply missing from it. A variant produced `johan.beck er@klinik.de`: an address split on glyph advance widths, which no detector matches and which would therefore reach the provider in the clear. Those two failures are not the same. Text that is *dropped* is text hushgate never forwards either, so nothing leaks — the model just gets less. Text that is *shredded* is forwarded, and is a leak. hushgate refuses both, but it is the second that decided this: a parser that silently mangles identifiers is worse than no parser at all, in a tool whose entire claim is that identifiers do not get through. So PDF extraction is delegated to a tool built for it, run as a separate process with the document on its standard input. Nothing derived from the request reaches its arguments — a file named `-l 1` handed to `pdftotext` as an argument truncates a 400-page document to one page and exits zero — nothing touches the disk, and the child gets a minimal environment rather than hushgate's, which holds your provider API key. ```jsonc "attachments": { "extractors": [ { "mediaTypes": ["application/pdf"], "command": "pdftotext", "args": ["-q", "-enc", "UTF-8", "-", "-"], "timeoutMs": 20000 } ] } ``` `hushgate doctor` reports whether each configured command is actually on PATH, so "PDFs are being refused" is something you find out before your users do. ### When it cannot read a document This is the case the design turns on, because it is where personal data would leak quietly. An extractor that fails is easy; an extractor that *succeeds* on a scanned page is not. `pdftotext` on a scan exits 0 and prints a single form feed, which is indistinguishable from "this document contains no text" to anything that only checks the exit status. So every extraction is asked a second question before it is believed: enough characters overall, enough per page, few enough replacement characters, few enough control characters, and words that are not overwhelmingly one and two letters long. Those are all measures of the wrong thing, though — they ask how the text *looks* rather than whether anything is hidden in it, and ordinary typesetting walks straight past them. A PDF whose address block carries normal kerning comes out of `pdftotext` as ``` E-M ail : a nna .sc hmi dt@ nor dli cht .ex amp le ``` which has no short words at all, reads as fluent to every ratio, and matches no detector. So the last check asks the question directly: run the detectors over a window of the text, then over the same window with its spacing closed up, and see whether closing the gaps reveals something that was not visible before. If it does, the gaps were what hid it, and the document is refused. A column of country codes closes up into `DEATCHFRIT`, which reveals nothing — so a table is not mistaken for a shredded address. Failing any of these makes the document unreadable, and `attachments.onUnreadable` decides what happens: | Setting | Behaviour | | --- | --- | | `block` (default) | The request is refused with **422** and nothing leaves the machine. | | `withhold` | The file is replaced by a note saying it was withheld; the rest of the request goes on. | | `forward` | The original bytes are sent. The one setting that lets an unread document reach the provider — `doctor` reports it as a failure, and the Article 30 report counts every document it affects. | ```console $ curl -sS -w '%{http_code}\n' http://127.0.0.1:8787/v1/chat/completions \ -H 'content-type: application/json' -d @scan-request.json 422 { "error": { "type": "hushgate_attachment_unreadable", "message": "attachment (application/pdf, 2416551 bytes) could not be pseudonymised: extracted only 1 characters from 3 pages, which is not a readable document", "mediaType": "application/pdf", "bytes": 2416551 } } ``` A remote `image_url`, an Anthropic `source.type: "url"`, and a provider `file_id` are all unreadable by the same rule. hushgate will not fetch them: making an outbound request of its own to pull in an unknown document, on a caller's say-so, is not a thing this program does. ### Seeing it without sending anything `hushgate extract` answers the question an auditor actually asks — show me what you sent — offline, against a file of their choosing, with no request and no upstream: ```console $ hushgate extract Rechnung.pdf Rechnung.pdf pdf, 17.7 KiB, 1 page, read by external.pdftotext 162 characters, 3 findings (EMAIL 1, IBAN 1, PHONE 1) Rechnung Nr. 2026-0815 Kundin: Anna Schmidt E-Mail: [EMAIL_1] Telefon: [PHONE_1] IBAN: [IBAN_1] Betrag: 1.240,00 EUR ``` It refuses what the proxy would refuse, so the two never disagree about a document. `hushgate scan` reads documents too, so a folder of contracts can be checked for what it holds before any of it goes near a model: name the folder, and every file in it and below is read in name order, with entries whose name starts with a dot and `node_modules` left alone. A file it cannot read is named as unreadable and is never counted as scanned — reporting "no personal data found" about a file nothing ever read is the one answer a sweep must not give. ### Limits Decoded size is checked before anything is allocated, per attachment and across the request, and a request may carry at most `attachments.maxCount` documents, 256 unless you set it. One with more is refused with 422 before any of them is decoded, whatever `attachments.onUnreadable` says, and its audit record holds a single entry saying why: every document is an entry in the record, and the limit is what keeps a record short enough for `hushgate audit` to read back. ZIP entries are capped by count, by size and by compression ratio: a 21 KiB crafted `.docx` has been measured driving a mature extractor to 178 MiB of resident memory. No XML is parsed with entity resolution, so XXE is structurally impossible rather than merely blocked. External extractors get a wall-clock timeout, a `SIGKILL` after it, and a cap on how much they may write. ## 5. Streaming Streaming is where a naive proxy falls apart. A model does not emit `[EMAIL_1]` as one token; it emits `[`, then `EMAIL`, then `_1]`, in three separate SSE events. A per-chunk find-and-replace sees none of them and hands the placeholder straight to the user. Here is the same conversation with `"stream": true`. First, what the upstream put on the wire — note `[EMAIL_1` cut in half across two events: ```console $ curl -sSN http://127.0.0.1:9099/v1/chat/completions -H 'content-type: application/json' \ -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"[NAME_1] ([EMAIL_1]) buche auf [IBAN_1]."}]}' data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Alles klar. "}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Ich schreibe"}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" an [EMAIL_1"}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"] und buche "}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"auf [IBAN_1]"}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"."}}]} data: [DONE] ``` And what the client sees through hushgate, for the same generation: ```console $ curl -sSN http://127.0.0.1:8787/v1/chat/completions -H 'content-type: application/json' \ -H 'authorization: Bearer sk-not-a-real-key' \ -d '{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"Anna Schmidt (anna.schmidt@nordlicht.example) hat die Rechnung nicht bezahlt. Bitte erinnere sie und buche auf DE89 3704 0044 0532 0130 00."}]}' data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Alles klar. "}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Ich schreibe"}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" an "}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"anna.schmidt@nordlicht.example und buche "}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"auf DE89 3704 0044 0532 0130 00"}}]} data: {"id":"chatcmpl-demo","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"."}}]} data: [DONE] ``` The third event emitted only ` an ` and held `[EMAIL_1` back, because that run could still grow into a placeholder. The fourth resolved it and flushed the whole address at once. §8 explains why that can never stall. ## 6. Data residency `hushgate residency` answers the DPO's question directly: where does each route send data, and on whose authority. This is the command an engineer screenshots. ```console $ hushgate residency hushgate residency config built-in defaults (no hushgate.config.json found) route openai.chat.completions (POST /v1/chat/completions) upstream https://api.openai.com endpoint OpenAI API — OpenAI, L.L.C. jurisdiction US — United States [third-country] Third country. The EU–US Data Privacy Framework covers certified organisations only; otherwise Article 46 safeguards apply. rule residency.allow (empty) legal basis none recorded enforcement sanitize (residency.mode) controls zero-retention via body store=false [set per request] no-training (account) [arranged with the provider] verdict PERMITTED — no allowlist is configured, so every upstream is permitted; add residency.allow to enforce one route anthropic.messages (POST /v1/messages) upstream https://api.anthropic.com endpoint Anthropic API — Anthropic PBC jurisdiction US — United States [third-country] Third country. The EU–US Data Privacy Framework covers certified organisations only; otherwise Article 46 safeguards apply. rule residency.allow (empty) legal basis none recorded enforcement sanitize (residency.mode) controls no-training (contract) [arranged with the provider] zero-retention (account) [arranged with the provider] verdict PERMITTED — no allowlist is configured, so every upstream is permitted; add residency.allow to enforce one 2 of 2 routes permitted. This is a technical control, not legal advice. See the README. ``` Jurisdictions come from a curated **offline** registry — data in the repository, no lookups, no telemetry. `hushgate residency --registry` prints all of it (abridged here): ```console $ hushgate residency --registry known endpoints (offline registry — extend it with residency.endpoints) US OpenAI API api.openai.com US Anthropic API api.anthropic.com US Google Gemini API generativelanguage.googleapis.com UNKNOWN Azure OpenAI Service *.openai.azure.com, *.cognitiveservices.azure.com DE AWS Bedrock (eu-central-1) bedrock-runtime.eu-central-1.amazonaws.com, bedrock.eu-central-1.amazonaws.com CH AWS Bedrock (eu-central-2) bedrock-runtime.eu-central-2.amazonaws.com, bedrock.eu-central-2.amazonaws.com ... FR Mistral AI — La Plateforme api.mistral.ai DE Aleph Alpha api.aleph-alpha.com DE IONOS AI Model Hub inference.de-txl.ionos.com, openai.inference.de-txl.ionos.com FR OVHcloud AI Endpoints *.endpoints.kepler.ai.cloud.ovh.net, *.endpoints.ai.cloud.ovh.net FR Scaleway Generative APIs api.scaleway.ai LOCAL Local model runtime (Ollama, vLLM, LM Studio, llama.cpp) localhost, 127.0.0.1, ::1, [::1], host.docker.internal Jurisdictions are where the operator documents the service as running. Confirm them against your own contract before relying on them. ``` Once `residency.allow` has an entry the list is closed, and **hushgate refuses to start against anything else**. A typo in an upstream URL is a startup failure, not a silent leak: ```console $ hushgate serve -c examples/demo.config.json --upstream-openai https://api.openai.com hushgate: configuration error residency policy refuses this configuration: upstreams.openai → https://api.openai.com is not on the residency allowlist [residency.allow] $ echo $? 1 ``` Enforcement is set globally, per route, or per category of personal data: | Mode | Behaviour | | --- | --- | | `block` | Refuse the request outright; the error names the rule that refused it. | | `sanitize` | Remove the personal data, then forward. The default. | | `warn` | Forward unchanged, and warn on stderr. For a staged rollout only. | | `allow` | Forward unchanged, silently. | All four are audited identically: every request writes a record naming each category found and its count, whatever the mode decided. `allow` differs from `warn` only in not printing the warning. Where a provider publishes a retention or training opt-out that can be set per request, hushgate attaches it automatically from the same registry — for example `store: false` on OpenAI. Controls that can only be arranged with the provider, an account setting or a contract clause, are reported rather than faked; with `residency.requireDataControls` set, an upstream that offers none refuses to start. ## 7. The audit trail Every request appends one JSONL record: when it happened, which route it came in on, how it ended, where it went, how many findings of each category it carried and the policy applied to each, and the residency verdict. **No values.** That includes the requests hushgate refused — a blocked category, a residency rule, a rejected tenant key, an exhausted quota, an oversized body — which are recorded as `outcome: "rejected"` or `"blocked"` with the status that says which. A brute force against tenant keys leaves a line per attempt. Each record carries the SHA-256 of the previous one, so the file is a hash chain. The trail below is [`examples/hushgate-audit.jsonl`](examples/hushgate-audit.jsonl), committed to the repository so this section can be read without running anything. It was recorded from the two requests in §3 and the stream in §5. Re-running them writes the same kind of records with new ids and timestamps, and therefore new hashes; a current build also writes `residency.endpoint`, which this file predates. ```json {"ts":"2026-08-23T10:40:09.900Z","id":"fa47a947-5723-4c1a-be43-2a9fdc10dca5","tenant":null,"route":"openai.chat.completions","outcome":"forwarded","status":200,"latencyMs":25,"stream":false,"upstream":"127.0.0.1:9099","tokens":64,"findings":{"TERM":1,"NAME":1,"EMAIL":1,"PHONE":1,"IBAN":1},"policies":{"TERM":"pseudonymize","NAME":"pseudonymize","EMAIL":"pseudonymize","PHONE":"pseudonymize","IBAN":"pseudonymize"},"residency":{"mode":"sanitize","rule":"residency.mode","jurisdiction":"LOCAL","controls":[]},"attachments":[],"prev":"0000000000000000000000000000000000000000000000000000000000000000","hash":"2db1a2b738345c5762bb12c02d1a5b514b3118015850af81035c086e2a042139"} ``` ### What a record holds Every field, in the order it is written: | Field | Meaning | | --- | --- | | `ts` | When the record was written: an RFC 3339 timestamp in UTC. | | `id` | A random UUID for this record. | | `tenant` | The tenant the request authenticated as, or `null` in single-tenant mode. | | `route` | The route it came in on, such as `openai.chat.completions`. | | `outcome` | `forwarded`; `blocked` by a `block` policy or rule; `rejected` before anything left the machine (a tenant key, a quota, a malformed or oversized body); or `failed` at the upstream. | | `status` | The HTTP status returned to the caller. | | `latencyMs` | How long the request took, in milliseconds. | | `stream` | Whether the response was an event stream. | | `upstream` | The host the request was sent to, or `null` if it was never sent. | | `tokens` | Tokens the upstream reported, or 0 when it reported none. | | `findings` | How many findings of each category, such as `"EMAIL":1`. | | `policies` | The policy applied to each category: `pseudonymize`, `redact`, `hash`, `allow` or `block`. | | `residency` | The residency decision, or `null` when the request never reached one: `mode` (`block`, `sanitize`, `warn` or `allow`), `rule` (the config path that decided), `jurisdiction`, `controls` (the retention and training controls set on the outbound request) and `endpoint` (the `residency.allow` entry that admitted the request, as configured, or `null` when no entry decided; absent from records written by 0.4.0 and earlier). | | `attachments` | One entry per document, in the order they appeared, or `[]`: `format`, `mediaType`, `bytes`, `chars` (characters of text extracted — a length, not the text), `pages`, `extractor`, `outcome` (`extracted`, `truncated`, `withheld`, `forwarded` or `blocked`) and `reason`. | | `dropped` | Present only after the writer failed to write records: how many were lost immediately before this one. See [How it is written](#how-it-is-written). | | `prev` | The `hash` of the record before it, or 64 zeros for the first. | | `hash` | SHA-256 of this record's line with the `hash` field taken out. | Filenames and document contents never reach the trail. A filename is routinely personal data on its own — `Kuendigung_Anna_Schmidt.pdf` names a person and an employment event — so it goes into the outbound prompt, where the detectors pseudonymise it like any other text, and nowhere else. An attachment's `reason` is assembled from fixed phrases, sizes and media types, and its `mediaType` is one hushgate recognises — the declared type when hushgate knows it or a configured extractor claims it, otherwise the type of the format it detected — so a caller cannot write text of its own into the trail through either. Where a field with a fixed set of values — `outcome`, `residency.mode`, a policy, an attachment's `outcome` — is given anything outside that set, the record says `invalid` rather than copying what it was given. Tenant ids are written as configured, and they are the one field where the name you choose decides whether the trail itself holds personal data. [Multi-tenant operation](#multi-tenant-operation) explains why they should name teams or applications rather than people. ### Verifying it `hushgate audit verify` walks the chain. Name the trail after the subcommand, or with `--file`; without one it reads the global trail from the configuration. A file named without `-c` is verified without loading any configuration at all, so an auditor handed a copy needs nothing else: ```console $ hushgate audit verify examples/hushgate-audit.jsonl audit trail /home/you/hushgate/examples/hushgate-audit.jsonl records 3 chain intact head 5531e6c715d8f4e7d6e5cbccd20c1e1005db9e8be9264073398ea37560c81b2f Anchor the head hash outside hushgate (a ticket, a signed note, another system) and pass it back with --anchor, so that records dropped from the end are detected too. ``` Change a single field of a single record — here the count that says a credential was blocked, edited from 1 to 0 — and it reports exactly where the chain broke: ```console $ sed 's/"SECRET":1/"SECRET":0/' examples/hushgate-audit.jsonl > examples/tampered.jsonl $ hushgate audit verify examples/tampered.jsonl audit trail /home/you/hushgate/examples/tampered.jsonl records 3 chain BROKEN at 1 point break record 2 (altered), line 2: id fa030c32-7b27-4947-9448-8567b7ca154b, ts 2026-08-23T10:40:09.921Z record hash is 07b73ca315a9…, but its contents hash to 25d22e0325ab… head 5531e6c715d8f4e7d6e5cbccd20c1e1005db9e8be9264073398ea37560c81b2f Records outside the points listed still link correctly. At each listed point a record was altered, or records were inserted or removed. $ echo $? 1 ``` Every break is listed, up to a hundred and then counted, with the record's number and the line of the file it is on. A record is `altered` when it no longer hashes to its own `hash`, and `unlinked` when its `prev` does not name the record before it, which is what an insertion or a removal leaves behind. The chain is judged afresh after each break, so one edited record is one break; the records after it are not condemned with it. On a broken chain, `head` is the hash stored in the last record. Three more things are reported, because each would otherwise pass in silence: - A line that is not a record — not JSON, not UTF-8, missing a field, or over 16 MiB — is listed as `unreadable` with its line number, and fails verification: nothing it held is covered by the verdict. hushgate never writes a line that long; see [How it is written](#how-it-is-written). - A record that carries `dropped` is reported as a gap, such as `2 records could not be written before record 7 (line 7)`. The writer linked that record to the last one that reached the disk and said what is missing, so the chain still verifies, with a warning that requests were served for which there is no evidence. - A trail with no records verifies trivially, and so does one that was emptied. verify says so in a warning. `--json` prints the same verdict for a script, with the keys `file`, `ok`, `chainOk`, `records`, `firstBreak`, `head`, `from`, `breaks`, `breakCount`, `gaps`, `gapCount`, `dropped`, `anchors`, `malformed` and `malformedCount`. `ok` is the whole verdict and always agrees with the exit code: no breaks, every anchor found, every line readable. `chainOk` is the chain's own verdict — no breaks and every anchor found — leaving unreadable lines aside. The exit code is 0 when the trail verifies, 1 when it does not, and 2 for a usage error: a malformed hash, a file named twice, or a flag that does not apply, such as `--from` on verify. ### Anchoring the head, and rotating the trail A chain shows that nothing in it was altered, inserted or removed. It cannot show that records were cut off the end, because a shorter chain is still a consistent one, and anyone who can write the file can recompute every hash after an edit. Both are answered the same way: keep the head somewhere hushgate cannot rewrite. `serve` helps. Its banner says where each chain stood when it was opened — `continuing chain from 5531e6c715d8` in §3, or `starting a new trail` — and on shutdown it prints each trail's head in full, so the service log, journald or the container log, holds one at no cost: ```console ^C hushgate: shutting down audit /home/you/hushgate/examples/hushgate-audit.jsonl head 5531e6c715d8f4e7d6e5cbccd20c1e1005db9e8be9264073398ea37560c81b2f ``` Pass a head back with `--anchor` (repeatable; upper or lower case), and verification fails unless a record that verifies carries exactly that hash. Here the last record has been cut off a copy of the trail. The chain still links; the anchor is gone: ```console $ head -n 2 examples/hushgate-audit.jsonl > examples/truncated.jsonl $ hushgate audit verify examples/truncated.jsonl --anchor 5531e6c715d8f4e7d6e5cbccd20c1e1005db9e8be9264073398ea37560c81b2f audit trail /home/you/hushgate/examples/truncated.jsonl records 2 chain intact head 07b73ca315a9c30abe0fe7a96c855dec7ccc45326dc2c9c04d18a13d3d830576 anchor 5531e6c715d8f4e7d6e5cbccd20c1e1005db9e8be9264073398ea37560c81b2f NOT FOUND An anchored head that no verified record carries means records were removed from the end of the trail, or the trail was rewritten, after the anchor was taken. $ echo $? 1 ``` On the whole trail the same anchor reads `found at record 3 (line 3), 0 records after it`: where it is, and how many records were appended since it was taken. A trail that held no records when hushgate stopped has the genesis hash, 64 zeros, as its head, and the shutdown line says `(no records yet)` after it. That head can be kept like any other: verify reports it `found at the start of the chain`, followed by every record written since, and `--json` gives it `index` 0. The hash given to `--continues-from`, below, is found there too when it is also passed as an anchor. To rotate the trail: 1. Stop hushgate. The lines it prints last give the head of each trail it was writing. 2. Move the file away: `mv hushgate-audit.jsonl hushgate-audit-2026-09.jsonl`. 3. Start hushgate. The banner says `starting a new trail`, and the new file's chain starts from 64 zeros. 4. Keep the old head with the archive. `hushgate audit verify hushgate-audit-2026-09.jsonl --anchor ` checks it, now or in five years. Never rotate with `copytruncate`. It copies the file and then empties it in place, so records written between the two steps are lost, and the first record in the emptied file links to one that exists only in the copy. hushgate notices a trail moved, deleted or truncated underneath it and says so on stderr, with the head to anchor, but that is a warning about damage, not a way to rotate. A trail whose beginning was removed on purpose — records past their retention period, cut from the front — no longer starts from 64 zeros, and verify reports its first record as `unlinked`. Note the hash of the last record you remove when you remove it, and pass it back with `--continues-from`: ```console $ tail -n 1 examples/hushgate-audit.jsonl > examples/pruned.jsonl $ hushgate audit verify examples/pruned.jsonl --continues-from 07b73ca315a9c30abe0fe7a96c855dec7ccc45326dc2c9c04d18a13d3d830576 audit trail /home/you/hushgate/examples/pruned.jsonl records 1 from 07b73ca315a9c30abe0fe7a96c855dec7ccc45326dc2c9c04d18a13d3d830576 chain intact head 5531e6c715d8f4e7d6e5cbccd20c1e1005db9e8be9264073398ea37560c81b2f Anchor the head hash outside hushgate (a ticket, a signed note, another system) and pass it back with --anchor, so that records dropped from the end are detected too. ``` ### Tenants' trails A tenant with its own `audit.path` is recorded in that file and nowhere else. `--tenant ` verifies, or reports on, that tenant's trail; a tenant without one is recorded in the global trail, which is then what is read, and a note says so. A plain `hushgate audit verify` still checks the global trail alone, and ends by naming the tenant trails it did not check. `--all` checks every distinct trail the configuration names — the global one and each tenant's, once per file however many tenants share it — prints a block for each, ends with `All 3 trails verified.` or `1 of 3 trails did not verify.`, and fails if any one does, including a trail that cannot be read. `--anchor` and `--continues-from` describe a single trail, so they do not combine with `--all`. ### Checking it without hushgate The verifier is a convenience, not the authority. Each line is its record serialised as JSON with no added whitespace and the fields in the order above, and its `hash` is the SHA-256 of that line with `,"hash":"<64 hex digits>"` taken out: ```console $ head -n 1 examples/hushgate-audit.jsonl | sed 's/,"hash":"[0-9a-f]\{64\}"}$/}/' | tr -d '\n' | sha256sum 2db1a2b738345c5762bb12c02d1a5b514b3118015850af81035c086e2a042139 - ``` That is the `hash` at the end of the first line and the `prev` of the second. Because the hash covers bytes, a line has to stay in exactly that form. One that parses to the same record but is spelt differently — added whitespace, a number written another way, a key given twice — is not what was hashed, and `audit verify` reports it as altered. A duplicate key is the case that matters: two JSON readers can disagree about which of its values the record holds. ### The report `hushgate audit report` turns the same file into an Article 30 style record of processing activities, in Markdown or, with `--json`, as data: ```console $ hushgate audit report -c examples/demo.config.json # Record of processing activities _Article 30 style summary, generated by hushgate from its own audit trail._ - **Generated**: 2026-10-27T18:27:34.342Z - **Generator**: hushgate 0.8.0 - **Source**: /home/you/hushgate/examples/hushgate-audit.jsonl - **Configuration**: /home/you/hushgate/examples/demo.config.json - **Period**: beginning → end (UTC days, inclusive; records from 2026-08-23T10:40:09.900Z to 2026-08-23T10:40:09.939Z) - **Controller**: Nordlicht GmbH - **Contact**: datenschutz@nordlicht.example - **Data protection officer**: A. Datenschutz - **Audit chain**: intact over 3 records (head 5531e6c715d8f4e7d6e5cbccd20c1e1005db9e8be9264073398ea37560c81b2f) ## Purposes of processing - Drafting customer support replies ## Volumes - Requests: **3** - Tokens reported by providers: **128** - Outcomes: blocked 1, forwarded 2 - Routes: openai.chat.completions 3 - Residency enforcement: n/a 1, sanitize 2 - Requests forwarded unredacted under residency warn or allow: **0** ## Categories of personal data | Category | Findings | Requests | Handling | | --- | ---: | ---: | --- | | EMAIL | 2 | 2 | pseudonymize | | IBAN | 2 | 2 | pseudonymize | | NAME | 2 | 2 | pseudonymize | | PHONE | 1 | 1 | pseudonymize | | SECRET | 1 | 1 | block | | TERM | 1 | 1 | pseudonymize | ## Recipients and transfers | Recipient | Jurisdiction | Transfer | Requests | Tokens | Safeguard recorded | | --- | --- | --- | ---: | ---: | --- | | 127.0.0.1:9099 | Your own infrastructure (LOCAL) | no transfer | 2 | 128 | Processing on our own hardware; no transfer occurs. | _Safeguards are read from the configuration in force when this report was generated, not from the time of each request: an allowlist entry added, changed or removed since then changes this column._ ### Controls applied to outbound requests - 127.0.0.1:9099 (LOCAL): none set per request ## Technical measures - Where a category is handled by `pseudonymize` (EMAIL, IBAN, NAME, PHONE, TERM), its values were replaced with placeholders before the request left the machine, and restored in the response. - Where a category is handled by `block` (SECRET), the request was refused and nothing in it left the machine. The audit trail records categories and counts only; it holds no personal data from requests. Identifiers the operator chooses, such as tenant ids, are recorded as configured. --- This document is generated from a technical control. It supports a record of processing activities; it is not legal advice and does not by itself make any transfer lawful. ``` It says what the trail supports and no more. The whole trail is verified, whatever the period, because a break outside it still means the figures inside cannot be trusted; the **Audit chain** line reads `intact` only when every link holds, every anchor is found and every line can be read, and otherwise names the first break with its line, the number of breaks, the missing anchor or the unreadable lines. Where they apply, the document also states: - what was sent **unredacted**. Categories forwarded under a residency `warn` or `allow` mode, or an `allow` policy, are counted as such and never listed as pseudonymised, and with attachments switched off the document says that documents were forwarded unread; - a safeguard it cannot attribute. Records without `residency.endpoint` are matched to the allowlist by host, and where several entries share a host with different legal bases, the row says **ambiguous** and lists them; - records the writer could not write, as **N audit records were lost** when the stretch they were lost in — from the record before the loss to the one that counts it — lies inside the period, and as **up to N audit records may be missing from this period**, with the stretch, when it reaches past the period's edge, since the trail cannot say on which side they fell. Either makes every figure a lower bound, and the **Audit chain** line names the gaps of the whole trail; - the configuration it was generated with, or **none found — defaults used**, and the hushgate version that generated it; - tenants whose own trails it does not include, under **Not covered**, when it reads the global trail, whether by default, by file name or through the `--tenant` of a tenant recorded there. `--from` and `--to` take UTC days, `YYYY-MM-DD`, both inclusive; a date that does not exist, such as `2026-02-31`, or a `--from` after the `--to`, is a usage error. `--tenant` chooses which trail is read and does not filter records: a tenant without a trail of its own is reported together with everything else in the global one, and a note on stderr says so. The report takes `--anchor` and `--continues-from` as verify does, always loads the configuration for the organisation and its safeguards, and exits 1 when the chain has a break, an anchor is missing or a line is unreadable. Lost records alone do not change the exit code; the document states them. ### How it is written Each record is appended with one synchronous write, so a request hushgate has answered for has its record in the kernel rather than in a buffer a crash would lose. On a local disk that takes microseconds; on a network filesystem it can stall every request for far longer. Keep the trail on a local disk. New trail files are created readable by their owner and group only. `serve` opens every trail before it binds the port, and if one cannot be opened — a directory it may not write, a read-only volume, a path that is a directory — it exits 1 with nothing listening, because a proxy that cannot write its trail must not forward personal data without one. `hushgate doctor` runs the same check ahead of time. A record that cannot be written once hushgate is running, on a full disk say, is counted and reported on stderr. The next record that does reach the file carries the count as `dropped`, if hushgate writes one before it stops. If it does not, because it was stopped, restarted or killed while the disk was still full, the trail itself never shows those records: they are counted only in the shutdown line, as `(3 records could not be written, 3 of them not counted in the trail)`, and in `hushgate_audit_dropped_records_total`, which exposes the count to monitoring. Anything above zero there is worth an alert, and worth reading the shutdown line for. A record whose line would be longer than the 16 MiB a reader takes is not written at all and counts as lost the same way, so that no single request can leave a line that `audit verify` refuses and a break after it that nobody made; `attachments.maxCount` keeps the proxy's own records far below that. On shutdown, requests still in flight get five seconds to finish; after that their upstream calls are abandoned and they are recorded as `failed`. Either way their records are written before the trails are closed. Tenants that name the same file, or the global trail's file however it is spelt, share one writer and one chain, and each record still names its tenant. Two hushgate processes must not share a file: each would extend a chain of its own, and the file would break at the first switch between them. ## 8. How it works ### Structural traversal, not a regex over the blob A request body is parsed and walked by path rules, so hushgate redacts the *conversation* rather than the JSON. `model`, tool identifiers, `stream`, `max_tokens` and the rest are forwarded byte for byte; the prose, the tool-call arguments and the tool schemas are not. | Provider | Paths that carry user content | | --- | --- | | OpenAI | `messages.*.content` (string form), `messages.*.content.*.text`, `messages.*.content.*.input_text`, `messages.*.name`, `messages.*.refusal`, `messages.*.tool_calls.*.function.arguments`, `messages.*.function_call.arguments`, `tools.*.function.description`, `tools.*.function.parameters.**`, `functions.*.description`, `functions.*.parameters.**` | | Anthropic | `system`, `system.*.text`, `messages.*.content`, `messages.*.content.*.text`, `messages.*.content.*.content`, `messages.*.content.*.content.*.text`, `messages.*.content.*.input.**`, `tools.*.description`, `tools.*.input_schema.**`, `metadata.user_id` | Tool-call arguments are a JSON *string*; redacting them as text is safe because a placeholder contains no character that JSON escapes, and the model's reply is re-hydrated the same way. Image content parts are deliberately excluded: a `data:` URL is megabytes of base64 in which no detector can validate anything, and scanning it would only burn time. ### Detectors validate, they do not pattern-match Every detector that can check itself does. This is the difference between a tool that flags `4111 1111 1111 1112` and one that does not. | Kind | Validation | | --- | --- | | `IBAN` | ISO 13616 / ISO 7064 MOD 97-10, folded digit by digit because the number does not fit a JS `number`; length table for 76 countries. The remainder must be exactly 1. | | `CREDIT_CARD` | Luhn plus an issuer-prefix check. A 16-digit run that fails Luhn is not a card. | | `BANK_ACCOUNT` | The pre-IBAN pair. The Bankleitzahl is looked up in the Bundesbank directory shipped with hushgate, and the Kontonummer is then run through *that bank's* check-digit method — all 91 the directory names, implemented from the Bundesbank spec and pinned by its 457 official test account numbers. That is what makes an unlabelled `532013000 / 37040044` safe to report. UK sort codes and US routing numbers are covered too; the routing number carries its own 3-7-1 checksum. | | `GERMAN_TAX_ID` | ISO 7064 MOD 11,10 check digit **and** the digit-frequency rule: within the first ten digits exactly one digit repeats — twice, or three times — and no more. Both must hold. | | `SOCIAL_SECURITY_ID` | Versicherungsnummer: the birth date inside it must be a real date, the Bereichsnummer must be one the DRV allocated, and the weighted check digit must agree with the twelve digits the eleven characters expand to. All three. | | `HEALTH_INSURANCE_ID` | Krankenversichertennummer: the leading letter expands to two digits, and the check digit is computed over the result. | | `ID_CARD_NUMBER` / `PASSPORT_NUMBER` | ICAO 9303 check digit, weights 7-3-1 modulo 10. One detector for both: the serial does not say which document it came off, so the word next to it decides, and failing that the series letter. | | `TRAVEL_DOCUMENT_MRZ` | The machine-readable zone off a passport or identity card, TD1, TD2 and TD3. The densest personal data hushgate will ever see — name, date of birth, sex, nationality, expiry and document number in one block — and the most completely checksummed: four or five independent ICAO 7-3-1 digits, one of them a composite over the others. A 44-character uppercase blob shape-matches and is rejected; that is the point. Reported as one span covering the whole block, so nothing inside it can carve it up. | | `EU_VAT_ID` | Per-country length and shape for every member state. Germany's ISO 7064 MOD 11,10 check digit is available but **off by default** — a wrong VAT number on an invoice is common, and refusing to redact it is the worse mistake. `redaction.detectors.vatId.requireGermanCheckDigit` turns it on. | | `GERMAN_TAX_NUMBER` | Steuernummer, which has no nationwide check digit. The slash-grouped form a Finanzamt prints — `27/123/45678` — is a shape almost nothing else has and stands alone; the same digits run together need a label. | | `COMMERCIAL_REGISTER_ID` | `HRA`/`HRB` carries its own label, and neither string precedes digits in ordinary German prose. The sibling registers `VR`, `GnR` and `PR` are deliberately not read: `VR 1234` is also a version. | | `DRIVER_LICENCE_ID` | No check digit exists outside the issuing authority, so a label is required — plus three structural rules, because `Verkehrsamt` is exactly as long as a licence number and sits next to the very words that would license one. | | `VEHICLE_PLATE` | The closed register of Unterscheidungszeichen. The shape alone is also an article number and a norm reference; the district code is what makes a plate a plate. | | `POSTAL_ADDRESS` / `POSTCODE` | A street-type suffix on a token plus a house number, graded: `-straße` fires alone, `-weg` needs a postcode or a label, because *Parkplatz* and *Radweg* are ordinary nouns. Five digits alone are never a postcode — a label, a country prefix or a place name licenses it. | | `ICD_CODE` / `MEDICATION` | Article 9 data, so the recall bar is higher and the false-positive risk worse: `E11.9` is a diabetes diagnosis, a software version and a spreadsheet cell. Codes are read against a catalogue and blocked by the words around them; a labelled code needs no catalogue. | | `BIC` | ISO 9362 shape, and the two country characters must be a real ISO 3166-1 alpha-2 code. | | `DEVICE_ID` | IMEI by its own Luhn digit; UUID by the version and variant nibbles RFC 9562 actually issues. | | `SESSION_TOKEN` | The cookie or header *name* plus an assignment — never the value's entropy. That was measured on this corpus and it does not separate the two populations. | | `EMAIL` | A practical RFC 5322 grammar, then the structural checks a regex cannot express: 64-byte local part, 254-byte total, 63-byte labels, no leading, trailing or doubled dot. | | `PHONE` | E.164, `00` international and German national forms, with subscriber-length bounds; `(0)` trunk notation tolerated. | | `IPV4` / `IPV6` / `MAC` | Octets bounded at 255; IPv6 `::` compression handled, embedded-IPv4 form included. | | `DATE_OF_BIRTH` | German `DD.MM.YYYY` and ISO, real calendar dates including leap years, inside a configurable birth-year window. | | `SECRET` | Anthropic and OpenAI keys, GitHub tokens classic and fine-grained, AWS access key ids, Google API keys, Slack tokens, JWTs whose header segment actually decodes, PEM private-key blocks. | | `URL_CREDENTIALS` | `scheme://user:pass@host`. | | `NAME` / `TERM` | Your dictionary: case-insensitive, whole-word, longest match wins. | | custom | Your named regexes; the name becomes the category, e.g. `EMPLOYEE_ID`. | Detectors return *candidates*, and candidates overlap. Resolution is central and deterministic: the longest span wins, ties broken by detector priority (`SECRET` 100 > `TRAVEL_DOCUMENT_MRZ` 99 > `URL_CREDENTIALS` 95 > `IBAN` 90 > `CREDIT_CARD` 85 > `GERMAN_TAX_ID` 80 > `EMAIL` 75 > `BANK_ACCOUNT` 72 > … > dictionary 40). ### What the detectors are shown Detectors do not run on the text the caller wrote; they run on a private scan copy of it, and any span they find is mapped back to the exact original characters before the value is replaced — hushgate forwards what the caller wrote, so the copy exists only to be scanned. Two transformations build it: - **Invisible characters are dropped** before matching: the whole `Cf` class (zero-width space, joiners, word joiner, byte-order mark, soft hyphen, bidi controls) plus every `Default_Ignorable_Code_Point` (variation selectors, Hangul fillers). A zero-width space inside an e-mail address renders as nothing, reads as an address to a human, and would otherwise match no pattern at all. - **NFKC is applied per combining cluster** — a base character plus the combining marks that follow it — not over the whole string. That composes a decomposed umlaut and folds full-width letters into ASCII ones while keeping the offset map exact. The deliberate cost: sequences that compose only across a cluster boundary (Hangul jamo, a halfwidth voiced mark after a kana) are left alone. Pure ASCII takes a fast path: every invisible above is non-ASCII and NFKC is the identity on ASCII, so ASCII prose skips the whole mechanism at one linear test. Two residual limits follow from what this does *not* cover. A detector still has to recognise the shape afterwards: `DATE_OF_BIRTH` matches `14.03.1987` and ISO forms, not "fourteenth of March" written out. And the folds are canonical, not cultural — `PHONE` matches E.164, `00` international and German national spellings; a number written the way another culture groups it, or in letters (`null fünf …`), is prose as far as every detector is concerned. ### Placeholders, and the injection case Within one session the same value of the same kind always maps to the same placeholder, and two different values never share one. The interesting part is what happens when the caller's own text already contains something that looks like a placeholder — otherwise a user could type `[EMAIL_1]` into a prompt and be handed somebody else's address on the way back. Placeholder-shaped literals in the input are themselves captured, under a reserved `LITERAL` kind, and re-issued — so no token hushgate mints can ever collide with one that arrived from outside: ```console $ node examples/redact.mjs upstream sees : [NAME_1] <[EMAIL_2]> — the template still says [LITERAL_1]. Steuer-ID [GERMAN_TAX_ID:a86f2fa599f1], host 10.14.2.7. findings : NAME=pseudonymize EMAIL=pseudonymize LITERAL=pseudonymize GERMAN_TAX_ID=hash IPV4=allow restored : Anna Schmidt — the template still says [EMAIL_1]. Steuer-ID [GERMAN_TAX_ID:a86f2fa599f1], host 10.14.2.7. ``` The real address became `[EMAIL_2]`, not `[EMAIL_1]`, and the caller's literal came back exactly as typed. `GERMAN_TAX_ID` was on the `hash` policy, so it is stable but irreversible and stays hashed in the response; `IPV4` was on `allow` and was never touched. ### Streaming: hold back only what could still become a token Re-hydration happens in two layers. The **byte layer** keeps a running buffer and emits everything except the trailing run that could still grow into a placeholder. Only the last `[` can start a viable prefix — `[` is not a character a placeholder body may contain, so any earlier one is already ruled out. Three properties make that safe: - a viable prefix is bounded by `MAX_PLACEHOLDER_LENGTH` (72), so the buffer is bounded: no deadlock, and no waiting for a byte that never comes; - `flush()` always emits whatever is still held, so a stream that dies mid-token still delivers its tail verbatim; - substitution is a single left-to-right pass, so a restored value that itself looks like a placeholder is never re-examined. The **SSE layer** sits on top, because the split is usually not at the byte level at all but between events. The stream is parsed into events; each delta field is fed to a re-hydrator that remembers across events, keyed by path and block index so two concurrent tool calls never mix their buffers; every other string in an event is complete by construction and is substituted on the spot. Anything still incomplete when a block ends is emitted as one synthetic event modelled on the last real one — before `content_block_stop`, before `[DONE]`, and at end of stream — so nothing is ever dropped and the client always sees a well-formed stream. The test suite splits a placeholder at **every byte offset**, in two- and three-way splits, and one character at a time. ### Cost Redaction is linear in input size. Measured on an Apple M1 Pro, Node 26, mean of 200 runs per size, on German prose carrying roughly one finding per 45 bytes: | Input | Time | | ---: | ---: | | 1 KiB | 0.15 ms | | 4 KiB | 0.51 ms | | 16 KiB | 2.04 ms | | 66 KiB | 8.9 ms | Four times the input, four times the work — measured across four doublings up to the 4 MiB body limit, and bounded on every CI run by the `does not degrade super-linearly` test, which compares the growth factor of one doubling against the next rather than trusting a single ratio. A typical chat request sits in the first row of that table, against a network round trip measured in hundreds of milliseconds. ## 9. Reference ### Commands | Command | Purpose | | --- | --- | | `hushgate init [--path

] [--force]` | Write a commented starter `hushgate.config.json`. | | `hushgate serve [options]` | Run the redacting proxy in the foreground. | | `hushgate scan [--json] [--show-values] [-q] ` | Find personal data in files and folders, including PDFs and Office documents. Exits 3 when it finds any. | | `hushgate extract [--raw] [--json] ` | Show the text a document would be sent as. | | `hushgate check [-q]` | Redact standard input to standard output, for piping. | | `hushgate residency [--json] [--registry]` | Where each route sends data, and on whose authority. | | `hushgate briefing [--tenant ] [--kinds ] [--json]` | Print what hushgate tells the model about the placeholders. | | `hushgate doctor [--json] [--allow-warnings]` | Validate config and residency, and verify every audit trail and whether `serve` could write it. For CI. | | `hushgate audit verify [ \| --tenant \| --all] [--anchor ]... [--continues-from ] [--json]` | Walk the whole hash chain: every break, gap and unreadable line, with line numbers, and every anchor. See [§7](#verifying-it). | | `hushgate audit report [ \| --tenant ] [--from ] [--to ] [--anchor ]... [--continues-from ] [--json]` | Article 30 style record of processing, over UTC days. See [§7](#the-report). | | `hushgate keys new ` / `hushgate keys hash` | Mint a tenant key, or hash an existing one. | | `hushgate update [--check [--json]] [--to ] [--yes]` | See whether a newer release exists, and install it where npm put this one. The only command that contacts anything but the upstream, and only when run. See [§12](#updating). | | `hushgate help [command]`, `hushgate version` | Help, and the version. | `serve` additionally takes `-H/--host`, `-p/--port`, `--upstream-openai`, `--upstream-anthropic`, `--audit ` and `--no-audit`. Every command except `setup`, `init` and `keys` takes `-c/--config`. Options may stand before the command as well as after it, so `hushgate --json residency` works; `hushgate help ` and `hushgate --help` print the same thing. A mistyped command, subcommand or option is answered with the nearest one that exists — `unknown command "scann"; did you mean "scan"?` — and exits 2 like any other usage error. | Exit code | Meaning | | ---: | --- | | 0 | Success. | | 1 | Failure — a broken chain, a missing anchor, an unreadable trail line, a `doctor` finding, a refused configuration, an audit trail `serve` cannot open. | | 2 | Usage error. | | 3 | `hushgate scan` found personal data. | ### HTTP surface | Method and path | Purpose | | --- | --- | | `POST /v1/chat/completions` | OpenAI-compatible. Redacted outbound, re-hydrated inbound, streaming or not. | | `POST /v1/messages` | Anthropic-compatible, same treatment. | | `GET /healthz` | `{"status":"ok","version":"…"}`. | | `GET /metrics` | Prometheus text exposition. Needs a tenant key once tenants exist. | `/healthz` is always open. `/metrics` is open on loopback in single-tenant mode and requires a tenant key as soon as any tenant is configured — counts are still telemetry. Annotation-based Prometheus scraping sends no `Authorization` header, so a multi-tenant deployment needs a scrape config that does: a `ServiceMonitor` with `bearerTokenSecret` pointing at the same secret the tenant key comes from, for example. ```console $ curl -sS http://127.0.0.1:8787/metrics # HELP hushgate_build_info Version of the running hushgate. # TYPE hushgate_build_info gauge hushgate_build_info{version="0.8.0"} 1 # HELP hushgate_requests_total Requests handled, by route, outcome and tenant. # TYPE hushgate_requests_total counter hushgate_requests_total{outcome="blocked",route="openai.chat.completions",status="403",tenant="none"} 1 hushgate_requests_total{outcome="forwarded",route="openai.chat.completions",status="200",tenant="none"} 2 # HELP hushgate_attachments_total Attachments handled, by format, outcome and extractor. # TYPE hushgate_attachments_total counter # HELP hushgate_attachment_bytes_total Attachment bytes decoded, by format. # TYPE hushgate_attachment_bytes_total counter # HELP hushgate_findings_total Personal data found, by category and the policy applied. # TYPE hushgate_findings_total counter hushgate_findings_total{kind="EMAIL",policy="pseudonymize"} 2 hushgate_findings_total{kind="IBAN",policy="pseudonymize"} 2 hushgate_findings_total{kind="NAME",policy="pseudonymize"} 2 hushgate_findings_total{kind="PHONE",policy="pseudonymize"} 1 hushgate_findings_total{kind="SECRET",policy="block"} 1 hushgate_findings_total{kind="TERM",policy="pseudonymize"} 1 # HELP hushgate_blocked_total Requests refused, by what refused them. # TYPE hushgate_blocked_total counter hushgate_blocked_total{reason="policy",rule="redaction.policies"} 1 # HELP hushgate_audit_dropped_records_total Audit records that could not be written, by trail. # TYPE hushgate_audit_dropped_records_total counter hushgate_audit_dropped_records_total{trail="global"} 0 # HELP hushgate_upstream_tokens_total Tokens reported by upstream providers. # TYPE hushgate_upstream_tokens_total counter hushgate_upstream_tokens_total{route="openai.chat.completions",tenant="none"} 64 # HELP hushgate_request_duration_seconds Time from request received to response finished. # TYPE hushgate_request_duration_seconds histogram ... ``` `hushgate_audit_dropped_records_total` counts the audit records that could not be written since hushgate started, one series per trail file: `trail="global"` for the global trail, and for a tenant's own file the ids of the tenants writing to it, such as `trail="sales,support"`. Anything above zero means requests were served without evidence, which makes it worth an alert; [How it is written](#how-it-is-written) in §7 has the rest. ### Policies | Policy | Effect | Reversible | | --- | --- | --- | | `pseudonymize` | `[EMAIL_1]` — stable within the session. The default. | yes | | `redact` | `[EMAIL_REDACTED]` — the value is gone. | no | | `hash` | `[EMAIL:9f86d081ab2c]` — HMAC-SHA256 truncated to 12 hex characters. Comparable, and stable once `hmacKey` is set. | no | | `allow` | Left untouched. | n/a | | `block` | The whole request is refused, HTTP 403. | n/a | ### Configuration File, then `HUSHGATE_*` environment variables, then command-line flags — later wins. The file is JSONC: `//` and `/* */` comments are stripped on load. | Key | Default | Notes | | --- | --- | --- | | `host`, `port` | `127.0.0.1`, `8787` | Binding a non-loopback address requires tenants. | | `upstreams.openai` / `.anthropic` | the official endpoints | Any OpenAI- or Anthropic-compatible base URL. | | `redaction.defaultPolicy` | `pseudonymize` | Applies to kinds without an override. | | `redaction.policies` | `{}` | Per-kind overrides. | | `redaction.dictionary` | `{}` | `names` and `terms`. | | `redaction.custom` | `[]` | `{ "name", "pattern" }`; the name becomes the category. | | `redaction.hmacKey` | random per session | Set it to make `hash` output comparable across requests and restarts. | | `redaction.dobYearRange` | 1900 → this year − 13 | Plausible birth years. | | `briefing.mode` | `auto` | `auto` \| `always` \| `off`. When to tell the model what the placeholders are. See [§3.1](#31-what-the-model-is-told). | | `briefing.text` | `null` | Replaces the built-in wording outright. At most 4000 characters. | | `briefing.append` | `null` | House rules added after whichever text is used. | | `limits.maxBodyBytes` | 16 MiB | Larger requests are refused with 413. Base64 inflates a document by a third. | | `limits.maxResponseBytes` | 16 MiB | Larger upstream responses are refused with 502. | | `limits.upstreamTimeoutMs` | 120000 | Upstream request timeout. | | `limits.requestTimeoutMs` | 60000 | How long a client may take to deliver its request. | | `limits.idleTimeoutMs` | 60000 | How long a connection may sit silent when it is *nobody's* turn to talk — between requests, or mid-stream waiting for input. While hushgate is serving, this limit does not apply: a model can think in silence for minutes, and that is what `limits.upstreamTimeoutMs` bounds. | | `limits.upstreamRetries` | 2 | Retries for an upstream that never answered. A response is never retried. | | `limits.retryBackoffMs` | 250 | Base delay for the full-jitter backoff, doubled each attempt. | | `attachments.enabled` | `true` | Turn documents into text before redacting. Off means they are forwarded unread. | | `attachments.onUnreadable` | `block` | `block` \| `withhold` \| `forward`. What to do with a document whose text hushgate could not read. | | `attachments.maxBytes` | 10 MiB | Per attachment, decoded. | | `attachments.maxTotalBytes` | 32 MiB | Across one request. | | `attachments.maxCount` | 256 | Documents per request. More is refused with 422, whatever `onUnreadable` says; it also keeps each audit record short enough to read back. | | `attachments.maxTextChars` | 200000 | Extracted text is cut here, and the cut is reported. | | `attachments.timeoutMs` | 20000 | Budget for extracting one attachment. | | `attachments.extractors` | `pdftotext` | Operator-provided commands, tried before the built-in ones. | | `audit.enabled`, `audit.path` | `true`, `hushgate-audit.jsonl` | | | `residency.mode` | `sanitize` | `block` \| `sanitize` \| `warn` \| `allow`. | | `residency.routes`, `.categories` | `{}` | Per-route and per-category overrides. | | `residency.allow` | `[]` | Non-empty means fail-closed. Every entry needs a `legalBasis`. | | `residency.requireDataControls` | `false` | Refuse upstreams that offer no retention control. | | `residency.endpoints` | `[]` | Extend or override the registry. | | `organisation` | nulls | Heads the Article 30 report. | | `tenants` | `[]` | See below. | Environment overrides: `HUSHGATE_HOST`, `HUSHGATE_PORT`, `HUSHGATE_UPSTREAM_OPENAI`, `HUSHGATE_UPSTREAM_ANTHROPIC`, `HUSHGATE_DEFAULT_POLICY`, `HUSHGATE_HMAC_KEY`, `HUSHGATE_MAX_BODY_BYTES`, `HUSHGATE_MAX_RESPONSE_BYTES`, `HUSHGATE_UPSTREAM_TIMEOUT_MS`, `HUSHGATE_IDLE_TIMEOUT_MS`, `HUSHGATE_AUDIT_PATH`, `HUSHGATE_AUDIT`, `HUSHGATE_RESIDENCY_MODE`, `HUSHGATE_ATTACHMENTS`, `HUSHGATE_ATTACHMENTS_ON_UNREADABLE`, `HUSHGATE_ATTACHMENT_MAX_BYTES`, `HUSHGATE_ATTACHMENT_MAX_COUNT`. A full annotated file is in [`hushgate.config.example.json`](hushgate.config.example.json). ### Multi-tenant operation Each tenant gets its own policy profile, its own pseudonym namespace, its own quotas, and its own audit trail if you give it one. A placeholder minted for one tenant cannot be resolved by another, and there is a test that asserts exactly that. hushgate refuses to bind a non-loopback address until at least one tenant exists, so it cannot become an open relay on the LAN. ```console $ hushgate keys new support tenant key for "support" — copy it now, hushgate does not store it: hg_I-fluM42ZeEGXgVWpmA3XC2QlRtAP3h-SwlOIcBcnxU add this to hushgate.config.json: { "tenants": [ { "id": "support", "name": "support", "keyHash": "sha256:3ca843a2a0627a619965ecbfa8b96aeeb75f41402ba99342df5d99cc0a069312" } ] } The caller sends the key as "Authorization: Bearer " or "x-api-key: ". Revoke it by removing the hash; rotate it by listing both hashes in keyHashes. ``` A tenant entry takes: | Field | Meaning | | --- | --- | | `id`, `name` | Identifier used in the audit trail and the metrics labels. Name a team or an application, not a person — see below. | | `keyHash` | `sha256:…` of the key. The key itself is never stored. | | `keyHashes` | Several hashes, for rotating a key without downtime. | | `keyEnv` | Name of an environment variable holding the key, so a container gets one without a secret in the config file. | | `upstreamKeyEnv` | Where this tenant's **provider** credential is read from. Defaults to `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`. | | `quotas` | `requestsPerMinute`, `tokensPerDay`. Over-quota callers get a 429. | | `audit.path` | A trail of this tenant's own, with its own hash chain. Without one, its requests are recorded in the global trail. | | `redaction` | Overrides folded over the global profile — see below. | | `briefing` | Overrides folded over the global briefing, key by key. A tenant naming only `append` keeps the house `mode` and `text`; writing `null` for a key drops a global override rather than inheriting it. | A tenant's `redaction` block is an **override**, not a replacement: policies merge per kind, dictionaries take the union of names and terms, custom rules merge by name, and anything the tenant does not mention it inherits. A tenant with no `redaction` block gets the global profile in full. The same is true of `briefing`: `hushgate briefing --tenant ` prints the words any one of them actually gets. A tenant's `audit.path` is resolved against the working directory, like the global one. Tenants that name the same file share one chain, and so does a tenant that names the global trail's file, however either is spelt: hushgate opens one writer per file, and each record still names its tenant. Only the global trail is read by a plain `hushgate audit verify`; `--tenant ` and `--all` read the others, and `hushgate doctor` checks them all; see [§7](#tenants-trails). Choose tenant ids for teams or applications — `support`, `crm-sync` — rather than for people. The id is written into every record next to a timestamp, so a per-person id such as `j.becker` turns the trail into a log of when each employee used the model and how much personal data their requests carried. That is personal data about employees in its own right, and in Germany it can engage the works council's co-determination right under §87(1) Nr. 6 BetrVG, which covers technical systems suited to monitoring employees' behaviour or performance. Note what changes about credentials in multi-tenant mode. The caller sends a *hushgate* key, not a provider key, and hushgate drops the header carrying it before forwarding — a tenant key must never reach a provider. The provider credential comes from `upstreamKeyEnv` instead. The unqualified claim in §2 that your provider API key is forwarded untouched holds for single-tenant operation, which is where you start. ### Library The proxy is one consumer of a plain library; everything is exported from the package root. ```js import { Session } from 'hushgate'; const session = new Session({ policies: { GERMAN_TAX_ID: 'hash', IPV4: 'allow' }, hmacKey: 'a fixed key, so hashes are stable across restarts', dictionary: { names: ['Anna Schmidt'] }, }); const { text, findings } = session.redact(input); // … send `text` somewhere … const back = session.restore(text); ``` Also exported: `detect`, `createDetectors` and every individual detector; `isValidIban`, `luhnValid`, `isValidGermanTaxId` and friends; `redactJson` and `restoreJson` for structured bodies; `StreamRehydrator` and `SseRehydrator`; the audit writer, the streaming trail reader (`readTrailEntries`), the verifier (`verifyTrailFile`, `ChainVerifier`) and the report builder (`ReportBuilder`); the residency registry and policy engine; the tenant registry; the metrics registry. ## 10. Detection: what it catches, and what it does not hushgate is a deterministic detector, not a model. It is very good at identifiers that carry their own proof, and structurally incapable of recognising personal data that looks like ordinary prose. `hushgate scan` over [`examples/ticket.txt`](examples/ticket.txt), whose last line contains an IBAN, a card number and a tax ID that are all *shaped* right but fail their checksums: ```console $ hushgate scan -c examples/demo.config.json examples/ticket.txt examples/ticket.txt 1:8 NAME An••••••dt → pseudonymize 1:27 DATE_OF_BIRTH 14••••••87 → pseudonymize 2:8 EMAIL an••••••le → pseudonymize 3:8 PHONE 03••••••45 → pseudonymize 4:8 IBAN DE••••••00 → pseudonymize 5:8 CREDIT_CARD 41••••••11 → pseudonymize 6:12 GERMAN_TAX_ID 86••••••19 → pseudonymize 7:8 IPV4 10•••••.7 → pseudonymize 7:24 MAC 00••••••2a → pseudonymize 8:8 EMPLOYEE_ID EM•••••19 → pseudonymize 8:44 TERM Pr••••••ht → pseudonymize 11 findings in 1 file: CREDIT_CARD 1, DATE_OF_BIRTH 1, EMAIL 1, EMPLOYEE_ID 1, GERMAN_TAX_ID 1, IBAN 1, IPV4 1, MAC 1, NAME 1, PHONE 1, TERM 1 ``` Nothing in that file belongs to anybody. The identifiers are the published documentation values, chosen so that they pass their checksums — which is the whole point of the demo — without designating a real account, person or machine: `DE89 3704 0044 0532 0130 00` is the ISO 13616 example IBAN, `86095742719` the BZSt example Steuer-ID, `4111 1111 1111 1111` the standard test card, `030 23125 45` a number from the block the Bundesnetzagentur reserves for scripts and documentation, `00:00:5e:00:53:2a` from the range RFC 7042 reserves for documentation, and `10.14.2.7` is RFC 1918 private space. Values are masked by default (`An••••••dt`), so scan output is safe to paste into a ticket; `--show-values` opts out. Run the invalid line on its own to see what the checksums did: ```console $ sed -n 9p examples/ticket.txt | hushgate check -c examples/demo.config.json Nicht echt: DE89 3704 0044 0532 0130 01, 4111 1111 1111 1112, 86095742718 hushgate: nothing to redact ``` All three failed their check and were rejected, so all three passed through as written. Nor does another detector claim their digits. `0044 0532 0130 01` on its own is a well-formed international number in the `00` form, and early versions pseudonymised it here as `PHONE`; but its groups carry on in both directions, and a phone number is no longer cut out of the middle of a longer run of digits. Where a check is genuinely ambiguous, hushgate's bias is the other way: **it prefers a false positive to a leak.** A pseudonymised phone number that was never a phone number costs the model a little context; the opposite mistake costs you a transfer. What it does **not** do: - **Names in free text.** `NAME` comes from your dictionary. hushgate will not work out that "Frau Özdemir from purchasing" is a person. Add the names you care about, or accept that free-text names go through. What it *will* do, once `redaction.detectors.dictionary.fuzzy` is on, is see through the spelling of a name you did add: a dropped letter, a swapped pair, `Mueller` for `Müller`, `Mustermann, Max` for `Max Mustermann`. It is off by default because it walks the entry list per candidate token — affordable for one tenant's address book, not for every request on a shared event loop. - **A street with nothing around it.** A street detector *does* ship, and it reads `Kaiserstraße 12, 76133 Karlsruhe`. What it will not read is a bare `Lindenweg 5` with no postcode and no label: `-weg`, `-platz` and `-gasse` are the endings of *Radweg*, *Parkplatz* and *Sackgasse*, so the weak suffixes need corroboration and the strong ones (`-straße`, `-allee`, `-chaussee`) do not. The English word order — `12 Kaiserstraße` — needs a postcode behind it, because bare it is indistinguishable from a numbered list. - **A lone account number.** `BANK_ACCOUNT` needs the bank code beside the Kontonummer or a label in front of it. Six to ten bare digits is the shape of every order number in German business correspondence, so an unaccompanied `532013000` goes through. `redaction.detectors.bankAccount.accountLabels` adds the word your own forms use. - **Article 9 data that has no format.** Two kinds of it now do have one and are read: ICD-10 diagnosis codes and the names of prescribed drugs. The rest is prose, and prose is where a deterministic detector ends. *Mitglied der SPD* differs from *Mitglied der Feuerwehr* by no arithmetic — ethnic origin, political opinion, religion, union membership, sexual orientation and the biometric blob all need a curated term list and a false-positive budget, or a semantic layer. A dictionary or a custom rule catches the terms you name; nothing catches the general case. - **Anything inside images.** There is no OCR, so a photograph or a scanned page has no text hushgate can read. It is refused rather than forwarded — see §4 — but refusing it is all hushgate can do. - **Every way an extractor can mangle a document.** The checks in §4 catch a document that is fragmented throughout, and — by asking whether closing up the spacing reveals an identifier that was not visible before — a passage of shredding inside an otherwise clean page. That covers what real extractors do: ordinary PDF kerning, and every invisible or blank-rendering separator measured against it. Two limits are worth naming. The check can only miss what the detectors would have missed anyway — an identifier hushgate does not recognise in the first place, such as a street address, is not one it can notice the loss of. And it closes up *spacing*, not line breaks: an extractor that broke an address across a line every few characters would defeat it. Line breaks are left alone deliberately, because the alternative refuses invoices with a narrow column of figures, which is the document this is most often pointed at. - **Re-identification by combination.** Removing the name does not stop "the customer in Ravensburg who ordered the ZX-40 on Tuesday" from being exactly one person. Pseudonymisation is not anonymisation. - **Cross-restart placeholder stability.** Pseudonyms are per session by design, so `[EMAIL_1]` in yesterday's audit trail means nothing today. `hash` is the policy for stable, comparable, irreversible identifiers. ## 11. Is this legally sufficient? No — and any tool that claims otherwise is selling something. Precisely what hushgate does, in the terms the question is usually asked in: - It is a **technical and organisational measure** in the sense of Art. 32 GDPR, and it implements **pseudonymisation** in the sense of Art. 4(5). - Data it replaces with a placeholder is not transmitted to the provider. To the extent a request contains only pseudonymised content after hushgate has run, what reaches the provider is data from which the individual cannot be identified without the mapping — and the mapping never leaves your machine. - It produces evidence: a hash-chained record of which categories were processed, in what volume, sent where, under which recorded legal basis. What it does not do: - Pseudonymised data remains **personal data** under Recital 26. Sending it to a third country is still a transfer and still needs a legal basis under Chapter V. hushgate reduces what is transferred; it does not remove the question. - It cannot assess your provider contracts, your DPA, your SCCs, your transfer impact assessment, or whether the controller's purpose is lawful in the first place. - Its jurisdiction registry records what operators publicly document. It is a starting point for a transfer impact assessment, not the conclusion of one, which is why every `residency.allow` entry is required to carry a `legalBasis` written by you. - Detection is best-effort (§10). A control that catches most personal data is not a guarantee that none escaped. hushgate is a technical control that supports compliance. It is not legal advice, and it does not by itself make any transfer lawful. Have your DPO review the configuration, and treat `hushgate residency` and `hushgate audit report` as inputs to that review rather than as its conclusion. ## 12. Deployment **Read this before the first `docker compose up`:** hushgate refuses to bind a non-loopback address while no tenant exists — by design, so it cannot become an open relay on the network. The Docker image binds `0.0.0.0` so the port can be published, and a freshly `init`-ed config has its `tenants` key commented out. The container therefore exits at startup, and `restart: unless-stopped` turns that into a crash loop until you have run `hushgate keys new` and pasted the tenant block into the config. ```sh hushgate init # the compose file mounts ./hushgate.config.json read-only hushgate keys new default # paste the printed "tenants" block into that file docker compose up --build ``` The middle step is not optional — see the warning above. The image is multi-stage and runs as a non-root user, with a `HEALTHCHECK` on `/healthz` that uses `fetch` rather than adding curl to the image. Because hushgate has no runtime dependencies, the final layer is Node plus the compiled output and nothing else — no package manager, no build toolchain, no transitive supply chain to audit. The compose service publishes the port on loopback only, runs read-only with all capabilities dropped and `no-new-privileges`, takes provider credentials from the environment rather than the config file, and keeps the audit trail in a named volume so it outlives the container. [`deploy/kubernetes.yaml`](deploy/kubernetes.yaml) is a Deployment, Service, ConfigMap and Secret with resource limits, liveness and readiness probes on `/healthz`, and a hardened `securityContext`: `runAsNonRoot`, read-only root filesystem, `allowPrivilegeEscalation: false`, all capabilities dropped, seccomp `RuntimeDefault`. Put `hushgate doctor` in the pipeline. It validates the configuration, resolves the residency policy, verifies every audit trail and exits non-zero on anything unsafe — including `warn` mode left switched on: ```console $ hushgate doctor hushgate doctor configuration note no hushgate.config.json found; running on built-in defaults → run "hushgate init" to write one briefing ok the model is briefed on the placeholders when a request carries any residency warn no residency allowlist is configured, so any upstream is permitted → list the endpoints you have assessed in residency.allow, each with its legal basis note upstreams.openai → api.openai.com [US, third-country] via residency.allow (empty) note upstreams.anthropic → api.anthropic.com [US, third-country] via residency.allow (empty) security ok bound to 127.0.0.1, so only this machine can reach it attachments ok attachment handling is on note extractor pdftotext is configured but is not on PATH, so application/pdf, pdf will be refused → install it with "apt install poppler-utils" or "brew install poppler" audit note no trail at /home/you/hushgate/hushgate-audit.jsonl yet; it is written on the first request 1 warning. Fix it, or re-run with --allow-warnings to accept it. $ echo $? 1 ``` Once the policy is real, it goes quiet: ```console $ hushgate doctor -c examples/demo.config.json hushgate doctor configuration ok config file /home/you/hushgate/examples/demo.config.json briefing ok the model is briefed on the placeholders when a request carries any residency ok upstreams.openai → 127.0.0.1 [LOCAL, local] via residency.allow[0] ok upstreams.anthropic → 127.0.0.1 [LOCAL, local] via residency.allow[0] security ok bound to 127.0.0.1, so only this machine can reach it attachments ok attachment handling is on note extractor pdftotext is configured but is not on PATH, so application/pdf, pdf will be refused → install it with "apt install poppler-utils" or "brew install poppler" audit ok trail /home/you/hushgate/examples/hushgate-audit.jsonl: 3 record(s), chain intact, head 5531e6c715d8f4e7d6e5cbccd20c1e1005db9e8be9264073398ea37560c81b2f Nothing unsafe found. ``` The `audit` section covers every trail `serve` would write — the global one and each tenant's `audit.path`, resolved against the working directory the way `serve` resolves them, once per file. For each it checks that `serve` could open it for writing, and fails if it could not, since `serve` would then refuse to start. A trail that does not exist yet is a note; one that exists and cannot be read, a broken chain or an unreadable line is a failure; records the writer could not write are a warning. The head is printed in full, because it is what you anchor. Operational behaviour: a graceful shutdown that lets requests in flight finish and writes their records, a body-size limit, an upstream timeout, and retries with full-jitter exponential backoff for failures that happened *before* a response arrived. A response is never retried, whatever its status — a 429 with a `retry-after` belongs to the caller, and quietly resending a request the provider has already seen and charged for would be worse than the error. ### Updating ```sh hushgate update --check # is there a newer release? installs nothing hushgate update # install it, after asking hushgate update --yes # the same without asking, for cron or config management hushgate update --to 0.5.0 # a particular version, older ones included ``` `update` asks npm for the newest release, through whichever registry npm is configured to use, so a company mirror, its proxy and its credentials apply as they do to any other install. It then updates hushgate where npm put it: globally, or in the project whose `package.json` names it. It is the one command that contacts anything but your upstream, and only when you run it: `serve` never checks for updates. For monitoring, `update --check --json` prints the installed and newest versions, whether an update is available, and how this copy was installed. A copy npm did not install is updated the way it was installed, and `update` says how instead of touching it: the Docker image by building or pulling the image for the new version, a clone with `git pull` and a build, and `npx` by naming the version, as in `npx hushgate@ serve`. Where it cannot tell whether npm installed a copy globally or into a project, it installs nothing and prints both commands. After an update, a running `hushgate serve` keeps the old version until it is restarted. Restart it, or the service that runs it, and run `hushgate doctor`. Before 1.0 a minor release can change behaviour, so `update` links the changelog of the release it is about to install. ## 13. Development ```sh npm install npm run lint # oxlint, warnings are errors npm run build # tsc, ESM output to dist/ npm test # vitest ``` `npm run typecheck` also type-checks the tests, which the build config excludes. `npm run check:docs` verifies that the banner is well-formed SVG within the constraints GitHub's sanitiser imposes, and that every relative link in every Markdown file here resolves — including the heading it points at. `npm run verify:package` checks what npm would publish: that the tarball carries the compiled output and not the sources, and that the `bin` entry actually runs. 3233 tests across 88 files, and **none of them touch the network**. Every proxy test runs against a fake upstream bound to `127.0.0.1` that records exactly what hushgate sent — which is the only way to assert the actual claim. CI proves the suite is offline by running it a second time with `HTTP_PROXY` and `HTTPS_PROXY` pointed at a closed port. CI is a matrix over Node 20, 22 and 24: install, lint, typecheck, build, test, then the no-network run. Before opening a change, read [CONTRIBUTING.md](CONTRIBUTING.md). Report vulnerabilities privately per [SECURITY.md](SECURITY.md); participation is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). Notable changes are recorded in [CHANGELOG.md](CHANGELOG.md). ## 14. License hushgate is **source-available**, not open source. It is licensed under the [Business Source License 1.1](LICENSE) — the standard, unmodified BUSL text, the same one used by MariaDB, Terraform and Materialize, with only the Parameters filled in. What follows is a summary, written to be read. The [LICENSE](LICENSE) file is the thing that governs; where this summary and the licence differ, the licence wins. **Copying, modifying, forking, redistributing and every non-production use are free for everyone, at any size.** That is the BUSL grant itself, and neither of the two production conditions below touches it. Those rights do carry the licence's own conditions, though: every copy and derivative work remains subject to the BUSL, and you must conspicuously display the licence on each original or modified copy. Evaluation, development, staging, CI, a security review, a proof of concept against real traffic captures — none of that is production use. No licence, no registration, nothing to phone home to. **Production use is free when both of these hold.** **(a) You are within the size grant** — any one of: - your organisation has **10 or fewer** individuals; or - you are an individual using hushgate personally, not on behalf of an organisation; or - you are a **non-profit, a registered charity or an accredited educational institution**, whatever your headcount (still subject to (b)). *How the 10 are counted.* One each, regardless of hours worked: every employee, officer, independent contractor, freelancer, intern, working student, apprentice, and every individual supplied to you by a staffing or temporary-employment agency. Non-executive directors and shareholders who do no work for the organisation are not counted. "Your organisation" is your legal entity plus everything under common control with it. The count is taken **on the first day of each calendar quarter**, and if it first goes above 10 the free grant keeps running for a further **90 days** from that date, so you have a quarter to buy a licence or to stop. **(b) You are not offering hushgate to third parties** — that is, your use does not include offering hushgate, or a product or service that includes or is derived from it, to third parties as a **hosted, managed or embedded commercial offering**. This one ignores headcount: a two-person company selling a managed hushgate instance needs a licence. **What (b) does *not* catch.** The licence says so explicitly, and it matters: - **your own employees and independent contractors are not "third parties"** — running one hushgate for your own people is not an offering; - **running hushgate for your organisation's own internal purposes** is not an offering; - **using hushgate as an internal component of a product whose value to your customers does not consist substantially of hushgate's functionality** is not an offering either. So a SaaS that puts hushgate in its own request path to sanitise its own LLM calls is not offering hushgate to anybody, however many customers that SaaS has — only its headcount is in question. Selling "PII filtering, powered by hushgate" as the thing the customer is buying is the case (b) exists for. **Needs a commercial licence:** production use by an organisation over the size grant, and any offering under (b). [COMMERCIAL.md](COMMERCIAL.md) works the concrete cases through one by one — a 40-person company using it internally, a small company embedding it in its own product, a 6-person agency, a hosting provider, a university, a freelancer — and says what to put in the mail. Or write directly: **jo_becker@mailbox.org**. **It turns into open source by itself.** Four years after a version is published, that version converts automatically and permanently to the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0). Nothing needs to be renegotiated for that to happen; it is in the licence. Contributions are taken under the [DCO](DCO) — see [CONTRIBUTING.md](CONTRIBUTING.md). Copyright © 2026 [Johan Becker](https://johanbecker.com). The licence covers the code; "hushgate" and its logo are trade marks and are not licensed with it — see [TRADEMARKS.md](TRADEMARKS.md).