GitHub - yagop/sandbox: ๐Ÿš A forward proxy that injects credentials to Docker sandboxes

9 min read Original article โ†—

A zero-dependency, stdlib-only Go forward proxy that injects your real credentials (GitHub GH_TOKEN, npm token, โ€ฆ) into outbound requests on the wire, so code running in a sandbox can use them without ever seeing them.

A simplified version of Infisical's agent-vault, driven by a single script (sandbox.sh) over a dependency-free proxy binary you own.

๐Ÿ”’ What it does

Code runs in a container whose HTTP(S) traffic goes through the proxy. The proxy holds your tokens and injects them into outbound requests as they leave โ€” so the workload can use them but never sees them. Other traffic (raw TCP, ssh, ...) egresses directly by default; set SANDBOX_OPEN_NET=0 to make the proxy the only route out.

sandbox  (git / gh / npm โ€” no token; HTTPS_PROXY, trusts proxy CA)
   โ”‚
   โ”‚  request without credentials
   โ–ผ
sandbox-proxy  (holds the real token; default-deny allow-list)
   โ”‚
   โ”‚  + Authorization injected on the wire
   โ–ผ
upstream  (github.com, api.github.com, registry.npmjs.org, โ€ฆ)
  • ๐ŸŒ Open by default (allow_all: true) for easy setup โ€” egress to any host, with credentials injected only on your configured hosts. Set allow_all: false for strict default-deny (only listed hosts reachable).
  • ๐Ÿ” HTTPS interception via a CA it generates on first run and the sandbox trusts; the intercepted TLS speaks HTTP/1.1 only (ALPN pins http/1.1), and hosts you don't inject into can be blind-tunnelled untouched.
  • ๐Ÿ›ก๏ธ A compromised workload can at most use a token against the hosts you allow โ€” it can't read or exfiltrate the secret itself.

๐Ÿš€ Usage: the sandbox command

Source the control script once; it gives you a sandbox function that manages one shared proxy and any number of sandbox containers. The function re-sources sandbox.sh on every call, so edits to it take effect immediately โ€” no need to source again (new default values of SANDBOX_* vars are the exception: those stick per shell once set).

source sandbox.sh

# GH_TOKEN is taken from `gh auth token` automatically if it isn't already set.
export NPM_TOKEN=npm_xxx           # optional; export any secret your rules need

sandbox proxy up                   # build (if needed) + start the shared proxy
sandbox proxy status               # is it running? on which networks?

# in any project directory:
cd ~/code/my-app
sandbox run                        # ensures proxy is up, opens a shell in $PWD
# ...or run a command directly:
sandbox run npm ci
sandbox run git clone https://github.com/you/private.git

Inside a sandbox there is no token in the environment, yet git/npm are authenticated โ€” the proxy injects credentials on the way out. Run as many sandboxes as you like at once; they all share the single proxy:

(cd ~/code/app-a && sandbox run npm test) &
(cd ~/code/app-b && sandbox run npm test) &
sandbox ps                         # list running sandboxes

Commands:

Command Does
sandbox proxy up Build if needed, start the shared proxy.
sandbox proxy status Show whether it's running and its networks.
sandbox proxy reload Restart the proxy, picking up current env/tokens and config edits.
sandbox proxy down / logs Stop+remove / follow logs.
sandbox run [cmd...] Ensure the proxy is up, run a sandbox in $PWD (shell if no cmd).
sandbox build [proxy|box|all] Force-rebuild images.
sandbox ps List running sandboxes.

๐Ÿ”‘ Where secrets come from: for each var in $SANDBOX_SECRET_ENVS, sandbox uses the environment value if set, otherwise runs SANDBOX_<VAR>_CMD on the host. The default set:

GH_TOKEN                    # defaults to `gh auth token` โ€” being logged into gh is enough
NPM_TOKEN
SCW_SECRET_KEY
FLY_API_TOKEN               # defaults to `fly auth token`
SCW_COCKPIT_TOKEN_<REGION>  # one per Cockpit region (FR_PAR, NL_AMS, PL_WAW) โ€” tokens are region-scoped

