HTTP ACL and Credential Injection
Allowing host:443 lets a workload send anything to that host. HTTP rules narrow it to a method and a path, and credential injection means the workload can use an API key it never holds.
Rules
An HTTP rule is a method, a host, and a path with glob matching:
"METHOD host/path"
"GET docs.python.org/*"
"POST api.openai.com/v1/chat/completions"
"* */admin/*"
http_deny rules are checked before http_allow rules. When either list is non-empty, the supervisor spawns a transparent proxy and redirects the configured ports to it.
$ sandlock run \
--http-allow "GET docs.python.org/*" \
--http-allow "POST api.openai.com/v1/chat/completions" \
--http-deny "* */admin/*" \
-r /usr -r /lib -r /etc -- python3 agent.py
Ports open themselves
You do not have to remember to add matching network rules. A rule naming a concrete host auto-extends net_allow with that host on each entry of http_ports, plus 443 when HTTPS interception is enabled, so the proxy's intercept ports are reachable. Wildcard hosts add the equivalent any-IP entries. Every auto-added entry is TCP.
http_ports defaults to [80], and 443 is added automatically when a CA is configured.
HTTPS interception
Rules on paths require reading the request, which over TLS requires terminating it. Sandlock offers three postures.
No interception (the default)
Without --http-ca or --http-inject-ca, port 443 is not intercepted at all. --net-allow host:443 permits raw TLS to that host with no content inspection, which is the right choice when transport-level control is enough.
Zero-config: an ephemeral CA
--http-inject-ca is the option most workloads want. Sandlock generates a CA whose private key exists only in memory and is never written to disk, and splices its public certificate into each trust bundle you name, at open time. There is no openssl invocation and no manual installation step.
$ sandlock run \
--http-allow "POST api.openai.com/v1/*" \
--http-inject-ca /etc/ssl/certs/ca-certificates.crt \
-r /usr -r /lib -r /etc -- python3 agent.py
File injection reaches anything that reads a trust file from disk: curl, git, the OpenSSL CLI, Go, Python's stdlib ssl, and Python's requests and httpx through certifi's cacert.pem if you name that path.
Runtimes with a compiled-in trust store
Node and Java carry their CA list in the binary, so file injection cannot reach them. Export the public certificate with --http-ca-out and point the runtime's own environment variable at it.
$ sandlock run \
--http-allow "POST api.example.com/*" \
--http-inject-ca /etc/ssl/certs/ca-certificates.crt \
--http-ca-out /tmp/sandlock-ca.pem \
--env NODE_EXTRA_CA_CERTS=/tmp/sandlock-ca.pem \
-r /usr -r /lib -r /etc -- node agent.js
--http-ca-out writes only the public certificate. The private key is never written, under any option.
Bring your own CA
$ sandlock run \
--http-allow "POST api.openai.com/v1/*" \
--http-ca ca.pem --http-key ca-key.pem \
-r /usr -r /lib -r /etc -- python3 agent.py
With --http-ca, the CA must be one you generated and installed into the sandbox's trust store yourself, typically under /etc/ssl/certs/. --http-key is required whenever --http-ca is set.
Credential injection
The problem with giving an agent an API key is that the agent then has an API key. Credential injection removes the key from the workload entirely: the secret is loaded into the supervisor and attached to a matching request inside the proxy, strictly after the ACL check has passed. A request the policy rejects never gets the credential.
Loading the secret
--credential NAME=SOURCE loads it. Three sources are supported:
| Source | Behaviour |
|---|---|
env:VAR | Read from the environment. The variable is stripped from the child, so the workload cannot read it back. |
file:PATH | The whole file is the secret; one trailing newline is stripped. |
fd:N | Read from a file descriptor. The secret never touches disk. |
Attaching it
--http-auth "METHOD HOST/PATH AUTHSPEC NAME [replace|add-only]" attaches a loaded credential to matching requests. AUTHSPEC is one of bearer, basic:<user>, header:<name>, apikey:<name> (an alias of header:), or query:<param>.
The default mode, replace, overwrites an existing credential of that shape, which is what you want for SDKs that always send a placeholder auth header. add-only leaves a caller-supplied value in place.
$ sandlock run \
--http-allow "POST api.openai.com/v1/*" \
--http-inject-ca /etc/ssl/certs/ca-certificates.crt \
--credential openai=env:OPENAI_API_KEY \
--http-auth "POST api.openai.com/v1/* bearer openai" \
-r /usr -r /lib -r /etc -- python3 agent.py
More shapes
# Custom header from a mounted secret file (Anthropic style)
--credential anthropic=file:/run/secrets/anthropic-key \
--http-auth "* api.anthropic.com/* header:x-api-key anthropic"
# HTTP Basic with a fixed user-id. A ':' in the user-id is rejected
# per RFC 7617: it would shift the user:pass boundary and leak part
# of the secret.
--credential registry=env:REGISTRY_PASSWORD \
--http-auth "* registry.internal/* basic:deploy registry"
# Query parameter: the least private shape. The value lands in the
# upstream's access logs and any Referer. Use only where required.
--credential maps=file:/run/secrets/maps-key \
--http-auth "GET maps.example.com/* query:key maps"
# One credential backing several hosts. `add-only` makes the second
# rule a fallback: a value the agent set itself is kept.
--credential shared=env:SHARED_TOKEN \
--http-auth "* a.example.com/* bearer shared" \
--http-auth "* b.example.com/* header:x-token shared add-only"
Requirements and warnings
- Injection requires an HTTP ACL proxy, meaning at least one
http_alloworhttp_denyrule. - Injecting into an HTTPS host additionally requires
--http-caor--http-inject-ca, so the proxy can intercept port 443. - Over cleartext HTTP the secret is sent to the upstream in plaintext. Sandlock emits a one-per-run warning rather than treating the transport as safe.
--credentialand--http-authare CLI and builder flags. They are not[config]profile keys.
Working with a secret manager
Sandlock ships no secret-manager client, on purpose. An external fetcher materializes the value into a file: or fd: source, which keeps it off ps, out of shell history, and out of the child's environment, and lets the same mechanism work with Vault, a cloud secret store, or a CSI driver.
# fd: via process substitution. The fetcher writes to fd 3, sandlock
# reads it through a dup, and the child never sees it.
$ sandlock run \
--http-allow "POST api.internal/*" \
--http-inject-ca /etc/ssl/certs/ca-certificates.crt \
--credential api=fd:3 \
--http-auth "POST api.internal/* bearer api" \
3< <(vault read -field=token secret/data/api) \
-r /usr -r /lib -r /etc -- python3 agent.py
# file: from a Vault Agent sidecar or CSI driver rendering onto tmpfs
$ sandlock run \
--http-allow "* api.internal/*" \
--http-inject-ca /etc/ssl/certs/ca-certificates.crt \
--credential api=file:/vault/secrets/api-key \
--http-auth "* api.internal/* bearer api" \
-r /usr -r /lib -r /etc -- python3 agent.py
Leased and rotating secrets are out of scope. The secret is loaded once at supervisor start, so a rotated value is picked up only on the next sandlock run. If your credential has a short TTL, restart the sandbox on rotation rather than expecting Sandlock to notice.
From the SDKs
from sandlock import Sandbox
agent = Sandbox(
fs_readable=["/usr", "/lib", "/etc"],
http_allow=["POST api.openai.com/v1/chat/completions"],
http_deny=["* */admin/*"],
)
result = agent.run(["python3", "agent.py"])
let mut agent = Sandbox::builder()
.fs_read("/usr").fs_read("/lib").fs_read("/etc")
.http_allow("POST api.openai.com/v1/chat/completions")
.http_deny("* */admin/*")
.name("agent-box")
.build()?;
let result = agent.run(&["python3", "agent.py"]).await?;
Policy fields
| Python | TOML | Default | Description |
|---|---|---|---|
http_allow | [http].allow | () | Allow rules, "METHOD host/path" with glob path matching |
http_deny | [http].deny | () | Deny rules, checked first. Same format. |
http_ports | [http].ports | [80] | TCP ports to intercept; 443 added when a CA is set |
http_ca | [config].http_ca | None | PEM CA certificate for HTTPS interception |
http_key | [config].http_key | None | PEM CA private key; required with http_ca |
http_inject_ca | [config].http_inject_ca | [] | Trust bundles to splice the active CA's public cert into |
http_ca_out | [config].http_ca_out | None | Write the active CA's public certificate here |