A tiny credential-injecting egress proxy so an agent (Prax, or any client) runs with no real API keys in its process. The proxy — a separate, isolated service — holds the keys; the agent points its model client's base URL at the proxy, which strips the placeholder auth, injects the real key, forwards to the provider, and streams the response back.
The guarantee: a compromised or prompt-injected agent has nothing to steal —
it can't read or exfiltrate a key it never holds. This is the infra-level "make the
secret unreachable" boundary: the real wall, versus an in-code guard the agent can
edit (or a second .env in the same repo, which the agent's process can just
open()).
Part of the Prax suite. Apache-2.0.
Two .env files in one directory is not a boundary — the agent's process can
read any file it has filesystem access to. Real isolation requires the keys to live
where the agent's process can't reach them: a separate OS user, container, or
host. This proxy is that separate trust domain. Deploy it isolated; that isolation
— not the file naming — is the security.
It's opt-in and adds no default friction. An agent that doesn't want it just keeps its keys in its own env and never points a base URL here. Nothing to run, nothing to learn. The proxy is for deployments that want the hardened, keyless mode.
Docker (recommended — a separate container is the isolation):
cp .env-example .env # put the REAL keys in .env (.env is gitignored)
docker compose up --build # gunicorn on :8785, in its own containerOr natively, in the proxy's own shell:
cp .env-example .env # put the REAL keys in .env (.env is gitignored)
pip install -e . # or: uv sync
python -m secrets_proxy # loads .env; listens on 127.0.0.1:8785Then point a keyless agent at it. The agent's "key" is the proxy access token (see below) — not a real provider key:
OPENAI_BASE_URL=http://<proxy-host>:8785/openai
ANTHROPIC_BASE_URL=http://<proxy-host>:8785/anthropic
OPENAI_KEY=<the PROXY_AUTH_TOKEN> # the proxy swaps this for the real key
ANTHROPIC_KEY=<the PROXY_AUTH_TOKEN>GET /healthz reports which providers have a key (booleans only, never values).
This section is the reverse proxy on :8785. The opt-in forward proxy on :8786
has its own, separate token (PROXY_FORWARD_AUTH_TOKEN) — see
Forward (MITM) proxy.
The proxy owns the token. So that only the authorised agent — not any other
process or person who can reach the port — can spend the keys, the proxy requires a
shared token. Generate it on the proxy side and set PROXY_AUTH_TOKEN in the
proxy's .env; hand the agent a copy as its OPENAI_KEY/ANTHROPIC_KEY. The agent
presents it in the normal auth slot (Authorization: Bearer … / x-api-key); the
proxy validates it constant-time, then strips it and injects the real provider
key. With PROXY_AUTH_TOKEN set, a missing or wrong token → 401, before it even
reveals whether a provider exists. Leaving PROXY_AUTH_TOKEN empty runs the reverse
proxy open to any caller that can reach the port (docker-compose.yml publishes it
to 127.0.0.1 only by default, and that reachability is then the only control).
./scripts/gen-token.sh # prints prx_… → put in PROXY_AUTH_TOKEN + agent's keysTLS (so the token + traffic aren't sent in plaintext). On loopback nothing crosses a wire, so it's optional there; for anything cross-host, turn it on:
./scripts/gen-cert.sh <proxy-host> # writes certs/proxy.crt + .key (self-signed)
# proxy .env: PROXY_TLS_CERT=certs/proxy.crt PROXY_TLS_KEY=certs/proxy.key
# agent env: SSL_CERT_FILE=/abs/path/certs/proxy.crt + https:// base URLsSSL_CERT_FILE is honored by the httpx-based OpenAI/Anthropic SDKs, so the agent
trusts the self-signed cert with no code change. (mTLS is a natural next step if
you want the proxy to authenticate the agent by client cert instead of a token.)
(This section describes the reverse proxy on :8785. The forward proxy's properties
and verification status are in its own section below.)
Guarantees
-
The agent never holds a real key → it can't be exfiltrated from the agent by any path (env read,
.envread, a poisoned tool call, an injection). -
Client-supplied auth is stripped and the real key injected server-side, so a leaked placeholder is worthless.
-
Allowlist by construction — only the configured providers (
/openai/…,/anthropic/…) are reachable; an unknown prefix is a404, so it can't be turned into an open relay. -
Audit log — one line per call (method / provider / path / status / request size), never the key or body.
-
Token-gated — with
PROXY_AUTH_TOKENset, only a caller presenting the shared token can reach any provider; everyone else gets401.
Limits (go in clear-eyed)
- It stops key theft, not key abuse — a compromised agent that still holds the token can make legitimate-looking calls it shouldn't (spam the model; smuggle data inside a request to an allowed provider). That's a strong containment of the key material, not total security — "hardened," not "airtight." Mitigate further with rate limits, payload caps, the audit log, and (optionally) a policy inspector on flagged requests.
- The proxy is the trusted component — it holds the keys, so isolate it (its own user/container) and don't let the agent reach its config.
The reverse proxy above only covers providers that expose a base-URL knob (/openai,
/anthropic). Everything else an agent calls (search APIs, TTS, telephony, …) goes
straight to the real host over TLS, so the only way to keep those keys out of the
agent is a forward proxy that terminates TLS and injects the credential by
destination host. That is the forward compose profile: mitmproxy running this
repo's addon (secrets_proxy/mitm_addon.py; the injection logic is
secrets_proxy/forward_inject.py).
# 1. Generate the host→credential map from Prax's credential registry (run in the prax repo):
python -m prax.services.credential_registry --export-forward-map ../prax-secrets-proxy/forward-map.json
# 2. Set PROXY_FORWARD_AUTH_TOKEN in the proxy's .env (see below), then:
docker compose --profile forward up # mitmdump on :8786, published to 127.0.0.1 onlyNatively, the addon needs the forward extra (pip install -e '.[forward]', which
pulls in mitmproxy) and runs as
mitmdump --mode regular --listen-host 127.0.0.1 --listen-port 8786 -s secrets_proxy/mitm_addon.py
with PROXY_FORWARD_MAP pointing at the map file. Pass --listen-host explicitly:
mitmdump's listen_host default is empty, which binds every interface, so without it
the native listener is not loopback-only the way the compose port mapping is.
The forward-map. forward-map.json is generated, gitignored, and mounted
read-only into the container. Each rule is a destination host plus one of four
injection schemes (bearer, header:<Name>, basic, query:<param>) and the env
var(s) holding the real value; a rule matches its host exactly or as a dot-suffix
(tavily.com also covers api.tavily.com), longest host first. A host with no rule
passes through untouched, and with no map at all the proxy injects nothing. The map
includes the model providers, so in forward mode you do not also set
OPENAI_BASE_URL/ANTHROPIC_BASE_URL.
Client wiring. The client sets HTTPS_PROXY (and HTTP_PROXY) at the proxy with
the token in the URL's credential slot, and trusts the mitmproxy CA:
HTTPS_PROXY=http://prax:<PROXY_FORWARD_AUTH_TOKEN>@<proxy-host>:8786
# CA: mitmproxy generates one on first start; the compose file persists it in the
# `mitm-ca` volume at /home/mitmproxy/.mitmproxy/mitmproxy-ca-cert.pem. Copy it out
# and add it to the bundle the client trusts (SSL_CERT_FILE / REQUESTS_CA_BUNDLE).The Prax-side procedure (CA bundle, NO_PROXY, non-empty placeholder keys) is in the
prax repo:
docs/security/deployment-topology.md.
Caller authentication — PROXY_FORWARD_AUTH_TOKEN. This listener has its own
token, separate from the reverse proxy's PROXY_AUTH_TOKEN:
- With the token set, the addon reads
Proxy-Authorization—Basic user:token(what HTTP clients send for credentials in a proxy URL) orBearer token— compares the token constant-time, and answers407withProxy-Authenticate: Basic realm="prax-forward-proxy"on a missing or wrong token. The check runs before the injection rule is looked up, so a refused caller cannot probe which hosts get credentials. - The credential is stripped before the request goes upstream, token or no token — it authenticates the caller to this proxy and must never reach a provider.
- With the token empty the forward proxy is open: anyone who can reach
:8786spends the real keys anonymously. The addon logs a warning at startup; loopback publishing is then the only control. - The
Basicusername is free-form and is meant to identify the caller in the audit line (see the known gap below).
Audit. The addon logs one line per injected request —
[forward] injected <scheme> @ <host> (caller=<label>) — and never a key or a body.
(mitmdump's own console output is separate from this line.)
Known gap (2026-09): the caller= label is always -. request() in
secrets_proxy/mitm_addon.py deletes Proxy-Authorization (to keep it off the wire
upstream) before it calls _caller_label() on the same headers, so the username is
gone by the time the audit line is built. Reproduced with a valid Basic credential.
The unit test for the label (tests/test_forward_auth.py,
test_caller_label_is_the_username_never_the_token) calls _caller_label() on a
hand-built header dict rather than through request(), so it does not catch this.
Verification status. The 407/allow/strip behaviour above is unit-tested only,
against a hand-built request object (tests/test_forward_auth.py), not through
mitmproxy. One open question matters for real clients: the check runs in mitmproxy's
request hook, and HTTP clients send Proxy-Authorization on the CONNECT request
when the destination is https://, not on the tunnelled requests inside it. Whether
the hook sees that credential for HTTPS destinations has not been verified live
with the token set; if it does not, HTTPS callers get 407 on every request once
PROXY_FORWARD_AUTH_TOKEN is set. Until that is verified, treat the forward proxy's
token gate as unproven and keep :8786 on loopback or a private interface.
What this process can see. It terminates TLS for all proxied egress: every destination, every request and response body, and it holds every key in the map. It is strictly more trusted than the reverse proxy. Run it locked down and isolated from the agent (its own container/user), exactly like the reverse proxy. The "stops theft, not abuse" limit above applies unchanged.
By default the forward proxy injects keys for known hosts and passes
everything else through untouched. Set PROXY_EGRESS_POLICY (and
PROXY_EGRESS_ADMIN_TOKEN) and it decides every request instead: allow,
deny, or ask. An ask holds the request while a person is asked through
the admin API (127.0.0.1:${PROXY_EGRESS_ADMIN_PORT:-8791}).
Tokens:
PROXY_EGRESS_ADMIN_TOKENanswers questions and belongs to the relay that carries a person's answers (TeamWorkEGRESS_GATES), never the agent.PROXY_EGRESS_TAINT_TOKENis the agent's, and can only raise taint. The policy lives insecrets_proxy/egress_policy.py, with an example inegress-policy.example.json.
Why here. This proxy already terminates TLS for all of Prax's traffic, so
rules see the method and path of HTTPS requests, not only the host. You
can allow GET but ask about POST to the same site.
Judged on what is dialled. A request is judged on the address the proxy
will actually connect to (the CONNECT target or absolute URL), and refused if
its Host header disagrees. The connection is pinned to the address that
was checked, so DNS rebinding cannot redirect it inside. Paths are decoded
and normalised before matching (/public/../admin is not under /public).
Raw TCP tunnels, which never reach the request hook, are refused.
No DNS before a decision. With the policy on, the add-on switches mitmproxy
to connection_strategy=lazy. A denied or merely-asked-about name is never
resolved, so it cannot leave as a DNS query. After an allow, a name that
resolves to a private, loopback or link-local address is still refused
(SSRF).
Answers are scoped. They are remembered per host + method + path for
PROXY_EGRESS_ALLOW_TTL. An allow given while clean is not reused once the
harness marks the work tainted. No answer means deny.
Enforcement is the other half. A policy only binds traffic that goes
through the proxy. Prax's deploy/systemd/prax.service.d/40-egress-only-through-the-proxy.conf
restricts the Prax process to loopback, with the kernel enforcing it, so the
proxy is its only way out.
Verified live (2026-09-24), with the real mitmproxy and Prax's approval poller:
GET https://example.netwas allowed by rule;POST https://example.net/uploadwas held, then denied by a person (403);- an unknown host was held, then allowed (200);
- a process under the loopback-only restriction could not connect directly, but could through the proxy.
Idea credit: NVIDIA OpenShell's policy prover, which requires policies to "stay within an allowed access boundary" and flags "whether proposed network rules add risky access".
-
Ceiling. Set
PROXY_EGRESS_CEILING=/config/egress-ceiling.jsonand pointPROXY_EGRESS_CEILING_FILEat your own file (default:egress-ceiling.example.json). It uses the same format as the policy, and is usually"default": "deny".- It is checked before the policy on every request.
- Anything outside it is denied outright and never asked about, so no person's answer, timed grant or later policy edit can exceed it.
- At startup the proxy logs every policy rule that reaches past it.
-
Diff. Review what a policy change newly allows before you ship it:
python -m secrets_proxy.egress_policy diff old.json new.json \ --ceiling egress-ceiling.json --forward-map forward-map.json- It lists each request the new policy allows, or asks about, that the old one didn't.
- It marks the ones that would carry an injected credential, and the ones the ceiling will refuse anyway.
- It exits 1 when anything opened, so it can gate a deploy.
This is not a proof. It compares the two policies only over the hosts, methods, ports and paths their own rules mention (each checked clean, and again after private data was read), plus "any other host" to cover the defaults.
Idea credit: NVIDIA OpenShell, whose network policy is per program.
Give each program its own token. Run
python -m secrets_proxy.callers new sandbox, give the printed token to that
program alone (HTTPS_PROXY=http://sandbox:<token>@proxy:8786), and put the
printed entry in a callers file (see callers.example.json). Then set
PROXY_FORWARD_CALLERS=/config/callers.json, with PROXY_FORWARD_CALLERS_FILE
pointing at your copy. The file holds only hashes. The main
PROXY_FORWARD_AUTH_TOKEN keeps working, and identifies as
PROXY_FORWARD_AUTH_NAME (default prax).
How a request is identified. Its identity is the name of the token it presented. The Basic username is not used, because anyone holding any token can set it to anything.
Name programs in rules. A rule with "callers": [...] applies only to
those programs. This works in the policy and in the ceiling alike:
{"host": "api.openai.com", "callers": ["prax"], "action": "allow"},
{"host": "pypi.org", "callers": ["sandbox"], "methods": ["GET"], "action": "allow"}
- Only one program can spend a key. Allow a credential's host for
praxalone, and no other program can use that credential, even through the same proxy. - Questions carry the program. A question shows which program asked
(
callerinGET /pending), and a person's answer covers that program only. diffreports per program. It says which program a change opens a request for ("POST api.openai.com/ by sandbox").- Rules fail closed. A rule naming programs never matches a request with no identity, so those requests fall through to later rules and the default.
Honest limit. An identity is only as separate as its token. Programs that share an environment (one container, one user) can read each other's tokens. This therefore separates components (the harness, the sandbox shell, the browser), not individual processes inside one of them. OpenShell's supervisor attributes each connection to the binary that opened it. That is the stronger form, and it is not built here.
Some protocols carry the credential in a message, not a header. A forward-map
rule with scheme ws-json:<path> sets the credential at that JSON path in
client→server WebSocket text messages to the host (wss only). Discord's
gateway sends the bot token in IDENTIFY and RESUME at d.token; REST wants
Authorization: Bot <token>, which header:Authorization with "prefix": "Bot "
covers.
A rule can name the program identities it injects for ("callers"). A rule
marked "exclusive": true must name them, or the proxy refuses to start: it
is for a credential exactly one instance may use. Two Prax instances holding a
Discord bot token both answer every message, so a dev instance with a
placeholder must never get the real token.
{"host": "discord.com", "scheme": "header:Authorization", "prefix": "Bot ",
"key_env": "DISCORD_BOT_TOKEN", "callers": ["prax-prod"], "exclusive": true},
{"host": "discord.gg", "scheme": "ws-json:d.token",
"key_env": "DISCORD_BOT_TOKEN", "callers": ["prax-prod"], "exclusive": true}
Verified 2026-10-01 with real discord.py 2.7.1 through this proxy against a fake
Discord: the named caller logged in and its IDENTIFY carried the injected token;
another caller got 401 / LoginFailure and never reached the gateway. Not yet
run against Discord itself.
Idea credit: NVIDIA's Open Agent Safety Platform (monitoring "on the node's only path to the model", out of the agent's reach).
An agent's own audit log lives in the process it audits, so a compromised
agent can drop entries. This proxy sits on the model path, outside that
process. With PROXY_WIRE_RECORD=/wire/wire.jsonl it appends one line per
model response (OpenAI-compatible and Anthropic, streamed or not):
{"ts","caller","host","path","status","model","request_sha256","response_bytes",
"tool_calls":[{"name","args_sha256"}],"prev","hash"}
- Never text — tool names, argument hashes and sizes only.
- Hash-chained: each line covers the previous line's hash, so an edit,
deletion or reordering breaks the chain from there
(
python -m secrets_proxy.wire_record verify wire/wire.jsonl). That is tamper-evident: whoever can rewrite the whole file can rebuild the chain, so keep./wirewritable only by the proxy and copy the head hash off the box (your backups) to anchor it. callersays which instance made the call, so instances sharing the proxy — dev and prod — can be told apart. With per-program identities it is the name whose token matched (PROXY_FORWARD_CALLERS); with only the main token it is the username inhttp://<caller>:<token>@host:8786. Give each instance its own.- Prax's
scripts/check_wire_record.pycompares the record with Prax's own traces and lists tool calls on the wire the traces don't show. - Limit: responses larger than
stream_large_bodies(1 MB in the compose file) stream through without being buffered and are recorded by size only.
- Front it with a real WSGI server, not the Flask dev server (the Docker image does
this):
gunicorn -k gthread -w 4 'secrets_proxy.app:build_proxy_app()'. - Set
PROXY_AUTH_TOKENand require it from the agent; enable TLS for any non-loopback link (or run over a tunnel — WireGuard/Tailscale). Loopback on a trusted host can skip both (nothing crosses a wire). - Forward mode: set
PROXY_FORWARD_AUTH_TOKENas well, and read the verification status above before relying on it as the control. - Run it as its own container/user with the keys in its secret store only.
pip install -e '.[dev]'
pytest -q # keyless: mocks the upstream, pins the security properties