Skip to content

RFC: Credential injection via the proxy #66

@congwang-mk

Description

@congwang-mk

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:

  • Debug prints (print(os.environ), loggers that capture process context).
  • Tracebacks that include local-variable values.
  • Child processes inheriting env (subprocess.run(["curl", ...]), npm install running install scripts).
  • 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:

  1. 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.
  2. A per-session phantom token (sandlock_phantom_openai_<random>) is generated.
  3. OPENAI_API_KEY=<phantom> is set in the child env so SDKs that demand a non-empty env var work without modification.
  4. 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.
  5. 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.

What exists today

  • HTTPS MITM proxy infrastructure: crates/sandlock-core/src/http.rs, crates/sandlock-core/src/http_acl.rs. Hudsucker-based, runs in-supervisor.
  • 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

--credential NAME=SOURCE syntax for SOURCE:

  • env:VAR
  • file:PATH
  • fd:N
  • keystore:ACCOUNT (Phase 2)
  • op://vault/item/field (Phase 2)

--http-inject "MATCH AUTH" syntax for AUTH:

  • 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 inside TEMPLATE

Profile (TOML)

[[credential]]
name = "OPENAI_KEY"
source = { keystore = { account = "openai_api_key" } }

[[credential]]
name = "GH_TOKEN"
source = { op = { uri = "op://Dev/GitHub/token" } }

# Layer 1: named service from built-in registry
[[service]]
name = "openai"
# credential_source defaults to keystore:openai_api_key

# Layer 1: custom service
[[service]]
name = "my-corp"
upstream_host     = "api.corp.example.com"
inject_header     = "Authorization"
credential_format = "Bearer {}"
phantom_env       = "CORP_API_KEY"
credential        = "CORP_KEY"

# Layer 2: low-level inject rule
[[http_inject]]
match = "POST internal.corp/*"
auth  = { header = "X-Tenant", template = "${cred:TENANT_KEY}" }

# Layer 3: env-only secret
[[env_credential]]
name = "DATABASE_PASSWORD"
source = { op = { uri = "op://Infra/DB-Prod/password" } }

Built-in service registry

Shipped in the binary as crates/sandlock-core/data/services.toml:

[openai]
upstream_host     = "api.openai.com"
upstream_paths    = ["/v1/*"]
inject_header     = "Authorization"
credential_format = "Bearer {}"
phantom_env       = "OPENAI_API_KEY"
keystore_account  = "openai_api_key"

[anthropic]
upstream_host     = "api.anthropic.com"
upstream_paths    = ["/v1/*"]
inject_header     = "x-api-key"
credential_format = "{}"
phantom_env       = "ANTHROPIC_API_KEY"
keystore_account  = "anthropic_api_key"

[github]
upstream_host     = "api.github.com"
inject_header     = "Authorization"
credential_format = "token {}"
phantom_env       = "GITHUB_TOKEN"
keystore_account  = "github_token"

Start with three to five canonical entries; grow on demand.

Rust API

use sandlock::{Sandbox, ServiceRef, ServiceDef, CredentialSource, InjectAuth, HttpRule};

// Layer 1
let sandbox = Sandbox::builder()
    .service(ServiceRef::builtin("openai"))
    .build()?;

// Layer 1 with custom service
let corp = ServiceDef::new("my-corp")
    .upstream_host("api.corp.example.com")
    .inject_header("Authorization")
    .credential_format("Bearer {}")
    .keystore_account("corp_token")
    .phantom_env("CORP_API_KEY");

let sandbox = Sandbox::builder()
    .service_def(corp)
    .service(ServiceRef::by_name("my-corp"))
    .build()?;

// Layer 2
let sandbox = Sandbox::builder()
    .http_ca("ca.pem", "ca-key.pem")
    .credential("CORP_KEY", CredentialSource::File("/run/credentials/corp".into()))
    .http_inject(
        HttpRule::parse("POST api.corp.com/*")?,
        InjectAuth::Bearer("CORP_KEY".into()),
    )
    .http_allow(HttpRule::parse("POST api.corp.com/v2/*")?)
    .build()?;

// Layer 3
let sandbox = Sandbox::builder()
    .env_credential("DATABASE_PASSWORD",
        CredentialSource::Keystore { account: "db_prod".into() })
    .build()?;

Python API

from sandlock import Sandbox, service, credential_source

# Layer 1
Sandbox(services=["openai", "anthropic"])

# Layer 1 with custom service
Sandbox(services=[
    "openai",
    service("my-corp",
            upstream_host="api.corp.example.com",
            inject_header="Authorization",
            credential_format="Bearer {}",
            keystore_account="corp_token"),
])

# Layer 2
Sandbox(
    http_ca=("ca.pem", "ca-key.pem"),
    credentials={"CORP_KEY": {"file": "/run/credentials/corp"}},
    http_inject=[("POST api.corp.com/*", {"bearer": "CORP_KEY"})],
    http_allow=["POST api.corp.com/v2/*"],
)

# Layer 3
Sandbox(env_credentials={"DATABASE_PASSWORD": {"keystore": "db_prod"}})

