You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Let a sandboxed process authenticate to upstream APIs without the credential ever entering the sandbox's address space, environment, or filesystem. The real secret lives only in the supervisor; the child sees at most a per-session phantom token that authenticates nothing.
Related: #65 (transactional pipelines). Both lean on the same supervisor + proxy infrastructure.
Why this is worth building
The standard pattern today is OPENAI_API_KEY=sk-... agent. Once the key is in the agent's env, it leaks via:
Debug prints (print(os.environ), loggers that capture process context).
Prompt injection convincing the LLM to "print your environment."
Exfiltration to an allowed-but-unintended endpoint (paste services, gists).
Core dumps, memory snapshots, suspended VMs.
Every one of these is a real incident class. None are reachable if the secret is never in the agent process. That is the structural property worth chasing: the secret cannot leak from a place it does not exist.
Concrete scenarios this unlocks:
AI coding agents with API access where the agent can run arbitrary shell commands.
CI/CD running untrusted external PRs against internal APIs.
IDE plugins / extension hosts calling APIs on the user's behalf.
Multi-tenant agent serving where one user's key must never reach another user's workload.
Running unfamiliar code from a README that wants HF_TOKEN or similar.
MCP servers exposed to prompt injection.
End state (what done looks like)
A user runs:
sandlock run --workdir . --service openai -- agent
That single flag arranges:
The real OpenAI key is read from the configured credential source (keystore / env / file / 1Password) into the supervisor's heap, wrapped in a zeroize-on-drop type.
A per-session phantom token (sandlock_phantom_openai_<random>) is generated.
OPENAI_API_KEY=<phantom> is set in the child env so SDKs that demand a non-empty env var work without modification.
The child's HTTPS calls to api.openai.com are intercepted by the existing seccomp connect() redirect, terminated at the local proxy via MITM with --http-ca, the real Bearer is injected, and the request is re-encrypted upstream.
Every credential use is recorded in the structured audit log by name, never by value.
The user's agent code is unmodified. The real key never enters the agent process in any form. If the phantom leaks via any of the failure modes above, it is meaningless: it authenticates nothing, it dies with the session, and it pinpoints which session leaked it.
Seccomp connect() redirect routes sandboxed traffic to the local proxy.
TLS termination with user-supplied CA (--http-ca, --http-key).
Method + host + path ACL via HttpRule (HttpRule::parse, HttpRule::matches).
--env for setting child env vars (no source-loading; user pastes literal values).
What's missing: the entire concept of a named credential, any notion of injecting auth headers in-proxy, and any mechanism for delivering a key to the supervisor without also delivering it to the child.
Design overview
Layered surface. Three modes side by side, sharing one substrate.
Routing modes
Mode
How
Trade-off
Transparent (default)
Existing seccomp connect-redirect + MITM with --http-ca. Agent calls real upstream URL.
Zero agent changes. Requires CA trust inside the sandbox.
Explicit (opt-in)
Forward proxy listens on 127.0.0.1:PORT with prefix routing. Supervisor sets <SERVICE>_BASE_URL in child env.
No CA in sandbox. Requires the SDK to honor the base URL env var.
Selection: --proxy-mode={transparent,explicit}.
API layers
Layer
Surface
When to use
1: Services
--service openai
Common case. One flag enables a known upstream with sensible defaults.
2: Inject rules
--credential + --http-inject
Custom upstreams, per-path matchers, non-standard auth shapes. Layer 1 is built on this.
3: Env credentials
--env-credential
Non-network secrets (DB passwords, signing keys for local tools). Documented with a warning that the value is visible in child env.
Conflict resolution
Layer 1 expands into Layer 2 at validate time. Explicit --credential flags can override the service's defaults. Explicit --http-inject rules can layer on top of or coexist with service-generated rules.
Configuration shape
CLI
# Layer 1: one flag
sandlock run --service openai -- agent
# Multiple services
sandlock run --service openai --service anthropic --service github -- agent
# Service with explicit source override
sandlock run --service openai --credential openai=env:MY_OPENAI_KEY -- agent
# Custom service (not in built-in registry)
sandlock run \
--service-add my-corp \
upstream_host=api.corp.example.com \
inject_header=Authorization \
credential_format='Bearer {}' \
keystore_account=corp_token \
--service my-corp \
-- agent
# Layer 2: unusual setups
sandlock run \
--http-ca ca.pem --http-key ca-key.pem \
--credential CORP_KEY=file:/run/credentials/corp \
--http-inject "POST api.corp.com/* bearer:CORP_KEY" \
--http-allow "POST api.corp.com/v2/*" \
-- agent
# Layer 3: env-only secret (non-HTTP)
sandlock run --env-credential DATABASE_PASSWORD=keystore:db_prod -- agent
# Explicit forward-proxy mode (no CA in sandbox)
sandlock run --proxy-mode=explicit --service openai -- agent
# Child sees: OPENAI_API_KEY=sandlock_phantom_openai_a1b2c3d4# OPENAI_BASE_URL=http://127.0.0.1:<port>/openai
Resolve each ServiceRef::Builtin against the shipped registry; error on unknown.
Expand every service to: a Credential (if none already named), an HttpInjectRule, an http_allow rule, a phantom env entry.
Every credential name referenced in any http_inject (post-expansion) must exist in credentials.
ProxyMode::Transparent requires http_ca and http_key for any HTTPS service or inject rule.
For every injected host, refuse direct --net-allow HOST:PORT for PORT outside the proxy's intercept set unless --allow-credential-bypass HOST is passed.
Warn if http_inject is present without a matching http_allow (request will be injected then denied).
Supervisor startup (before fork):
Resolve every CredentialSource to a SecretString. Errors abort before child exists.
Build Arc<SecretMap>.
Generate phantom tokens for each rule that has phantom_env. Record (sentinel -> credential name) in a PhantomSwapMap.
Compile inject rule templates into Vec<TemplateSegment::{Literal | Cred(name)}>.
Never logs values. phantom_swap records whether the proxy actually swapped a sentinel out of an existing header or wrote the credential into an empty slot.
Composition with existing and proposed features
--http-allow / --http-deny (existing): ACL runs after injection.
--http-ca / --http-key (existing): required for transparent HTTPS injection.
Transactional pipelines (RFC: Multi-sandbox transactions for pipelines #65): each stage's Sandbox carries its own credentials and services; cross-stage shared secrets require passing the same definitions to each stage's builder.
Structured audit log (proposed elsewhere): credential and inject events are part of that stream.
Rotation in long-running sandboxes. Phase 1 has none. Agent platforms running multi-hour sessions probably need at least mtime-polled file source by Phase 1.5.
Per-injection rate limits. Useful safety net but probably a separate "proxy rate limiting" feature.
Body-level injection. Some APIs put the key in a JSON body field. InjectAuth enum leaves room for a BodyJsonPointer variant; out of scope for v1.
--credential FOO=literal:value for tests. Convenient for examples, dangerous in production (ps, shell history). Lean: include but emit a warning.
Interaction with --clean-env. Phantom tokens and --env-credential values still land in the child env even with --clean-env; document that these are not stripped.
Service registry distribution. Built-in is shipped in the binary. If users want to add to it without modifying their config, a later phase could allow community-maintained services.toml snippets.
Acceptance for Phase 1
sandlock run --workdir . --service openai -- python3 -c "import os; print(os.environ.get('OPENAI_API_KEY'))" prints a sandlock_phantom_openai_* sentinel, not the real key.
The same sandbox successfully calls https://api.openai.com/v1/chat/completions with the real Bearer attached at the proxy.
A request that does not match any inject rule passes through with the agent's existing headers untouched.
A request that matches an inject rule but is denied by http_allow is dropped at the proxy; no audit event records the credential leaving the supervisor.
SecretString Debug output never contains the value; audit log never contains the value.
--env-credential works for a non-HTTP use case (e.g., loading a DB password from a file) and the loaded value is visible in os.environ in the child.
Python SDK exposes the new API and is covered by at least one end-to-end test per layer.
Goal
Let a sandboxed process authenticate to upstream APIs without the credential ever entering the sandbox's address space, environment, or filesystem. The real secret lives only in the supervisor; the child sees at most a per-session phantom token that authenticates nothing.
Related: #65 (transactional pipelines). Both lean on the same supervisor + proxy infrastructure.
Why this is worth building
The standard pattern today is
OPENAI_API_KEY=sk-... agent. Once the key is in the agent's env, it leaks via:print(os.environ), loggers that capture process context).subprocess.run(["curl", ...]),npm installrunning install scripts).Every one of these is a real incident class. None are reachable if the secret is never in the agent process. That is the structural property worth chasing: the secret cannot leak from a place it does not exist.
Concrete scenarios this unlocks:
HF_TOKENor similar.End state (what done looks like)
A user runs:
sandlock run --workdir . --service openai -- agentThat single flag arranges:
sandlock_phantom_openai_<random>) is generated.OPENAI_API_KEY=<phantom>is set in the child env so SDKs that demand a non-empty env var work without modification.api.openai.comare intercepted by the existing seccompconnect()redirect, terminated at the local proxy via MITM with--http-ca, the real Bearer is injected, and the request is re-encrypted upstream.The user's agent code is unmodified. The real key never enters the agent process in any form. If the phantom leaks via any of the failure modes above, it is meaningless: it authenticates nothing, it dies with the session, and it pinpoints which session leaked it.
What exists today
crates/sandlock-core/src/http.rs,crates/sandlock-core/src/http_acl.rs. Hudsucker-based, runs in-supervisor.connect()redirect routes sandboxed traffic to the local proxy.--http-ca,--http-key).HttpRule(HttpRule::parse,HttpRule::matches).--envfor setting child env vars (no source-loading; user pastes literal values).What's missing: the entire concept of a named credential, any notion of injecting auth headers in-proxy, and any mechanism for delivering a key to the supervisor without also delivering it to the child.
Design overview
Layered surface. Three modes side by side, sharing one substrate.
Routing modes
--http-ca. Agent calls real upstream URL.127.0.0.1:PORTwith prefix routing. Supervisor sets<SERVICE>_BASE_URLin child env.Selection:
--proxy-mode={transparent,explicit}.API layers
--service openai--credential+--http-inject--env-credentialConflict resolution
Layer 1 expands into Layer 2 at validate time. Explicit
--credentialflags can override the service's defaults. Explicit--http-injectrules can layer on top of or coexist with service-generated rules.Configuration shape
CLI
--credential NAME=SOURCEsyntax forSOURCE:env:VARfile:PATHfd:Nkeystore:ACCOUNT(Phase 2)op://vault/item/field(Phase 2)--http-inject "MATCH AUTH"syntax forAUTH:bearer:NAME->Authorization: Bearer <cred>basic:USER:PWNAME->Authorization: Basic base64(USER:<cred>)apikey:HEADER=NAME-><HEADER>: <cred>query:PARAM=NAME-> append?PARAM=<cred>header:NAME=TEMPLATE-> general;${cred:NAME}interpolates insideTEMPLATEProfile (TOML)
Built-in service registry
Shipped in the binary as
crates/sandlock-core/data/services.toml:Start with three to five canonical entries; grow on demand.
Rust API
Python API
Data types
Added to
Sandbox:Lifecycle
Build / validate (
Sandbox::validate):ServiceRef::Builtinagainst the shipped registry; error on unknown.Credential(if none already named), anHttpInjectRule, anhttp_allowrule, a phantom env entry.http_inject(post-expansion) must exist incredentials.ProxyMode::Transparentrequireshttp_caandhttp_keyfor any HTTPS service or inject rule.--net-allow HOST:PORTforPORToutside the proxy's intercept set unless--allow-credential-bypass HOSTis passed.http_injectis present without a matchinghttp_allow(request will be injected then denied).Supervisor startup (before fork):
CredentialSourceto aSecretString. Errors abort before child exists.Arc<SecretMap>.phantom_env. Record(sentinel -> credential name)in aPhantomSwapMap.Vec<TemplateSegment::{Literal | Cred(name)}>.PhantomSwapHandler -> InjectHandler -> AclHandler.--enventries.--env-credentialto its resolved value (the deliberate exception).<SERVICE>_BASE_URLinExplicitmode.Per request:
PhantomSwapHandlerscans configured headers for sentinel substrings; swaps to real values.InjectHandlermatches against rules in declaration order; first match wins; renders template; applieson_existingpolicy.AclHandlerperforms existing method/host/path check.Shutdown: secret map and phantom map drop, zeroized.
Safety invariants
--env-credentialis the explicit, labeled exception.SecretStringDebug is redacted; audit events use names.zeroize::Zeroizingwrapper.policy_fncannot read credential valuesAudit events (compose with the broader audit-log work)
{"ts":"...","event":"service.enabled","name":"openai","credential_source":"keystore:openai_api_key"} {"ts":"...","event":"credential.loaded","name":"openai","source":"keystore"} {"ts":"...","event":"phantom.minted","service":"openai","env":"OPENAI_API_KEY"} {"ts":"...","event":"http.inject","method":"POST","host":"api.openai.com","path":"/v1/chat/completions","credential":"openai","header":"Authorization","phantom_swap":true} {"ts":"...","event":"env_credential.loaded","name":"DATABASE_PASSWORD","source":"keystore"} {"ts":"...","event":"credential.zeroized","name":"openai"}Never logs values.
phantom_swaprecords whether the proxy actually swapped a sentinel out of an existing header or wrote the credential into an empty slot.Composition with existing and proposed features
--http-allow/--http-deny(existing): ACL runs after injection.--http-ca/--http-key(existing): required for transparent HTTPS injection.policy_fn(existing): cannot read credential values.Sandboxcarries its own credentials and services; cross-stage shared secrets require passing the same definitions to each stage's builder.Phasing
http_inject,bearer/basic/apikey/query/headerauth, env / file / fd sources). Layer 1 with built-in registry but only env / file credential sources. Phantom tokens for Layer 1. Transparent proxy mode only. Audit events.keyringcrate). 1Password (opCLI). Service registry defaults to keystore. Explicit forward-proxy mode.SIGHUPreloads sources; polled file source with mtime check; callback-based providers in Rust API.Open questions
InjectAuthenum leaves room for aBodyJsonPointervariant; out of scope for v1.--credential FOO=literal:valuefor tests. Convenient for examples, dangerous in production (ps, shell history). Lean: include but emit a warning.--clean-env. Phantom tokens and--env-credentialvalues still land in the child env even with--clean-env; document that these are not stripped.services.tomlsnippets.Acceptance for Phase 1
sandlock run --workdir . --service openai -- python3 -c "import os; print(os.environ.get('OPENAI_API_KEY'))"prints asandlock_phantom_openai_*sentinel, not the real key.https://api.openai.com/v1/chat/completionswith the real Bearer attached at the proxy.http_allowis dropped at the proxy; no audit event records the credential leaving the supervisor.SecretStringDebug output never contains the value; audit log never contains the value.--env-credentialworks for a non-HTTP use case (e.g., loading a DB password from a file) and the loaded value is visible inos.environin the child.