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].
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, and its 1000 tests pass 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/ contains the config and the local
stand-in upstream, so you can reproduce all of it offline.
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.
0.1.0 is not on the npm registry yet, so the clone is the path that works. It needs nothing but Node 20 and the dev toolchain:
git clone https://github.com/johan-becker/hushgate
cd hushgate && npm install && npm run build
node dist/cli/main.js init # write a commented starter config
node dist/cli/main.js doctor # check it; non-zero exit on anything unsafe
node dist/cli/main.js servedoctor 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.
Once 0.1.0 is published, every node dist/cli/main.js below becomes plain
hushgate, which is the name the transcripts in this README use:
npm install -g hushgate # not published yet
hushgate init
hushgate doctor
hushgate serveThen change one line in your application:
import OpenAI from 'openai';
const client = new OpenAI({
+ baseURL: 'http://127.0.0.1:8787/v1',
apiKey: process.env.OPENAI_API_KEY,
}); 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:
export OPENAI_BASE_URL=http://127.0.0.1:8787/v1
export ANTHROPIC_BASE_URL=http://127.0.0.1:8787Your 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:
$ 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.To reproduce this section, start the local stand-in upstream shipped in
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.
$ node examples/upstream.mjs &
fake upstream on http://127.0.0.1:9099
$ hushgate serve -c examples/demo.config.json
hushgate 0.1.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 examples/hushgate-audit.jsonl
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:8787Send a ticket through it:
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:
{"model":"gpt-4o-mini","messages":[{"role":"system","content":"Du bist die Support-Assistenz von [TERM_1]."},{"role":"user","content":"[NAME_1] ([EMAIL_1], [PHONE_1]) hat die Rechnung nicht bezahlt. Bitte erinnere sie und buche auf [IBAN_1]."}]}The caller got this back:
{
"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:
$ 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.
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:
{"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:
[
{
"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.
| 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. |
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.
"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.
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. |
$ 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.
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:
$ 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 EURIt 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. 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.
Decoded size is checked before anything is allocated, per attachment and across
the request. 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.
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:
$ 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:
$ 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.
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.
$ 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):
$ 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:
$ 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 $?
1Enforcement 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.
Every request appends one JSONL record: timestamp, route, outcome, latency,
upstream, the count of findings per category, 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,
committed to the repository so this section can be read without running
anything. It is exactly what the two requests in §3 and the stream in §5
produce; delete it and re-run them and you get the same records, though the
hashes differ because the chain covers timestamps.
{"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"}hushgate audit verify walks the chain:
$ hushgate audit verify -c examples/demo.config.json
audit trail /home/you/hushgate/examples/hushgate-audit.jsonl
records 3
chain intact
head b2e5930378425ec5265a1bc8d61c51147dd756e19c3880176f0f591152883be2
Anchor the head hash outside hushgate (a ticket, a signed note, another
system) if you also need to detect records being dropped from the end.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:
$ sed 's/"SECRET":1/"SECRET":0/' examples/hushgate-audit.jsonl > examples/tampered.jsonl
$ hushgate audit verify --file examples/tampered.jsonl
audit trail /home/you/hushgate/examples/tampered.jsonl
records 3
chain BROKEN
first break record 2 (altered)
id eb05e168-4577-4d03-9755-c87773f86fe5
timestamp 2026-08-22T14:55:49.213Z
detail record hash is b40af9be67b1…, but its contents hash to 4f1fae52d170…
Everything before that record still verifies. Everything from it onwards
has been altered, or had records inserted or removed.
$ echo $?
1hushgate audit report turns the same file into an Article 30 style record of
processing activities, in Markdown or JSON:
$ 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-08-22T14:56:18.842Z
- **Source**: /home/you/hushgate/examples/hushgate-audit.jsonl
- **Period**: beginning → end (records from 2026-08-22T14:55:49.202Z to 2026-08-22T14:55:49.226Z)
- **Controller**: Nordlicht GmbH
- **Contact**: datenschutz@nordlicht.example
- **Data protection officer**: A. Datenschutz
- **Audit chain**: intact over 3 records (head b2e593037842…)
## Purposes of processing
- Drafting customer support replies
## Volumes
- Requests: **3**
- Tokens reported by providers: **64**
- Outcomes: blocked 1, forwarded 2
- Routes: openai.chat.completions 3
- Residency enforcement: n/a 1, sanitize 2
## 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 | 64 | Processing on our own hardware; no transfer occurs. |
### Controls applied to outbound requests
- 127.0.0.1:9099: none set per request
## Technical measures
Personal data detected in outbound requests is replaced with pseudonymous
placeholders before the request leaves the machine, and restored in the
response. The audit trail records categories and counts only; it contains no
personal data itself, by construction.
---
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.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.
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).
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
Cfclass (zero-width space, joiners, word joiner, byte-order mark, soft hyphen, bidi controls) plus everyDefault_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.
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:
$ 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
restored : Anna Schmidt <anna.schmidt@nordlicht.example> — 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.
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.
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.
| Command | Purpose |
|---|---|
hushgate init [--path <p>] [--force] |
Write a commented starter hushgate.config.json. |
hushgate serve [options] |
Run the redacting proxy in the foreground. |
hushgate scan [--json] [--show-values] [-q] <file...> |
Find personal data in files, including PDFs and Office documents. Exits 3 when it finds any. |
hushgate extract [--raw] [--json] <file> |
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 doctor [--json] [--allow-warnings] |
Validate config, residency and audit chain. For CI. |
hushgate audit verify [--file <p>] [--json] |
Walk the hash chain, report the first break. |
hushgate audit report [--from <d>] [--to <d>] [--json] |
Article 30 style record of processing. |
hushgate keys new <tenant-id> / hushgate keys hash |
Mint a tenant key, or hash an existing one. |
hushgate help [command], hushgate version |
Help, and the version. |
serve additionally takes -H/--host, -p/--port, --upstream-openai,
--upstream-anthropic, --audit <path> and --no-audit. Every command except
init and keys takes -c/--config.
| Exit code | Meaning |
|---|---|
| 0 | Success. |
| 1 | Failure — a broken chain, a doctor finding, a refused configuration. |
| 2 | Usage error. |
| 3 | hushgate scan found personal data. |
| 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.
$ 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.1.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_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_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
...| 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 |
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. |
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.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. A full annotated file is in
hushgate.config.example.json.
Each tenant gets its own policy profile, its own pseudonym namespace, its own audit stream and its own quotas. 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.
$ 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 <key>" or "x-api-key: <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. |
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 dedicated trail for this tenant, with its own hash chain. |
redaction |
Overrides folded over the global profile — see below. |
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.
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.
The proxy is one consumer of a plain library; everything is exported from the package root.
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 log and chain verifier; the residency registry and policy engine; the
tenant registry; the metrics registry.
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, whose last
line contains an IBAN, a card number and a tax ID that are all shaped right
but fail their checksums:
$ 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
9:23 PHONE 00••••••01 → pseudonymize
12 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 2, TERM 1Nothing 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:
$ sed -n 9p examples/ticket.txt | hushgate check -c examples/demo.config.json
Nicht echt: DE89 3704 [PHONE_1], 4111 1111 1111 1112, 86095742718
hushgate: redacted 1 finding (PHONE 1)All three failed their check and were rejected, so all three passed through as
written — but part of the IBAN's digits were then claimed by a different
detector. 0044 0532 0130 01 really is a well-formed international number in
the 00 form: twelve digits after the 00 prefix, inside the subscriber-length
bounds the detector enforces. That is a false
positive, and it is the honest illustration of hushgate's bias: 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.
NAMEcomes 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, onceredaction.detectors.dictionary.fuzzyis on, is see through the spelling of a name you did add: a dropped letter, a swapped pair,MuellerforMüller,Mustermann, MaxforMax 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 bareLindenweg 5with no postcode and no label:-weg,-platzand-gasseare 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_ACCOUNTneeds 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 unaccompanied532013000goes through.redaction.detectors.bankAccount.accountLabelsadds 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.hashis the policy for stable, comparable, irreversible identifiers.
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.allowentry is required to carry alegalBasiswritten 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.
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.
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 --buildThe 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 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 the audit chain and exits non-zero on anything
unsafe — including warn mode left switched on:
$ hushgate doctor
hushgate doctor
configuration
note no hushgate.config.json found; running on built-in defaults
→ run "hushgate init" to write one
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
audit
note no trail at /home/you/hushgate/hushgate-audit.jsonl yet; it is written on the first request
1 warning. Fix them, or re-run with --allow-warnings to accept the warnings.
$ echo $?
1Once the policy is real, it goes quiet:
$ hushgate doctor -c examples/demo.config.json
hushgate doctor
configuration
ok config file /home/you/hushgate/examples/demo.config.json
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
audit
ok /home/you/hushgate/examples/hushgate-audit.jsonl: 3 record(s), chain intact, head b2e593037842…
Nothing unsafe found.Operational behaviour: graceful shutdown, 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.
npm install
npm run lint # oxlint, warnings are errors
npm run build # tsc, ESM output to dist/
npm test # vitestnpm 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.
1000 tests across 36 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. Report vulnerabilities privately per SECURITY.md; participation is governed by the Code of Conduct. Notable changes are recorded in CHANGELOG.md.
hushgate is source-available, not open source. It is licensed under the Business Source License 1.1 — 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 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 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. Nothing needs to be renegotiated for that to happen; it is in the licence.
Contributions are taken under the DCO — see CONTRIBUTING.md.
Copyright © 2026 Johan Becker. The licence covers the code; "hushgate" and its logo are trade marks and are not licensed with it — see TRADEMARKS.md.
{ // 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 <id>" and paste the snippet. // "tenants": [] }