A small egress proxy that lets a sandboxed program use secrets it never holds.
The program gets a placeholder like sp_github_k7f3m9q2x8w4v1n6b0c5z2jd
instead of a real token. The proxy sits between the sandbox and the
internet. When the program sends the placeholder to the right host, in the
right header, with an allowed method and path, the proxy swaps in the real
value. In every other case the request is refused with a message that says
why.
sandbox secproxy internet
GITHUB_TOKEN=sp_github_… holds the real token
curl api.github.com ──────────▶ not listed? ─▶ refused
on the egress list? ─▶ raw TLS passed through, untouched
has a secret bound? ─▶ decrypt with our own CA
method + path allowed?
Authorization == "Bearer sp_github_…"?
swap, forward over fresh verified TLS ─▶ api.github.com
scrub the real value out of the response
one JSON line to the audit log
It is one static binary, a YAML file, and a certificate. Secrets come from environment variables or files. There is no vault, no server, no account, no UI, and nothing specific to any cloud or sandbox product.
It is not a sandbox. The proxy only helps if the sandboxed program has no
other route to the internet. Enforcing that is the job of whatever runs the
sandbox: a firewall rule, a network namespace, a cloud sandbox's egress
allowlist, or a relay that captures all port-443 traffic. Setting
HTTPS_PROXY in the sandbox is a courtesy to well-behaved programs, not a
boundary.
It also does not stop the sandbox from using a secret it is allowed to
use. If the GitHub token can delete repositories and the binding allows
DELETE, the sandbox can delete repositories without ever seeing the token.
Method and path rules on bindings are how you narrow that.
The proxy keeps a hostile sandbox from reading a secret. It assumes the upstream is the real service and is not trying to hand the secret back. Response scrubbing catches the literal value in plaintext headers and bodies (gzip is decompressed first; other encodings are refused) and in WebSocket frames. It cannot catch a value the server transforms before echoing: base64, URL-encoding, a hash, a derived session token, a cookie. Bind secrets to the hosts that own them, keep method and path rules tight, and treat the scrubber as a safety net for careless servers, not as a guarantee.
- Passthrough is name-level, not tenant-level. For an egress host the
proxy checks the hostname (from SNI, and on the CONNECT listener the SNI
must also match the CONNECT line) and then copies encrypted bytes. It
cannot see the HTTP
Hostheader inside. On a shared CDN or reverse proxy, a connection that presents an allowed name could carry requests the CDN routes to another tenant. If that matters for a host, do not put it onegress; bind a secret to it so it is inspected, where theHostheader is checked on every request. secproxy runis not a sandbox. The child runs as the same user as the proxy, so it can read any file the proxy can:from_filesecrets, apersist_dirCA key, your shell history.runhides the proxy's environment variables from the child as a courtesy, nothing more. Real isolation means the proxy and its files live outside the sandbox's filesystem and network view, which is how the sidecar deployments work.- A hostile upstream wins. As above: the scrubber catches literal echoes, not transformations. A split-across-frames WebSocket value or a value straddling a chunk boundary in a very large frame is not caught.
- Denial of service by the sandbox is not prevented. Connection counts are capped, but a sandbox can hold its own proxy's connections open. The proxy is meant to be per sandbox, so the only victim is the attacker.
- Transparent (
listen.transparent: ":443"). Accepts raw TLS. The destination is read from the SNI in the ClientHello. Use this behind a network relay that delivers all of the sandbox's HTTPS traffic to the proxy; the program in the sandbox does not need to know a proxy exists. - Connect (
listen.connect: ":8080"). An ordinary HTTP proxy for programs that honourHTTPS_PROXY.CONNECT host:443is brokered; plainhttp://requests are forwarded only for egress hosts.
Both lead to the same decision per host: refuse, pass through, or inspect.
See examples/secproxy.yaml. The short version:
version: 1
listen: { transparent: ":443", connect: ":8080" }
ca: { cert_out: /run/secproxy/ca.pem }
unmatched_host: deny
egress: [pypi.org, files.pythonhosted.org, registry.npmjs.org]
secrets:
github: { from_env: SP_GITHUB_TOKEN, env: GITHUB_TOKEN }
bindings:
- secret: github
hosts: [api.github.com]
slot: { header: Authorization, format: "Bearer {secret}" }
methods: [GET, POST, PATCH]
paths: ["/repos/my-org/**"]Rules that keep the model honest:
- A host is either an egress host or a binding host, never both.
- Substitution happens only when the placeholder is the entire value in the declared slot. A placeholder anywhere else in a request to an inspected host is a 403 with an explanation.
- A binding can opt out of needing the placeholder with
inject: always: every request to that host gets the secret, whether or not the program sent the placeholder. If the slot already holds some other value the request is refused rather than quietly rewritten. This is for programs that cannot be handed a placeholder (they read credentials from their own config, or check the token's shape before sending). With it the sandbox need not hold the placeholder at all, soenvcan be left off the secret. - Unknown hosts are refused unless
unmatched_host: passthrough. Unmatched hosts are never inspected. - Missing secret at startup means the proxy does not start.
- Config typos are errors, not silently ignored keys.
go install github.com/taylorai/secproxy/cmd/secproxy@latest
For a Linux container from a Mac:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go install github.com/taylorai/secproxy/cmd/secproxy@latest
# binary lands in ~/go/bin/linux_amd64/secproxy
make linux does the same from a checkout into dist/. The Dockerfile
builds a distroless image with just the binary.
ca:
cert_out: /run/secproxy/ca.pem # public cert, written at startup; optional
persist_dir: ~/.config/secproxy/ca # laptop: keep one CA across restarts; optional
key_env: SECPROXY_CA_KEY # orchestrator-supplied PEM; optional, with cert_env
cert_env: SECPROXY_CA_CERTBy default the proxy mints a new root in memory every start and the
private key never touches disk. That is right for a sidecar that lives and
dies with one run. On a laptop it means re-trusting the certificate after
every restart, so set persist_dir: the first start writes ca.pem and
ca.key (mode 600) there, valid for a year, and later starts reuse them.
It also keeps an empty ca.lock file for process coordination; leave that
file in place. Persistent mode uses kernel file locks and is supported on
macOS, Linux, the BSDs, and illumos; elsewhere, use an ephemeral or
orchestrator-supplied CA. Trust ca.pem once. On macOS, Go programs such as
gh ignore SSL_CERT_FILE and consult the keychain, so:
security add-trusted-cert -r trustRoot -k ~/Library/Keychains/login.keychain-db ~/.config/secproxy/ca/ca.pem
key_env/cert_env is the third option, for an orchestrator that wants to
hand the certificate to the sandbox before the proxy starts. Set only one
of persist_dir and key_env.
secproxy check --config cfg.yaml validate and print the effective policy
secproxy serve --config cfg.yaml run
secproxy env --config cfg.yaml print the env the sandbox should get
secproxy run --config cfg.yaml -- cmd … laptop mode: proxy on loopback, run cmd with that env
env prints the placeholders under their configured names, the
HTTPS_PROXY family (when --proxy-url is given), and the certificate
variables (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE,
CURL_CA_BUNDLE, GIT_SSL_CAINFO, DENO_CERT) pointing at --ca-path.
Install the certificate into the sandbox's system trust store as well;
some programs read none of those variables.
One JSON line per decision on stdout. Never header values, query strings,
bodies, placeholders, or secrets. bindings lists every binding that
substituted a secret; automatic is the subset that did so without a
placeholder because of inject: always.
{"ts":"…","run_id":"r1","decision":"inject","host":"api.github.com","method":"GET","path":"/user","bindings":["github#0"],"status":200,"ms":143}
{"ts":"…","run_id":"r1","decision":"inject","host":"api.github.com","method":"GET","path":"/user","bindings":["github#0"],"automatic":["github#0"],"status":200,"ms":97}
{"ts":"…","run_id":"r1","decision":"deny","host":"api.github.com","method":"DELETE","path":"/repos/x/y","reason":"method_not_allowed"}
{"ts":"…","run_id":"r1","decision":"deny","host":"evil.example","reason":"unmatched_host"}You only need to know two things going in. First, HTTPS is HTTP wrapped in TLS: the request is encrypted between the program and the server, so anything sitting in between sees only noise. Second, the only way for something in between to read or change an HTTPS request is to be the server as far as the program is concerned: present a certificate the program trusts, decrypt, do its work, then open its own, separate HTTPS connection to the real server. That is what "inspecting" a host means below. Everything else follows from those two facts.
Here is what happens to one request, and which piece does each step.
It has GITHUB_TOKEN=sp_github_k7f3… in its environment (a placeholder,
not a real token) and runs, say, gh api /user. That tool opens a TLS
connection to api.github.com. The traffic has to end up at the proxy; how
that happens is the job of whoever set up the sandbox, and there are two
ways.
childenv (internal/childenv) is the list of environment variables a
well-behaved program reads to find a proxy and to trust a certificate:
HTTPS_PROXY, SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, and a handful more,
because every language runtime picked its own name. secproxy env prints
them. This is the cooperative route; a program can ignore these variables,
so it is not a security boundary, only a convenience.
The other route is a network rule outside the sandbox that grabs all port-443 traffic and delivers it to the proxy whether the program likes it or not. That is the real boundary, and it is the sandbox platform's job, not this tool's.
Depending on which route the traffic took, it arrives in one of two shapes.
The connect listener is what a proxy-aware program talks to. It sends
a line like CONNECT api.github.com:443 first, in plain text, asking the
proxy to open a tunnel. So the proxy knows the destination before any TLS
happens. It answers "200 OK" and from then on the program sends TLS
through the tunnel.
The transparent listener is what the network-rule route delivers. Here the proxy receives raw TLS bytes with no introduction. It still needs to know where the program was going, so it uses SNI peek. SNI (Server Name Indication) is a field in the very first TLS message, the ClientHello, where the program says "I want to talk to api.github.com" in plain text so that shared servers know which certificate to present. The peek reads just that first message, pulls out the name, and then rewinds so the bytes can be replayed to whatever comes next as if nothing had been read. Go's own TLS library does the parsing; we just stop it after the first message.
Both doors end at the same place: a hostname and a decision.
policy (internal/policy) answers: given this hostname, what now?
It reads the compiled config. Three answers:
- Deny. The host is not listed anywhere. Connection closed. On the connect listener the program gets a readable 403 first.
- Passthrough. The host is on the
egresslist (pypi.org, the npm registry, and so on). The proxy dials the real host and copies bytes in both directions without decrypting anything. This is the passthrough splice: two pipes, one each way, and when either side hangs up both are closed. No secret can be involved because the proxy never sees the plaintext, and the program sees the real server's own certificate, so nothing in the sandbox needs to trust ours for these hosts. - Inspect. The host has a secret bound to it. Now the proxy has to be the server.
To decrypt, the proxy needs a certificate for api.github.com that the program will accept. Real certificates come from public authorities we don't control, so the proxy runs its own tiny authority.
ca (internal/ca) generates a root certificate and private key in
memory when the proxy starts. The public half is written to a file so the
orchestrator can hand it to the sandbox to trust; the private half is never
written anywhere. When a request for api.github.com arrives, the CA mints a
certificate for that exact name, signed by the root, and caches it so the
second request is instant. Certificates expire in a day and can never
outlive the root, which itself expires in a week; the whole thing is
disposable per run. The exception is ca.persist_dir, for a proxy on your
own machine: there the root is kept on disk for a year so you trust it
once rather than after every restart.
The one-shot TLS listener is a Go detail that needs a sentence. Go's HTTP server is built to accept many connections from a socket. Here we have exactly one already-open connection that we just decrypted, and we want the standard HTTP server to parse requests on it. So we wrap that one connection in something that looks like a socket, hands over the connection once, and then never produces another. It lets us reuse Go's well-tested HTTP parsing instead of writing our own.
Now the proxy has a decrypted HTTP request and the request forwarding
code (internal/proxy/forward.go) runs the checks in a fixed order:
- Build the audit log line now, from the request as sent, before any secret is added. That way the log structurally cannot contain one.
- Refuse
TRACE, an HTTP method whose entire purpose is to echo the request headers back, which would echo the injected secret. - Check that the request's own
Hostheader agrees with where the connection was opened. Without this a program could open a tunnel to api.github.com, get the GitHub token injected, and address the request to some other site hosted on the same server. - Hand the request to policy.Apply. It looks at each binding for this
host. If the placeholder is the entire value in the declared slot (say,
Authorization: Bearer sp_github_k7f3…) and the method and path are allowed, it swaps in the real token. A binding markedinject: alwaysalso fires when the slot is empty, and refuses the request if the slot holds something else, so it never overwrites a credential the program chose to send. Then it scans the whole request for any placeholder that survived. Finding one means the program put it somewhere it isn't allowed, and the request is refused with a message naming the secret, where it was found, and where it belongs. Coding agents read error messages, so this turns a policy violation into something the agent can fix. - Open a fresh HTTPS connection to the real api.github.com, verifying its
certificate normally, and send the rewritten request. Headers that only
make sense between a program and its proxy are dropped.
Accept-Encodingis dropped too, so the server replies uncompressed, for the next step. - Stream the response back, through the scrubber.
WebSocket relay is a variant of the same. A WebSocket starts life as
an ordinary HTTP request with an Upgrade: websocket header; the server
answers "101 Switching Protocols" and from then on it is a two-way stream
of frames. Some tools use this to talk to model APIs. The proxy runs the
handshake through the same checks, so a placeholder in the handshake's
Authorization header is swapped, then on the 101 it splices the two
connections like passthrough. Frames after that are not inspected.
Plain-HTTP forwarding is the last shape: a program asking the connect
listener for an http:// (not https://) URL. Only egress hosts are
allowed, and a host that carries a secret is refused with "use https", so
a credential never crosses the network unencrypted.
Every outgoing connection the proxy makes goes through netguard
(internal/netguard). It answers a different question from the policy:
not "is this hostname allowed" but "is the address behind this hostname
somewhere the proxy should ever connect to".
The threat. The proxy sits in a better network position than the sandbox. It can usually reach the machine it runs on, the private network around it, and on any cloud VM the metadata service at 169.254.169.254, which hands the instance's own cloud credentials to whoever asks. The sandbox cannot reach any of that directly; its only route out is the proxy. So if the sandbox can make the proxy connect to an address of its choosing, the proxy becomes a tunnel into the internal network. This is server-side request forgery, and it is the standard way proxies get turned against their owners.
Why it does not need a compromised resolver. The sandbox never names
an address, only a hostname, but anyone can register a domain and point
it at 127.0.0.1 or 169.254.169.254. Public DNS serves such records
without complaint; services like nip.io exist to do exactly that on
demand. Your own honest resolver faithfully returns the attacker's
answer. All it takes is a way to get an untrusted name past the policy:
unmatched_host: passthrough, a wildcard egress entry on a domain where
strangers can create subdomains, or a trusted host whose DNS is later
changed.
The defence. Netguard is a dialer with a list of forbidden address ranges: loopback, link-local, multicast, the RFC 1918 private ranges and their IPv6 equivalents, the cloud metadata addresses, and reserved or documentation space that nothing legitimate lives in. It resolves the hostname, checks every answer, refuses if any one is forbidden, and then connects to the exact address it checked rather than resolving again. That last step matters: a zone with a very short TTL can answer a public address for the check and a private one for the connection, and using one answer for both closes that gap. Addresses that wrap an IPv4 address inside IPv6 (NAT64, 6to4) are unwrapped and judged by what they would reach.
With a strict egress list of hosts you trust, netguard rarely fires. It
is there for the day someone widens the list on a cloud machine, so the
result is a refused connection and an audit line rather than stolen
instance credentials. --allow-private-upstreams opens the private
ranges for deliberately reaching an internal service; loopback and
metadata stay blocked regardless.
Servers sometimes echo a credential back: in an error message, in a redirect, in a debug page. If that happened the sandbox would see the real token. So every byte of the response, headers and body, passes through a filter that replaces the real value with the placeholder. The tricky bit is that the body arrives in chunks and a token could be split across two of them, so the filter holds back only as many trailing bytes as could be the start of a match, and lets everything else through immediately. That keeps streamed responses (an LLM writing token by token) arriving as they are produced.
audit (internal/audit) writes one JSON line per decision: time,
host, method, path, what was decided and why, which binding was used,
status, bytes, milliseconds. Never a header value, a query string, a body,
a placeholder, or a secret. It goes to standard output so whatever runs
the proxy can capture it.
secproxy checkloads the config, resolves every secret from the environment or files, refuses if any is missing (a proxy that starts without a secret it was told about would forward placeholders to real servers, which is a confusing failure), and prints the policy it would enforce, in one line per host.secproxy servedoes the same and then opens the listeners.secproxy envprints the environment variables for the sandbox.secproxy run -- some-commandis for a laptop: it starts the connect listener on the local machine, writes the CA certificate to a temporary file, runs the command with all the right variables set, and exits with the command's exit code. Cooperative only, since nothing stops the command from ignoring the proxy; useful for trying things out.
cmd/secproxy CLI
internal/config config file: types, validation, secret resolution
internal/policy per-host decision, per-request substitution and rejection
internal/ca in-memory certificate authority, per-host leaf certs
internal/proxy listeners, TLS termination, forwarding, passthrough, scrubbing
internal/netguard upstream dialer that refuses private and metadata addresses
internal/audit JSON log lines
internal/childenv the env vars a sandboxed program needs
docs/research notes on the projects this borrows from
Apache License 2.0. See LICENSE.
Designs, and in a few places small pieces of code, come from Infisical's
CLI agent proxy and Agent Vault (MIT), inflightsec's agent-vault-proxy
(Apache-2.0), and stashbase's agent-proxy (MIT). Each borrowed piece is
credited in a comment where it lives and listed in NOTICE. Apoxy CLRK
(AGPL) was read for reference only; nothing from it was used. Research
notes are in docs/research/.