Resolution happens in a subshell at proxy up/reload, so tokens never persist in your shell, and they're passed to the container by name (never on the command line). To pull another secret from a command, e.g.:

export SANDBOX_SECRET_ENVS="GH_TOKEN NPM_TOKEN AWS_TOKEN"
export SANDBOX_AWS_TOKEN_CMD="aws-vault exec me -- printenv AWS_SESSION_TOKEN"

๐Ÿ’พ Persisting tool configs / caches: set SANDBOX_VOLUMES to a whitespace-separated list of docker -v specs and every sandbox run mounts them (named volumes are created on first use and survive across sandboxes):

export SANDBOX_VOLUMES="claude-config:/root/.claude codex-config:/root/.codex pi-config:/root/.pi"

Each entry is a raw -v spec, so host paths ($HOME/.foo:/root/.foo) and read-only mounts (somevol:/root/.x:ro) work too. Note these are shared across all sandboxes, so treat anything mounted there as readable by any workload.

๐Ÿ”ง Env vars in sandboxes: one owner per concern โ€” static env (proxy vars, locale, placeholder tokens like GH_TOKEN=dummy) is image ENV in sandbox/Dockerfile; per-run extras go in SANDBOX_ENVS, a whitespace-separated list of docker -e specs (NAME=value, or bare NAME to forward from the host) โ€” never real secrets, those belong in the proxy. Shell aliases and interactive setup live in sandbox/zshrc.sandbox (baked into the image; sandbox build box after edits).

โ™ป๏ธ Changing tokens or rules: edit proxy/config.json and/or update the token source, then sandbox proxy reload โ€” it re-resolves secrets (picking up a rotated gh token) and restarts. The CA is stored in a persistent Docker volume, so it survives reloads and sandboxes keep trusting it.

The sandbox network is created --internal. By default (SANDBOX_OPEN_NET=1) each sandbox is also attached to the egress network, so traffic that doesn't use the proxy (raw TCP, ssh, database connections, ...) reaches the internet directly, while HTTP(S) keeps going through the proxy for credential injection. Export SANDBOX_OPEN_NET=0 before sandbox run for strict isolation, where the proxy is physically the only route out. Confirm:

sandbox run sh -c 'env | grep -i token'    # -> nothing (no token inside the sandbox)
sandbox run curl -sI https://example.com   # reachable via the proxy (allow_all default);
                                           # with allow_all:false an unlisted host -> 403

Override defaults (network/image/volume names, which env vars are forwarded as secrets) by exporting SANDBOX_* vars before sourcing โ€” see the top of sandbox.sh.

๐Ÿ“ฆ Installing packages at runtime: the image sets both upper- and lower-case proxy vars, so apt, curl, wget, go, npm, bun all route through the proxy โ€” apt update && apt install <pkg> works (needs allow_all, or the Ubuntu archive hosts allow-listed). These installs live in the --rm container and vanish on exit; for anything you want every time, add it to sandbox/Dockerfile and sandbox build box. Your host TERM/COLORTERM are forwarded too, so full-color TUIs work.

๐Ÿ”Œ Reaching raw-TCP / LAN services: the proxy only speaks HTTP/HTTPS, but its CONNECT/blind-tunnel is a generic TCP splice โ€” so any TCP service is reachable via the bundled forward (alias tunnel) helper, which exposes it on 127.0.0.1 through the proxy:

forward 192.168.1.39 5432          # 127.0.0.1:5432 -> 192.168.1.39:5432
forward 192.168.1.39 5432 6379 &   # several ports, backgrounded

Point your client at 127.0.0.1:<port>. Keep allow_all on and don't add a rule for the host โ€” a rule would MITM it and break the raw stream. The proxy must be able to reach the target on the LAN.

โš™๏ธ Configuration

