dumbtv
dumbtv가 ADB로 Fire TV에서 광고·추적·업데이트 알림을 제거, 루트·앱 변경 없이, 무료 오픈소스
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Keep personal data out of cloud LLM APIs. A self-hosted PII firewall and EU data-residency gateway for the OpenAI and Anthropic APIs — zero runtime dependencies.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
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 [email protected] while the provider only ever saw
[EMAIL_1].README 전문 보기 ↗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 [email protected] while the provider only ever saw
[EMAIL_1].
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; 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/ 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.
Node 20 or newer, and nothing else:
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:
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:
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:
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:
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: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:
$ 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.
{
// 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": []
}
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.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 ends
at 5531e6c715d8…, so new records continue from there.
Send 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 ([email protected], +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":"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 is about why it is there.
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 [email protected] 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 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:
[email protected] 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:
$ 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.
"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.
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 [email protected]: 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 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.
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.
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 ([email protected]) 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":"[email protected] 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 $?
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.
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,
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.
{"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"}
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 : , , , (characters of text extracted — a length, not the text), , , (, , , or ) and . |
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 explains why they should name teams or applications rather than people.
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:
$ 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:
$ 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:
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.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.--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.
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:
^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:
$ 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:
mv hushgate-audit.jsonl hushgate-audit-2026-09.jsonl.starting a new trail, and the new file's
chain starts from 64 zeros.hushgate audit verify hushgate-audit-2026-09.jsonl --anchor <head> 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:
$ 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.
A tenant with its own audit.path is recorded in that file and nowhere else.
--tenant <id> 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.
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:
$ 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.
hushgate audit report turns the same file into an Article 30 style record of
processing activities, in Markdown or, with --json, as data:
$ 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**: [email protected]
- **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:
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;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;--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.
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.
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. turns it on. |
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:
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.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 IPV4=allow
restored : Anna Schmidt <[email protected]> — 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:
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;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 or folder...> | Find personal data in files and folders, 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 briefing [--tenant <id>] [--kinds <list>] [--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 [<file> | --tenant <id> | --all] [--anchor <hash>]... [--continues-from <hash>] [--json] | Walk the whole hash chain: every break, gap and unreadable line, with line numbers, and every anchor. See §7. |
hushgate audit report [<file> | --tenant <id>] [--from <date>] [--to <date>] [--anchor <hash>]... [--continues-from <hash>] [--json] | Article 30 style record of processing, over UTC days. See §7. |
hushgate keys new <tenant-id> / hushgate keys hash | Mint a tenant key, or hash an existing one. |
hushgate update [--check [--json]] [--to <version>] [--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. |
serve additionally takes -H/--host, -p/--port, --upstream-openai,
--upstream-anthropic, --audit <path> 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 <command> and hushgate <command> --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. |
| 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.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 in §7 has the rest.
| 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. |
briefing.mode | auto | auto | always | off. When to tell the model what the placeholders are. See §3.1. |
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. |
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.
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.
$ 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. 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 <id> 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 <id> and
--all read the others, and hushgate doctor checks them all; see
§7.
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.
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 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.
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
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:
$ 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.
No — and any tool that claims otherwise is selling something.
Precisely what hushgate does, in the terms the question is usually asked in:
What it does not do:
residency.allow entry is required to carry a
legalBasis written by you.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 --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 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:
$ 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:
$ 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.
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@<version> 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.
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. 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:
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:
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: [email protected].
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.
[]formatmediaTypebytescharspagesextractoroutcomeextractedtruncatedwithheldforwardedblockedreasondropped | Present only after the writer failed to write records: how many were lost immediately before this one. See 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. |
redaction.detectors.vatId.requireGermanCheckDigitGERMAN_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. |
hushgate help [command], hushgate version | Help, and the version. |
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. |
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.