The secrets proxy — running a KEYLESS Prax
The optional prax-secrets-proxy keeps supported provider API keys outside Prax's process. Prax sends requests to the proxy, which substitutes the credential and streams the response. This protects provider key material only when Prax…
f7a8cb56
View source ↗
The optional prax-secrets-proxy keeps supported provider API keys outside Prax’s process. Prax sends requests to the proxy, which substitutes the credential and streams the response. This protects provider key material only when Prax cannot read or administer the proxy’s environment, files, or host. Prax still holds a proxy token and other local credentials, and can misuse its authorized access.
The design goal is the infra-level “make the secret unreachable” boundary that
the OpenAI long-horizon-safety assessment
names as the real wall — an in-code guard the agent can edit is only a speed
bump (that piece’s “model split a token to evade the scanner” incident, and the
source_grep secret leak fixed here). The same
boundary caps sandbox env-key exfiltration at the root provided the sandbox is
not handed the token. Closed 2026-09: prax’s own docker-compose.yml /
.lite.yml used to pass OPENAI_API_KEY=${OPENAI_KEY} /
ANTHROPIC_API_KEY=${ANTHROPIC_KEY} into the sandbox service (in keyless mode,
the proxy token — spendable, not stealable); they now pass nothing, matching
prax-sandbox’s own compose, and tests/test_compose_sandbox_service.py pins it.
See sandbox-execution-boundary.md.
This page is about Tier 1: the model providers (OpenAI-compatible + Anthropic), reached through a base-URL reverse proxy — where the high-value keys live. The Tier 2 forward (MITM) proxy for all other REST egress is shipped as an opt-in profile of the same repo; its wiring and lock-down checklist live in deployment-topology.md (summary below).
Two paths — and which Praxagent endorses
| Configuration | Where provider keys live | Requirement |
|---|---|---|
| Direct provider access | Prax’s environment | Code able to read that environment can obtain the keys. |
| Reverse proxy | A separate service | Keep secrets and administrative access outside Prax’s reach; require a proxy token. |
Both configurations are supported. Praxagent endorses the reverse-proxy path for
its security properties; direct provider access stays the zero-friction default
so casual adoption isn’t taxed, but it is planned for deprecation in a future
release — prefer the proxy for anything new or exposed. Use credential
isolation to reduce exposure to provider-key extraction; a second repository,
process, or virtual environment under the same OS user does not create that
isolation. Turning the proxy on needs no code change for the agent’s model calls
(OPENAI_BASE_URL already existed; ANTHROPIC_BASE_URL was added so
ChatAnthropic can point at the proxy too).
Known gap (2026-09) — closed 2026-09 for the harness-owned clients. Only
build_llm() used to read OPENAI_BASE_URL / ANTHROPIC_BASE_URL; the direct
OpenAI(api_key=settings.openai_key) clients elsewhere did not, so under a
base-URL-only reverse-proxy path they sent the proxy token to api.openai.com
and failed (live symptom, 2026-09-07: an hourly schedule dying with
invalid model ID from the legacy history summariser in
prax/conversation_memory.py). The OpenAI SDK would pick up OPENAI_BASE_URL
from the process environment, but Pydantic loads .env without exporting it,
and nothing guarantees a process has it (tests, CLIs and evals export nothing;
the server’s _export_dotenv_config in prax/settings.py exports .env minus
credentials) — so the fix is in code, not config.
What the server puts in its environment. app.py exports .env with
every credential withheld (the registry’s rows and any *_KEY/*_TOKEN/
*_SECRET/*_PASSWORD-shaped name), because every child process inherits it.
Until 2026-10-04 that was not what happened: Flask’s app.run() loads the whole
.env into the environment unless told load_dotenv=False, so every key in
.env was there for any child to print. prax/agent/llm_factory.py now has openai_client(), the only
sanctioned constructor of a raw openai.OpenAI client, with exactly
build_llm’s OpenAI key/base-URL semantics (OPENAI_KEY = the proxy token
when keyless; OPENAI_BASE_URL → the proxy; callers cannot override
api_key/base_url). tests/test_keyless_clients.py AST-scans prax/ and
fails CI on any other construction. Migrated: prax/conversation_memory.py
(summaries via build_llm(tier="low"), so tier/provider routing applies as
well), prax/services/youtube_service.py (Whisper via openai_client()),
prax/readers/web/web2mp3.py (summary via build_llm, TTS via
openai_client()), prax/readers/latex/latex_functions.py (via build_llm),
prax/services/sms_service.py (its client was never read; removed). Still
direct — grandfathered in that test (new sites fail, stale entries warn, so the
list only shrinks) and still failing under
the base-URL-only path until routed through openai_client():
prax/plugins/capabilities.py (TTS, Whisper), prax/services/library_service.py
(covers), prax/readers/latex/latext_gpt_tools.py, and
prax/plugins/tools/image/plugin.py (plugins may not import prax.agent, so it
needs a capability). The embedder and the vision client keep their own
EMBEDDING_BASE_URL / VISION_BASE_URL deliberately. The forward (MITM) mode
does not depend on a base URL, so it should cover the remaining clients (the
SDK’s httpx transport honours HTTPS_PROXY), but this has not been checked per
client — see the ledger row; the 2026-07-22
forward-proxy verification recorded OpenAI returning 200 through it without
exercising these clients individually.
Why a separate service and repo
The boundary depends on filesystem permissions, host administration, and network policy. Stock Prax Compose mounts the host Docker socket into Prax. A proxy container on that same daemon is not isolated from a process with administrative access to the daemon. Run the proxy on a separate host or independently administered daemon Prax cannot control, or redesign execution privileges before relying on same-host isolation. See Deployment topology.
How it works
Prax Reverse proxy Provider
OPENAI_KEY = proxy access token --> validate token
OPENAI_BASE_URL = .../openai inject provider key ----> model API
<-- stream response <----
/openai/… and /anthropic/… select configured upstreams. Unknown prefixes return
404. When PROXY_AUTH_TOKEN is set, missing or incorrect client tokens return
401 before forwarding. The implementation allows unauthenticated access when
that setting is empty; production operators must set it. /healthz is a
separate readiness endpoint and is not an authentication test.
Prax’s OPENAI_KEY and ANTHROPIC_KEY must contain the configured proxy token,
not an arbitrary placeholder, for the authenticated reverse proxy.
Run it
Production: isolated reverse proxy
-
On a proxy host Prax cannot administer, clone the proxy repository and follow its container setup. Store provider keys only in its private configuration. Generate
PROXY_AUTH_TOKENwithscripts/gen-token.shand set it there. If you usescripts/gen-cert.shfor a self-signed certificate, makecerts/proxy.keyreadable by the container’sproxyappuser — a0600key raisesPermissionErrorat startup. -
Start its production Gunicorn container. Keep the default loopback binding and reach it through an authenticated encrypted tunnel, or deliberately publish on a private interface with TLS and firewall rules allowing only the Prax host. The stock loopback binding is not remotely reachable.
-
Set URLs reachable from Prax’s own network namespace:
OPENAI_BASE_URL=https://proxy.example.internal:8785/openai ANTHROPIC_BASE_URL=https://proxy.example.internal:8785/anthropic OPENAI_KEY=<PROXY_AUTH_TOKEN> ANTHROPIC_KEY=<PROXY_AUTH_TOKEN>Replace the example hostname. Use a trusted certificate, or add the proxy CA to a bundle preserving system roots and point
SSL_CERT_FILEto that bundle inside Prax’s process/container. Do not disable certificate verification. An encrypted tunnel terminating on loopback may use HTTP on its local leg.If the OpenAI-compatible route forwards to OpenAI itself, also set
OPENAI_BASE_URL_IS_OPENAI=true. Prax otherwise treats a custom base URL as a third-party endpoint and forces Chat Completions, which can break models requiring Responses. Leave this flag false for OpenRouter or other compatible upstreams. This applies to the local wiring below as well. -
The sandbox needs nothing from this: stock Prax Compose passes it no provider keys and the image ships no coding-agent CLI (closed 2026-09 — previously the two key values were passed without matching provider base URLs). Any coding client you install in the sandbox yourself must be configured separately; a token without client endpoint configuration does not make it use the proxy.
-
Check readiness, then request a provider route without a token and confirm
401. Repeat with an incorrect token. Finally make one small authorized model request and check its response and proxy log. This last step is a billed provider call; readiness alone does not prove it works.
Development wiring on one host
make secrets-proxy runs the sibling in a virtual environment at 127.0.0.1:8785;
the full-Compose secrets-proxy profile runs a container. These are wiring
examples, not proof of isolation from the same user or Docker administrator.
Native Prax uses http://127.0.0.1:8785/openai and /anthropic. Prax in the same
Compose network uses http://secrets-proxy:8785/openai and /anthropic. Set a
nonempty PROXY_AUTH_TOKEN and use it as the client’s key in either case.
Plain HTTP belongs only on a trusted local leg or inside an encrypted tunnel.
Security properties — and limits
- Provider-key isolation does not protect workspace data or every credential in Prax. Session-signing, inbound, sandbox, and other local tokens remain sensitive.
- A leaked proxy token cannot authenticate directly to the provider, but can authorize calls through a reachable proxy. Revoke it after suspected compromise.
- The reverse proxy restricts upstream selection. It does not prevent other network paths or sending sensitive data to an allowed provider. Enforce network policy outside the agent where needed.
- Logs omit keys and bodies. Review access, limit provider spending, patch dependencies, and protect the proxy’s configuration and administrative access.
- Rate limits, payload limits, and trajectory monitoring are controls to configure or add; do not assume this integration implements them.
The channel between Prax and the proxy
Audited 2026-10-01 on the production VM by probing the listeners from another local account, with requests that could not reach a provider:
| Listener | Caller auth | Encryption | State |
|---|---|---|---|
:8785 reverse proxy (model calls) |
PROXY_AUTH_TOKEN required — 401 without it |
TLS; self-signed cert with SANs secrets-proxy, localhost, 127.0.0.1, trusted through the CA bundle; Prax never disables verification |
sound |
:8786 forward proxy (all other HTTPS) |
none — an unauthenticated request was forwarded | plain-HTTP proxy protocol on loopback; TLS inside each CONNECT tunnel to the proxy’s CA | open to every local process |
:8791 egress admin |
admin / taint tokens | loopback | not running (policy off) |
Loopback is not a user boundary: every account on the machine, the dev tree,
and every process Prax starts can use :8786 and have production’s keys
injected. That is the hole to close. In order:
- Authenticate every caller of the forward proxy with its own identity
(prax-secrets-proxy:
PROXY_FORWARD_AUTH_TOKENworks over HTTPS since its #5; per-program identities since #6) —prax-prod,prax-dev, and for childrenprax-tools. ThenHTTPS_PROXY=http://prax-prod:<token>@127.0.0.1:8786. - Keep that credential out of child processes before step 1:
CHILD_ENV_STRIP_PROXY_CREDENTIALS(on by default) hands every subprocess the URL without it, orCHILD_PROXY_URL’s own identity (prax/services/child_env.py). Verified with realgitthrough an authenticating mitmproxy: unprotected, the child used Prax’s credential; stripped, it got407; with its own identity, it worked asprax-tools. - Then give identities different rules: the egress policy and forward-map
rules can name callers (
callers), soprax-toolscan be limited to, say, GitHub, and one-instance credentials (the Discord bot token) toprax-prod.
What is acceptable as it is: the proxy credential crosses loopback in clear text
(in Proxy-Authorization), readable only by root, who can read the keys anyway;
request contents are TLS inside the tunnel.
Production notes
Use a production WSGI server, a nonempty reverse-proxy token, encrypted cross-host
transport, and restricted reachability. secrets_proxy/config.py treats an empty
PROXY_AUTH_TOKEN as open — any caller that can reach the port spends the
keys — so set it, and bind loopback or a private network anyway so that
reachability is a second control rather than the only one. Keep the proxy
outside Prax’s administrative and filesystem access, as its own container/user
with the keys in its secret store only. The proxy README
owns component setup; this page describes Prax’s integration and limits.
Discord through the forward proxy
The bot token used to stay in Prax: Discord’s REST API wants
Authorization: Bot <token>, and the gateway carries the token inside its
IDENTIFY and RESUME messages, not in a header. The forward proxy now handles
both (prax-secrets-proxy: header injection with a prefix, and a ws-json:d.token
rule that rewrites client→server WebSocket messages). discord.py sends those
messages as plain JSON text — the gateway connects with compress=0, and
zlib-stream compresses only the server’s messages.
One instance only. A token injected for every caller would turn any Prax
holding the placeholder — a dev instance — into the bot as well, and both would
answer every message. So the registry marks the credential exclusive: the
map is exported with it only when its callers are named, and the proxy refuses
to load an exclusive rule without them. Callers are per-program identities
(prax-secrets-proxy PROXY_FORWARD_CALLERS), so production needs its own proxy
token.
Rollout (production .env changes — the operator’s to make):
- Proxy: put the real
DISCORD_BOT_TOKENin the proxy’s.env; give production its own caller (python -m secrets_proxy.callers new prax-prod). - Map:
python -m prax.services.credential_registry --export-forward-map ../prax-secrets-proxy/forward-map.json --exclusive-callers DISCORD_BOT_TOKEN=prax-prod, then recreate the proxy. - Prax:
HTTPS_PROXY=http://prax-prod:<token>@127.0.0.1:8786,DISCORD_USE_PROXY=true, and replaceDISCORD_BOT_TOKENwith a placeholder. - Rotate the bot token in the Discord developer portal once it lives only in
the proxy — the old one has been in Prax’s
.env.
Verified 2026-10-01 with real discord.py 2.7.1 through the real mitmproxy
against a fake Discord: the production caller logged in (REST got Bot + the
token) and its IDENTIFY reached the gateway carrying the token although the
client sent a placeholder; a second caller got 401 and LoginFailure, never
reaching the gateway. Not yet run against Discord itself.
Tier 2 — general egress
The optional forward proxy is shipped as the companion repository’s opt-in
forward profile: a mitmproxy-based transparent forward proxy (HTTPS_PROXY →
:8786, a CA Prax trusts) that terminates TLS and injects credentials for
configured hosts from the registry-generated forward map. Coverage is partial;
see the dated support matrix
and verification ledger. Wiring and the lock-down
checklist are in deployment-topology.md.
The forward listener has its own caller credential, PROXY_FORWARD_AUTH_TOKEN
(secrets_proxy/mitm_addon.py answers 407 without it once it is set); the
reverse-proxy PROXY_AUTH_TOKEN has no effect on forward-mode access, and an
empty forward token leaves the listener open to any caller that can reach it.
Restrict it to loopback on a trusted host or an authenticated tunnel regardless.
Two honest limits remain: the addon injects for allow-listed hosts and passes
every other host through untouched — its map controls credential injection, not
allowed egress, so it is not yet the egress allowlist that would kill the
data-exfiltration leg from sandbox-execution-boundary.md;
and the sandbox container does not route through it. Both are tracked in the
adopt-tracker. Trusting its CA permits
interception of routed HTTPS traffic and does not authenticate the caller to the
proxy.
Wire record — checking Prax’s account against the model path
Idea credit: NVIDIA’s Open Agent Safety Platform. With the proxy’s
PROXY_WIRE_RECORD on, the forward proxy writes one hash-chained line per
model response: the tool calls the model asked for, as names and argument
hashes, never text, written outside Prax’s process
(proxy README).
scripts/check_wire_record.py WIRE.jsonl --caller <name> verifies the chain,
then lists every tool call on the wire that Prax’s own traces don’t show
within a few minutes — activity Prax didn’t account for. It is read-only.
- Give each Prax instance its own proxy username (
http://<name>:<token>@…) so--callercan separate dev from prod. - Arguments too. Each tool span records
requested_args_sha256— the hash of the model’s raw arguments, from its response, matched bytool_call_id, in the wire record’s canonical form — and never the arguments themselves. So beyond a hidden or dropped call, the check reports ARGS DIFFER (the trace misreports what the model asked for). Traces written before the hash existed are matched on name and time only. Every exact match (name and hash) is assigned before any hashless one, and both before anything is reported, so with parallel same-name calls a misreported call never takes the span of a sibling that matches exactly. - CHANGED BEFORE RUNNING comes from the span’s
args_changedflag. While a call runs, Prax keeps the model’s arguments in memory (never in the trace) and compares them with the inputs of every layer of the call — governance, context binding, the tool itself. A requested argument that is missing, or whose value differs, sets the flag. The comparison is loose on purpose, because LangChain validation changes ordinary calls: schema defaults are filled in,"5"becomes5, and governance dropsexpected_observation. None of that counts. The limits: a layer that adds an argument is not detected (it looks like a default), and a change that only converts a value’s type is not reported. A span’sargs_sha256hashes what the innermost layer received after validation. It is not comparable with the requested hash, because the two differ on ordinary calls. - The chain is tamper-evident, not tamper-proof: keep the record writable only by the proxy and copy its head hash off the machine.