proxy/config.json maps secrets (how to build an auth header, with the value read from the proxy's environment) to rules (which host gets which secret):

{
  "allow_all": true,
  "secrets": {
    "github":     { "type": "basic",  "env": "GH_TOKEN", "username": "x-access-token" },
    "github-api": { "type": "bearer", "env": "GH_TOKEN" },
    "npm":        { "type": "bearer", "env": "NPM_TOKEN" },
    "scaleway":   { "type": "header", "env": "SCW_SECRET_KEY", "header": "X-Auth-Token" }
  },
  "rules": [
    { "host": "github.com",              "inject": "github" },
    { "host": "api.github.com",          "inject": "github-api" },
    { "host": "*.githubusercontent.com" },
    { "host": "registry.npmjs.org",      "inject": "npm" },
    { "host": "api.scaleway.com",        "inject": "scaleway" }
  ]
}
  • secrets โ€” type is bearer, basic, or header; env names the host env var holding the token (never the value itself). username is for basic auth (GitHub uses the token as the password with any username). header sets the token in a custom header named by header โ€” e.g. Scaleway's X-Auth-Token.
  • rules โ€” matched by host, covering all methods and paths. A *. prefix is a suffix wildcard: *.githubusercontent.com matches objects. / raw. / any subdomain (and the bare domain) โ€” handy for CDN hosts behind gh-release/git-lfs/npm-tarball downloads. inject names a secret to add on every request; omit it to allow a host with no credential added.
  • allow_all โ€” true by default for simplicity: egress to any host, with injection still scoped to listed hosts (others are blind-tunnelled, untouched). Set it to false for strict default-deny โ€” only listed hosts are reachable. โš ๏ธ With allow_all on, the proxy is a credential broker, not a firewall: a compromised workload can send data anywhere. Flip it to false for untrusted code.

Run sandbox proxy reload to re-read the config.

๐Ÿ“ฆ Run without Docker (portable binary)

The proxy is just a binary โ€” no runtime deps.

cd proxy/src
go build -o sandbox-proxy .                 # or: GOOS=linux GOARCH=amd64 go build ...

GH_TOKEN=ghp_xxx NPM_TOKEN=npm_xxx ./sandbox-proxy   # listens on :3128, writes ca/ca.crt

# point any client at it:
export HTTPS_PROXY=http://127.0.0.1:3128
curl --cacert ca/ca.crt https://api.github.com/user   # authenticated, token never left the proxy
git -c http.proxy=$HTTPS_PROXY clone https://github.com/your/private.git

Cross-compile for any target with GOOS/GOARCH โ€” the output is a single static file you can drop anywhere.

๐Ÿงช Tests

cd proxy/src && go test ./...

Covers the policy engine (host matching, decide, credential injection incl. placeholder overwrite), config loading, header/host helpers, WebSocket-upgrade detection, and end-to-end forward-proxy and CONNECT/MITM flows (injection, default-deny, allow_all) via httptest. _test.go files are excluded from the built binary.

๐Ÿ”ง Environment variables

Var Default Meaning
PROXY_LISTEN :3128 listen address
PROXY_CONFIG config.json rules file
PROXY_CA_DIR ca where ca.crt / ca.key are stored/generated
HTTP(S)_PROXY โ€” upstream proxy for the proxy's own egress (optional)

๐Ÿ›ก๏ธ Security notes

  • ca.key stays in the proxy. It lives (mode 0600) in the proxy-only CA volume; sandboxes bind-mount only the public ca.crt read-only, never the volume โ€” so a workload can't read the key and mint trusted certs. Keep the CA volume private on the host.
  • Lock down the network, not just the env. The --internal sandbox network (sandbox-net) is what actually forces traffic through the proxy; without it a workload could ignore HTTPS_PROXY and dial out directly. Note the default SANDBOX_OPEN_NET=1 deliberately relaxes this by also attaching sandboxes to the egress network โ€” convenient for raw TCP, but a workload can then bypass the proxy entirely. Export SANDBOX_OPEN_NET=0 when you want enforced proxy-only egress.
  • Scope tightly. Prefer specific injection hosts over broad allow_all so a leaked-but-injected token is useful only for what you intended.