Gatekeeper is a Rust/Axum reverse proxy that removes sensitive values before LLM API requests reach an upstream provider and restores only mappings created while handling the current request.
agent / SDK Gatekeeper :8080 upstream provider
any LLM client authless, no sessions, 1 hop Anthropic / OpenAI / ...
| | |
| --- REQUEST ---------> | ---- tokens, no PII -----> |
| | |
| < -- RESPONSE -------- | < -- originals restored -- |
| | |
ROUTE
/health, /healthz .......... {"status":"ok"} (local)
/scan ...................... {matches, anonymized} (local)
anything else .............. proxy()
proxy() - outbound, in order:
1 body > GATEKEEPER_MAX_BODY_BYTES ................. 413
2 no body ................... forward untouched
(GET /v1/models, /api/tags, DELETE: no allowlist,
no rewriting - SDKs send a JSON content-type on
these too, and an absent body is not malformed)
3 JSON parse
ok ..................... step 4
claims JSON, broken .... 400
not JSON (binary, form, prose) ... passthrough, step 8
4 tool guard - WITHHOLD, never refuse
read of .env / *.pem / *.key ... result replaced by
a "ask the user" notice, the call still forwarded
cp .env /tmp/x ... legitimate, but the copy is tracked:
a later read of that path is blanked too
5 detector - PII + secrets -> tokens
API_KEY EMAIL CARD SSN IP DOB PHONE ADDRESS NAME
PATH CUSTOM
"[EMAIL_a1b2c3d4e5f6]" <- a real address, a phone, a name
fingerprints are cached process-wide, so a token the client
echoes from an earlier turn is recognized and wrapped in a
fresh request-local token before it reaches the provider
(the registry never stores or restores plaintext)
6 headers - hop-by-hop dropped, client accept-encoding
dropped, upstream asked for identity
7 auth - UPSTREAM_API_KEY replaces the client credential;
unset, the client's own header is forwarded
8 same path + query onto TARGET_URL - no schema
translation, no retries, redirects not followed
upstream - RESPONSE back:
9 status + headers forwarded; a 3xx handed to the caller
10 restore only this request's mappings
JSON | SSE | NDJSON | plain text
token split across chunks -> carried, then stitched
earlier tokens recognized inbound -> wrapped, not resolved
model-invented text and foreign tokens -> left alone
responses are never scanned for new secrets
11 no tokens ............ byte-for-byte passthrough
upstream compressed .. warn, bytes handed back as-is
undecodable body ..... 502
what can make Gatekeeper answer by itself:
413 oversized request, or oversized JSON response whose
tokens could not be restored
400 body declares JSON but does not parse
502 unreachable upstream, or undecodable upstream body
no policy denial exists: a protected read is stripped,
not rejected
- Detects and tokenizes common PII and secrets in JSON request strings and object keys, including recursively encoded JSON objects and arrays inside string values. See Secret coverage for the key and credential formats recognized.
- Handles bare 10- and 11-digit, leading-
+, dashed/dotted/parenthesized, international, and Vietnamese phone formats. Placeholder numbers such as000-000-0000are redacted too, so an undialable number in a prompt is still removed. - Detects person names through two routes. In ordinary text, a ~19k-entry multi-origin given-name dictionary requires a following capitalized surname; a bare first name is intentionally insufficient. Explicit identity contexts (
my name is,name:,Mr.,Mrs.,Ms.,Dr.) accept one to three words, while general person contexts (attn,cc,contact,patient,regards,signed,sincerely) accept multi-word names outside the dictionary. Contextual pairs may be lowercase or all caps. Unicode letters, hyphenated names, and common surname particles are supported. Selected common-word, calendar, direction, geographic, and known-phrase deny lists reduce false positives such as "New York", "Redis Cluster", and "Docker Compose"; they do not guarantee that every ambiguous English name is excluded. - Redacts outbound requests only. Response text is never scanned, so a name, number, or address the model invents reaches the client exactly as written.
- Redacts home-directory paths down to the account name:
/home/<user>,/var/home/<user>, and/Users/<user>become onePATHtoken, so the username goes and the rest of the path (/projects/gatekeeper/src) stays readable for the model and restorable for the client. - Redacts whatever you list in
GATEKEEPER_REDACT, plus optionally this machine's login and host name — see Custom redaction. - Withholds the result of outbound tool calls that read
.env,.pem, or.keyfiles, so private configuration and key material cannot be handed to a provider — while forwarding the request, so the agent keeps working. Writes and.env.exampleare unaffected. See.envread guard. - Restores tokens in JSON, Server-Sent Event, and other streamed responses, including tokens split across chunks. Restoration resolves only request-local mappings, so common model changes to token prefixes, brackets, separators, or hex case restore only when the digest belongs to that request.
- Supports Anthropic and OpenAI wire protocols without translating schemas. Every non-local route keeps its incoming path and query when forwarded to the configured upstream base path, apart from the dot-segment collapsing that any conforming URL parser performs. Upstream redirects are returned to the caller instead of followed inside Gatekeeper.
- Requests that carry no body are forwarded untouched, so provider endpoints that only read —
GET /v1/models,/api/tags,/api/version— need no allowance list and see exactly what the caller sent. SDKs putcontent-type: application/jsonon those calls too, which does not make an absent body malformed. Inspection starts where a body exists. - Never requires Redis or another external state service.
Plaintext mappings are request-local and are dropped when the response ends. A process-global registry retains only keyed fingerprints of previously issued tokens, never their original values. When a client sends an earlier token T1 in a later request, Gatekeeper recognizes its fingerprint and replaces it with a fresh request-local token T2 upstream. If the provider echoes T2, the client receives exactly T1, not the secret that originally produced T1. If the provider independently replays T1, it remains T1 because no global plaintext mapping exists.
This strict wrapping preserves opaque placeholders across conversation turns without retaining plaintext globally or allowing one request to recover another request's secret. Registry entries expire after 30 minutes without a full-token hit and cap at 10,000 entries, evicting the colder half under pressure. Unknown and colliding fingerprints stay opaque. Restarting Gatekeeper rotates the keyed fingerprint seed and clears the registry. No session header is required.
Keys and credentials are found by their own shape, wherever they appear —
a prompt, a pasted config, a file the agent read. This is independent of the
.env read guard, which works on file names: a key in
notes.txt or a heredoc is caught here even though that file is unprotected.
Vendor-prefixed keys. Recognized on prefix and length, so no label is needed:
| Vendor | Forms |
|---|---|
| Anthropic / OpenAI | sk-ant-…, sk-… |
| AWS | AKIA, ASIA, AGPA, AIDA, AROA, ANPA, ANVA + 16 |
| GitHub | ghp_, gho_, ghs_, ghu_, ghr_, github_pat_ |
| Generic PAT | pat_ + 20 alphanumerics |
| GitLab | glpat- |
| Slack | xoxb-, xoxa-, xoxp-, xoxr-, xoxs- |
AIza + 35 |
|
| Stripe | sk_live_, sk_test_, rk_live_, rk_test_ |
| Shopify | shpat_, shpca_, shppa_, shpss_ |
| npm | npm_ + 36 |
| Hugging Face | hf_ |
| DigitalOcean | dop_v1_ |
| SendGrid | SG.…….… |
| Square | sq0atp-, sq0csp- |
| Databricks | dapi + 32 hex |
| PyPI | pypi-AgEIcHlwaS5vcmc… |
| JWT | eyJ….….… |
PEM private keys. Matched on content, not file name: any
-----BEGIN … PRIVATE KEY----- through its matching -----END----- is taken
as one value, up to 8 KiB. RSA, EC, OPENSSH, PKCS#8, and unlabeled blocks all
match, so key material in backup.txt, server.pem.bak, or a shell heredoc is
removed even though those names mean nothing to the file guard. A lone armor
line is matched on its own, so a truncated or quoted block is still caught.
Labeled secrets. A password or house-built token has no prefix and no
shape, so the label is what finds it. After password, passwd, secret,
api_key / api-key / apikey, auth_token, access_token,
refresh_token, client_secret, private_key, or bearer — followed by
: or = — the value is tokenized and the label stays readable.
AWS secret access keys need their label too (aws_secret_access_key = …).
Forty base64 characters is too ordinary a shape to redact unlabeled.
What this deliberately does not catch:
- Prose.
rotate the password before Fridayhas no separator and no value. - Environment references and placeholders:
api-key: $ANTHROPIC_API_KEYandapi_key: <your-key-here>name no secret, so both pass through. - Type annotations:
private_key: Option<String>is code, not a credential. A bare PascalCase type wider than eight characters (client_secret: SecretString) is still tokenized — it round-trips intact, so the cost is model readability rather than correctness. - Unlabeled high-entropy strings. A bare 32-character value with no prefix and no label is indistinguishable from a hash, an ID, or a commit SHA.
Everything here lands in the API_KEY token class and round-trips like any
other value: the provider sees a token, the client sees the original bytes.
Gatekeeper inspects outbound tool-call invocations and withholds the result of any that read a protected file, so a file's secrets can never ride a request to the provider:
-
Protected: the exact basename
.env, plus any.pemor.keyfile. Add more toPROTECTED_BASENAMES/PROTECTED_EXTENSIONSinsrc/toolguard.rs. -
Not protected:
.env.example,.env.local, any other.env.*variant, and.pubpublic keys, which are meant to circulate. -
Writes pass. An agent may create or overwrite these files, so the usual
cp .env.example .envsetup works. -
A copy of a protected file stays protected.
cp .env /tmp/local.envis a legitimate tool call and is forwarded untouched — but the guard records where the secret landed and withholds reads of that path too, socat /tmp/local.envon a later turn is stripped like the original. Copy-of-a-copy chains resolve, since every hop is in the request history. -
Checked for every tool, not just file tools: structured arguments (
input,arguments,path,file_path) and shellcommandstrings both count. -
The request is forwarded, not refused. A read has already run on your machine by the time its contents ride back outbound, and clients resend conversation history every turn — so a refusal stops the agent and every later turn of that session without un-reading the file. Withholding the payload blocks the one thing that matters (the provider seeing the file) and leaves the agent working. The result becomes:
[gatekeeper withheld the contents of .env. Do not retry the read or reach for another tool: this proxy never forwards them to the model provider. Ask the user for the value you need.]The wording tells the model to ask rather than route around, which is what a silent blank would invite.
-
Correlated by call id (
tool_use_id,tool_call_id,call_id), so only the result answering that read is blanked: unrelated tool output, prose mentioning.env, and the read invocation itself all survive untouched. A read whose result is not in the body cannot be stripped and is logged asunpairedatwarnlevel, where PII redaction is the remaining net. -
Shell expansions are resolved, not refused.
x=n; cat .e${x}vnames.envonce the command's ownx=nis substituted, so it is caught. A value inherited from the parent shell (${HOME},$()) cannot be resolved from the request alone; those spellings are not blocked, and PII redaction is the net. The earlier fail-closed rule that blocked any shell call containing$(,${, or a backtick was removed — it stoppedecho "built $(date)"and similar ordinary commands, which is the over-blocking the forward-only design is meant to avoid. -
The guard reads outbound requests only. Provider responses are never blocked or rewritten.
Anything Gatekeeper does reject is still shaped like a provider error, so a client or a model relaying the failure shows a reason instead of an opaque transport error:
{
"type": "error",
"error": {
"type": "gateway_error",
"message": "upstream request failed: connection refused"
}
}error.type is gatekeeper_security_error when a size limit protects redaction and
gateway_error for everything else Gatekeeper rejects itself (oversized body,
unreachable upstream, undecodable upstream response). Those cannot be answered with a
pass-through: an oversized body cannot be parsed, so it cannot be redacted, and a
transport failure has no upstream answer to forward. They are 413/502, not policy
denials — no security control on the agent path throws any more.
# Literal values, comma-separated, matched case-insensitively and whole-word.
export GATEKEEPER_REDACT='acme-corp,Project Chimney'
# Also redact this machine's login name and host name, read at startup so they
# never have to be written into an env file. `user`, `host`, or both.
export GATEKEEPER_REDACT_IDENTITY=user,hostBoth land in the same CUSTOM token class and round-trip like any other value.
Limits worth knowing:
- Values are literals, not regexes:
12.34blanks only that string, never every two-digit-dot-two-digit number. Pattern support is the obvious next step if a case for it shows up. - A value with a comma cannot be expressed, and one-character values are dropped as noise.
GATEKEEPER_REDACT_IDENTITYskips names that are ordinary words (root,admin,node,localhost, …) and logs why — blanking those would mangle more text than it hides. Force one by listing it inGATEKEEPER_REDACT.- Home-directory paths need no configuration at all:
/home/<user>,/var/home/<user>, and/Users/<user>are always tokenized./rootis not, since it names a standard account rather than a person.
cp .env.example .env
export TARGET_URL=http://localhost:20128
export UPSTREAM_API_KEY=your-9router-endpoint-key
cargo run --releaseGatekeeper is authless: it never checks a client credential. UPSTREAM_API_KEY is the credential it presents to the upstream, and it replaces whatever the client sent so a stale client key cannot leak past the proxy. Leave it unset to forward the client's own auth headers unchanged.
Images are published to ghcr.io/legitcamper/gatekeeper. Pushes to main publish main and latest; tags such as v1.2.3 also publish v1.2.3, 1.2.3, and 1.2.
docker pull ghcr.io/legitcamper/gatekeeper:latest
docker run --rm --env-file .env -p 8080:8080 ghcr.io/legitcamper/gatekeeper:latestFor Compose, copy the environment template, set a reachable upstream, then start the example:
cp .env.example .env
docker compose -f compose.yml up -dInside a container, localhost refers to that container. Set TARGET_URL to an upstream Compose service name such as http://ollama:11434, or another host reachable from the container. If the GHCR package is private, authenticate first with docker login ghcr.io.
The default listen address is 0.0.0.0:8080. Send provider requests to Gatekeeper using the same path and headers you would send upstream:
curl http://127.0.0.1:8080/v1/messages \
-H 'content-type: application/json' \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H 'anthropic-version: 2023-06-01' \
-H 'x-request-id: example-request' \
-d '{
"model": "claude-opus-5",
"max_tokens": 256,
"messages": [{"role":"user","content":"Email Alice Johnson at alice@example.com or +1 (415) 555-2671"}]
}'No session header is required: strict wrapping carries opaque tokens across turns without global plaintext restoration. Use any request ID header when upstream observability needs correlation; it does not affect token restoration.
Gatekeeper is endpoint- and schema-neutral. Anthropic /v1/messages, OpenAI /v1/chat/completions or /v1/responses, and other provider routes use the same incoming path and query upstream. A path already present in TARGET_URL is retained as a prefix, and a query on it is kept as an upstream parameter: with TARGET_URL=https://gateway.example/api?api-version=2024-02-01, incoming /v1/messages?beta=1 goes to /api/v1/messages?beta=1&api-version=2024-02-01. Configure TARGET_URL as the canonical provider or gateway base URL; Gatekeeper does not discover or rewrite provider endpoints.
Request methods, provider headers, response status, response headers, JSON structure, SSE framing, NDJSON records, and stream mode pass without Anthropic/OpenAI schema translation. Gatekeeper does not retry requests or convert a streaming request into a non-streaming one. Upstream redirects are forwarded with their 3xx status and Location; Gatekeeper does not follow them and hide the redirect behind a later response.
Responses are not validated against provider schemas or scanned for new sensitive values. Only request-local mappings are restored. The process-global registry can recognize an earlier token's keyed fingerprint but holds no plaintext: provider replay of an earlier T1 remains T1, while inbound T1 is wrapped as a fresh T2 upstream and an echoed T2 unwraps to exactly T1. A name, number, address, or secret newly introduced by the upstream is never scanned, filtered, or tokenized. A stream event with nothing to restore is forwarded byte for byte, event: line, key order, and all; a payload that did receive a value is re-encoded, which can change insignificant whitespace or object-key order without changing its data structure. Provider protocol fields — type, index, delta.type, stop reasons — are never rewritten by restoration. Response status and end-to-end headers remain upstream's; only hop-by-hop headers are removed as HTTP forwarding requires. If a provider or intermediate gateway directly returns valid JSON with HTTP 200 but the wrong provider schema, Gatekeeper forwards it; diagnose that upstream response and its request-ID headers. Malformed JSON that is valid UTF-8 is likewise forwarded with its upstream status after request-local restoration. A fresh non-JSON request body that contains raw PII is passed through unredacted; non-JSON text is inspected only for recognized placeholders that need strict wrapping. Gatekeeper asks upstream for accept-encoding: identity so responses arrive as plain text; an upstream that ignores that gets its compressed body forwarded byte for byte with its Content-Encoding and a warn log, unrestored, because rewriting compressed bytes would corrupt them for the client. Undecodable bytes with no declared encoding cannot be restored at all and produce a Gatekeeper 502.
GET /healthandGET /healthzreturn{"status":"ok"}.POST /scanaccepts{"text":"..."}and returns detected values plus an anonymized form.- Every other route is forwarded to
TARGET_URL, preserving the incoming path and query.
| Variable | Default | Meaning |
|---|---|---|
TARGET_URL |
http://127.0.0.1:11434 |
Upstream provider base URL |
LISTEN_ADDR |
0.0.0.0:8080 |
TCP listen address |
UPSTREAM_API_KEY |
unset | Upstream credential, injected in place of the client's. Falls back to ANTHROPIC_API_KEY |
UPSTREAM_AUTH_HEADER |
x-api-key |
Header carrying it. Use authorization for Bearer gateways and include the Bearer prefix in the value |
GATEKEEPER_MAX_BODY_BYTES |
10485760 |
Maximum buffered request/JSON response body |
GATEKEEPER_REDACT |
unset | Comma-separated literal values to redact as CUSTOM, matched case-insensitively and whole-word |
GATEKEEPER_REDACT_IDENTITY |
unset | user, host, or both: redact this machine's login name and hostname, read at startup |
RUST_LOG |
info |
Tracing filter |
cargo fmt --all -- --check
cargo test --all-targets --all-features --locked
cargo clippy --all-targets --all-features --locked -- -D warnings
cargo bench --bench detectorGatekeeper was inspired by AgentVeil but has since evolved into an independent project.