Security
Security model and residual risks
Tokenhush is a local, loopback-only HTTP gateway that redacts secrets and PII before they reach the model. Because it is a security tool, it has to be inspectable first. This page states what the loopback gateway can act on, the eight invariants the open-source core enforces, the nine residual risks it records rather than hides, and exactly what reaches the disk.
- Loopback only — no MITM, no root certificate
- Every invariant pinned by a named test
- Nine residual risks, recorded and accepted
The honest claim is high-confidence interception with the limits written down, not a guarantee that nothing sensitive can leave.
What the gateway can act on
Tokenhush runs on your machine and sits between one AI coding tool and the model provider, on a base URL you point at loopback. Requests that pass through that path are the requests it can work on — coverage is exactly as wide as the path.
Protected by construction
- Loopback only. The service binds
127.0.0.1plus[::1]where the host has an IPv6 loopback, and a non-loopback bind is impossible. The Host allowlist is always enforced, and Origin is checked for browser-style requests. - One control endpoint, one credential. The control surface is exactly
GET /status, behind a per-run bearer token stored0600. There is no allowlist-mutation endpoint; a non-GET request gets a JSON 405 withAllow: GET, and any other GET gets a 404. - Fail closed, not fail open. A detector failure refuses the request instead of forwarding it unredacted, and a request carrying a non-identity
Content-Encodingis refused with 415 before its body is read and before any upstream dial is opened. - Deterministic detection. Six detector categories —
prefix,jwt,pem,luhn,email, and the opt-inentropyrule — run as rules. There are no model calls and no semantic guessing; five of the six are on by default.
Outside the path, by design
- Tools that do not accept a base URL. Cursor agent traffic, the ChatGPT and Claude desktop apps, and browser web UIs expose no base-URL setting. Covering them would need system-level interception with a root certificate, which the open-source core does not implement.
- Encoded secrets. A secret that is base64-, hex-, or URL-encoded before it leaves is not detected: the rewrite has no normalization pass. This is residual risk R1 below.
- Secrets in key positions. Only values are walked, so a secret used as a JSON object key is forwarded unchanged. This is R2 below.
- Response-path substitution. Response-scoped rules may allow, warn, or block; they never substitute. A rule that tries to redact a response is rejected when it is compiled.
- Tool-side telemetry. Some tools send their own telemetry to services such as PostHog or Sentry; those endpoints bypass base-URL redirection and are not on the gateway's path.
The eight invariants
All eight are enforced in code and pinned by a named test in the open-source core. They are the rules this design does not bend.
- Never backfill placeholders outbound. The outbound writer holds only the forward map (secret to placeholder); it has no reverse map and no restore method. Re-applying a redaction is idempotent, and a body that already carries a known placeholder is forwarded verbatim.
TestInvariant1NeverBackfillOutbound - No request or response plaintext on disk; metadata only. The audit seam is a single write method and writes metadata-only records; the default sink is a no-op that persists nothing. A run-level guard drives the real binary and scans everything the run could have written.
TestInvariant2NoPlaintextOnDisk - No root certificate, no MITM by default. Tokenhush never terminates TLS. An absence guard scans the production source for every shape a certificate-install or MITM path would need — TLS listeners, key pairs, certificate pools, embedded certificates — and fails if any appears.
TestAbsenceNoCertificateInstallPath - Loopback-only bind, Host and Origin validation, and the control bearer token. A non-loopback bind is impossible, a foreign Host is rejected, a cross-origin request is rejected, and the control surface refuses a missing credential with 401 and a wrong one with 403.
TestInvariant4LoopbackOnly - Fail-safe on detection failure, not fail-open. A rule that panics, times out, exceeds its span budget, or returns malformed findings yields a labelled refusal with the right reason — never a silent pass. Each failure writes exactly one metadata-only record, and a leaf past its byte budget writes one metadata-only stderr line rather than passing silently.
TestInvariant5FailSafe - Exactly two switchable vendor-bound egress categories, command-scoped.The product may reach the vendor only for the two disclosed purposes, and only until you set the disclosed switch. A second guard proves the disclosure declares exactly two categories, each naming its switch, and that each switch short-circuits before any network call.
TestInvariant6NoVendorEgress - Content-Encoding fail-closed. A request carrying a non-identity
Content-Encodingis refused with 415 before its body is read. On the response side, gzip and deflate are decoded before any status is committed, and a coding the gateway cannot decode is a 502 that discards the upstream bytes. There is no uninspected pass-through.TestInvariant7EncodingFailClosed - SSE-aware bounded backfill (256 KiB). An unterminated token in an SSE stream can never make the backfill writer hold more than
SSEBackfillHoldbackBytes(256 KiB); past the bound the oldest held bytes leave literally and in order. Nothing is dropped and nothing is fabricated.TestInvariant8BoundDerived
The nine residual risks
These are recorded rather than left implicit; listing them is not a claim that they are absent. Each entry below matches the security model's register one-for-one, including the ones that are uncomfortable to publish.
| # | Residual risk | What it means in practice | Status |
|---|---|---|---|
| R1 | Encoded secrets are not caught | A secret that is base64-, hex-, or URL-encoded before it leaves is not detected: the rewrite has no normalization pass by design, and a non-identity request Content-Encoding is refused with 415 rather than decoded for detection. JSON string escaping is handled in both directions, so the residual is content encodings, never JSON escaping. | Recorded and accepted |
| R2 | A secret in a JSON object key is not caught | A secret placed in an object key rather than a value is forwarded unchanged: the leaf walk supplies no key leaves, so there is no half-working key path. The sensitive_keys block redacts the value of a named key, never a secret stored in the key itself, and two sibling shapes stay unmatched: a sensitive key whose value is a container, and the Kubernetes/Docker-env shape where a name field carries the key name and a value field carries the secret. | Recorded and accepted |
| R3 | A signed remote pack may weaken detection through its own allowlist | A signed remote pack can suppress matches through its own global or per-rule allowlist: the non-weakening floor is allowlist-neutral and does not inspect allowlists, while the rule-signing key remains the trust root. A pack that weakens detection is a pack the signing-key holder chose to publish. | Recorded and accepted |
| R4 | The command-rule rejection is dormant, and the manifest v2 bump is backward-incompatible | The command-rule rejection stays dormant until the next schema gate opens, and that gate opens only when the rules manifest schema_versionreaches 2. The bump is backward-incompatible with clients pinning== 1, so the backend must keep serving a v1 manifest until the legacy line is out of support. | Recorded and accepted |
| R5 | Response-path bodies are not capped by the request-side body guard | A response body is not limited by the request-side guards:response_buffer_bytes (default 32 MiB) is a separate total cap on the whole buffered response, and a response past it is a 502 before commit. Inside a body that cap admits, a response-scoped detector still scans at most its per-primitive budget, and a leaf past that budget is truncated with no stderr report and no counter — so response-scoped detection can still miss content. | Recorded and accepted |
| R6 | A placeholder split across SSE JSON-envelope data: events is only partially restored | When every event is a complete JSON envelope, a content-level split is restored exactly once, in both the content channel and the tool-call arguments channel, with matching in the decoded domain. What remains: if the upstream tears the JSON document itself, only the raw spelling is knowable; and an interleaved event of a non-matching channel releases the held halves byte-identically, so the placeholder fragments are delivered literally and not restored. While a token is unresolved, the affected channel is held until the token resolves or the joint 256 KiB budget trips. | Partially closed; residual recorded and accepted |
| R7 | The opt-in high_entropy detector replaces legitimate base64 payloads | entropy is off by default because a legitimate high-entropy payload is indistinguishable from a secret by construction: enable it and a base64 image in a request is replaced by a single placeholder before the request reaches upstream, so the model never sees the image. Enable it only for a workload with no images, data URLs, or long random identifiers, or accept the substitution. | Recorded and accepted |
| R8 | The global blocklist is not applied on every evaluation path | Two decision paths do not apply the global blocklist; only the compiled evaluation path runs it. Every leaf keeps Block precedence on that path, but a caller that decides through the lightweight path does not get the blocklist applied. This is recorded rather than fixed, and the test that pins the behaviour lives on the compiled path. | Recorded and accepted |
| R9 | The request body guard is a memory wall, and the second walk is unguarded | A body at or below max_body_bytes (default 64 MiB) is walked twice on the declared-JSON path, and only the first walk runs inside the detector timeout; the second walk, inside the forwarder, is unguarded and has no timeout. Each walk copies leaf values, so peak memory is a small multiple of the body rather than the body alone. A body over the limit is still refused with 403 body_too_large, now with one metadata-only stderr line. | Recorded and accepted |
What is written to disk
Nothing is persisted but metadata. Four paths are written under the data directory, and none of them carries request or response content.
<DataDir>/run.jsonpid,port,addrs,started_at. Never the control token. Mode0600, atomic, removed on clean shutdown.<DataDir>/control.token- The session control bearer token. Mode
0600, atomic, removed on clean shutdown. <DataDir>/rules/…- The verified rules cache and the anti-rollback mark: signed documents and serial numbers, no request content.
<DataDir>/update/highwater.json- The update anti-rollback mark: a serial/version integer, nothing else.
Never written to disk: bodies, secrets, and the mapping.
Request and response bodies, detected secrets, and the placeholder-to-secret mapping are never written to disk. The redaction log line is masked, goes to stderr only, and is never persisted; the response-side restore counter is stderr-only as well. Because redaction re-scans the whole carried conversation each turn, those counters grow with the number of carried occurrences — metadata-only work, never a persisted body.
Key handling: visible in memory, never on disk
Keys pass through the gateway to do their job; they are not stored.
- Provider keys pass through in memory. Your tools keep their own provider keys; the gateway reads and forwards them for the request it is handling and stores nothing. Detection, substitution, and restore all run in the same process.
- The mapping is session-scoped. The secret-to-placeholder mapping lives in memory for the session, and restarting the gateway drops it — a placeholder can then reach your tool unchanged, which is visible degradation rather than a silent failure. A foreign placeholder is returned byte-identical, never guessed at.
- Two credentials, two treatments. The one credential written to disk is the per-run control token (
control.token, mode0600, removed on clean shutdown) — a loopback control credential, not a provider key.
Network egress: two command-limited categories
The only vendor requests Tokenhush makes are two categories, each scoped to the command that triggers it and each switchable off. Both go toupdates.tokenhush.com with 30-day retention.
Update check runs only when you run tokenhush update (including--check) on a self-managed install; rule sync runs only when you runtokenhush rules sync. Each category returns before any network request once you set its switch — TOKENHUSH_NO_UPDATE_CHECK=1 andTOKENHUSH_NO_RULE_SYNC=1 — and tokenhush privacy prints the disclosure.
Update check
ActiveThis build sends these requests only when you run the matching command; the fields, retention, and switch below apply.
Check for a signed release: the update manifest, its signed revocation document, and the root-signed key-list. These share one host and one switch and are one egress category, not three.
Fields involved
channelSent by the clientRelease channel requested (for example stable or beta).os_archSent by the clientOperating system and CPU architecture of the running binary.current_versionSent by the clientCurrent binary version, so the server can tell whether an update exists.ipObserved by the serverSource IP address of the request, visible to the server.timestampObserved by the serverRequest time recorded by the server.access_logsObserved by the serverCloudflare access logs (path, time, source IP, User-Agent).
Rule sync
ActiveThis build sends these requests only when you run the matching command; the fields, retention, and switch below apply.
Fetch signed detection-rule manifests and rule bundles so the local detectors can be tightened without shipping a new binary.
Fields involved
channelSent by the clientRule channel requested (for example stable or beta).os_archSent by the clientOperating system and CPU architecture of the running binary.current_schema_versionSent by the clientSchema version and serial of the rule set currently in use.ipObserved by the serverSource IP address of the request, visible to the server.timestampObserved by the serverRequest time recorded by the server.access_logsObserved by the serverCloudflare access logs (path, time, source IP, User-Agent).
This disclosure is generated from the same manifest the binary ships, and it is rendered field by field in the privacy policy as well.
Reporting a vulnerability
Do not open a public issue for a vulnerability.
Report it through GitHub's private security advisories; details are published only after a fix has been released. The disclosure policy lives inSECURITY.md in the open-source repository.