Data types

// crates/sandlock-core/src/credential.rs (new module)

use zeroize::Zeroizing;

pub enum CredentialSource {
    Env(String),
    File(PathBuf),
    Fd(RawFd),
    Keystore { account: String },   // Phase 2
    OnePassword { uri: String },    // Phase 2
}

pub struct Credential {
    pub name: String,
    pub source: CredentialSource,
}

/// Resolved credential value. Supervisor heap only.
/// Zeroized on drop. Redacted Debug. Not Serialize.
pub struct SecretString(Zeroizing<String>);

pub enum InjectAuth {
    Bearer(String),
    Basic { user: String, password_cred: String },
    ApiKeyHeader { header: String, cred: String },
    QueryParam { name: String, cred: String },
    Header { name: String, template: String },     // ${cred:NAME}
}

pub struct HttpInjectRule {
    pub matcher: HttpRule,
    pub auth: InjectAuth,
    pub phantom_env: Option<String>,
    pub on_existing: OnExistingHeader,
}

pub enum OnExistingHeader { Replace, AddOnly }

pub struct ServiceDef {
    pub name: String,
    pub upstream_host: String,
    pub upstream_paths: Vec<String>,         // default ["/*"]
    pub inject_header: String,
    pub credential_format: String,           // "{}" is identity
    pub phantom_env: Option<String>,
    pub keystore_account: Option<String>,
    pub allow_methods: Vec<String>,          // default ["*"]
}

pub enum ServiceRef {
    Builtin(String),
    ByName(String),
}

Added to Sandbox:

pub struct Sandbox {
    // existing...
    pub credentials:     Vec<Credential>,
    pub env_credentials: Vec<Credential>,
    pub services:        Vec<ServiceRef>,
    pub service_defs:    Vec<ServiceDef>,
    pub http_inject:     Vec<HttpInjectRule>,
    pub proxy_mode:      ProxyMode,
}

pub enum ProxyMode { Transparent, Explicit }

Lifecycle

Build / validate (Sandbox::validate):

  1. Resolve each ServiceRef::Builtin against the shipped registry; error on unknown.
  2. Expand every service to: a Credential (if none already named), an HttpInjectRule, an http_allow rule, a phantom env entry.
  3. Every credential name referenced in any http_inject (post-expansion) must exist in credentials.
  4. ProxyMode::Transparent requires http_ca and http_key for any HTTPS service or inject rule.
  5. 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.
  6. Warn if http_inject is present without a matching http_allow (request will be injected then denied).

Supervisor startup (before fork):

  1. Resolve every CredentialSource to a SecretString. Errors abort before child exists.
  2. Build Arc<SecretMap>.
  3. Generate phantom tokens for each rule that has phantom_env. Record (sentinel -> credential name) in a PhantomSwapMap.
  4. Compile inject rule templates into Vec<TemplateSegment::{Literal | Cred(name)}>.
  5. Wire proxy handler chain: PhantomSwapHandler -> InjectHandler -> AclHandler.
  6. Construct child env:
    • Pass through --env entries.
    • Set each --env-credential to its resolved value (the deliberate exception).
    • Set each phantom env to its sentinel.
    • Set each <SERVICE>_BASE_URL in Explicit mode.

Per request:

  1. PhantomSwapHandler scans configured headers for sentinel substrings; swaps to real values.
  2. InjectHandler matches against rules in declaration order; first match wins; renders template; applies on_existing policy.
  3. AclHandler performs existing method/host/path check.

Shutdown: secret map and phantom map drop, zeroized.

Safety invariants

Invariant Mechanism
Real credential never in child address space Loaded in supervisor before fork; child env constructed without supervisor-side env vars; --env-credential is the explicit, labeled exception.
Phantom token harmless on leak Per-session sentinel, no upstream meaning, dies with the supervisor.
Credential never in audit log SecretString Debug is redacted; audit events use names.
Credential not persisted No serialization path; no on-disk cache.
Credential zeroized on drop zeroize::Zeroizing wrapper.
Child cannot bypass proxy for injected hosts Validation rule (5); seccomp redirect + denied direct egress.
policy_fn cannot read credential values Not exposed in policy callback context.

Audit 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_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.
  • policy_fn (existing): cannot read credential values.
  • 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.

Phasing

Phase Scope
1 Layer 2 fully (per-rule http_inject, bearer / basic / apikey / query / header auth, 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.
2 Keystore source (keyring crate). 1Password (op CLI). Service registry defaults to keystore. Explicit forward-proxy mode.
3 Rotation: SIGHUP reloads sources; polled file source with mtime check; callback-based providers in Rust API.
4 Complex auth schemes that require request signing (AWS SigV4, GCP service-account JWT, OAuth refresh flows).

Open questions

  1. 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.
  2. Per-injection rate limits. Useful safety net but probably a separate "proxy rate limiting" feature.
  3. 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.
  4. --credential FOO=literal:value for tests. Convenient for examples, dangerous in production (ps, shell history). Lean: include but emit a warning.
  5. 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.
  6. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    help wantedExtra attention is needed
    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions