Skip to content

Latest commit

 

History

12 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

Inject Secrets into a Sandbox with a proxy Sidecar

Code running in a Sandbox often needs to call an external API that requires an API key. Rather than passing that key into the Sandbox, you can keep it in a separate, trusted container and let that container add it to outbound requests:

Sandbox code  ->  proxy Sidecar  ->  api.anthropic.com
(no API key)      (adds the key)

The proxy runs as a Sidecar: a sibling container that shares a private network with the Sandbox. That network is reachable only from inside the Sandbox, so the proxy is available to your Sandbox code and to nothing else, while the key itself stays in a Modal Secret mounted on the Sidecar alone.

We use Caddy as the proxy and the Anthropic API as the upstream, but any proxy that can set request headers (nginx, Envoy, or something you write yourself) and any authenticated API will do.

Sandbox Sidecars are in alpha and access is restricted to allowlisted workspaces.

The whole example is one file: sidecar_secrets_injection.py. All of the code below comes from it.

To run this example, create the Secret it reads the key from:

modal secret create anthropic-secret ANTHROPIC_API_KEY=sk-ant-...

then run the script:

pip install modal
python sidecar_secrets_injection.py
import argparse
import tempfile
from pathlib import Path

import modal

app = modal.App.lookup("example-sidecar-secrets-injection", create_if_missing=True)

MINUTES = 60  # seconds

Configure the proxy

Each Sidecar is reachable from the Sandbox at the name we give it, resolved through /etc/hosts on the shared bridge network.

SIDECAR_NAME = "egress-proxy"
SIDECAR_PORT = 8080
PROXY_URL = f"http://{SIDECAR_NAME}:{SIDECAR_PORT}"

The proxy's behavior is defined by a Caddyfile. Ours forwards every request to the Anthropic API, filling in the real key from the Sidecar's own environment. It also drops any Authorization header that came from the Sandbox, so the proxy decides which key reaches the upstream.

DEFAULT_CADDYFILE = """\
{
    admin off
}

:8080 {
    reverse_proxy https://api.anthropic.com {
        header_up Host api.anthropic.com
        header_up x-api-key {env.ANTHROPIC_API_KEY}
        header_up -Authorization
    }
}
"""

To point the proxy at a different upstream, or to add rules of your own — rate limits, path restrictions, extra headers — pass your own config instead:

parser = argparse.ArgumentParser()
parser.add_argument(
    "--caddyfile",
    type=Path,
    default=None,
    help="path to a Caddyfile to use instead of the default",
)
args = parser.parse_args()

caddyfile = args.caddyfile.read_text() if args.caddyfile else DEFAULT_CADDYFILE

Build the Images

We write the config out and copy it to the path the caddy Image already reads on startup, so the Sidecar needs no command of its own.

Sidecars can't build their Image lazily on startup, so we build it up front with Image.build and pass the resolved Image along.

with tempfile.TemporaryDirectory() as tmp_dir:
    caddyfile_path = Path(tmp_dir) / "Caddyfile"
    caddyfile_path.write_text(caddyfile)
    with modal.enable_output():
        sidecar_image = (
            modal.Image.from_registry("caddy:2.11")
            .add_local_file(caddyfile_path, "/etc/caddy/Caddyfile", copy=True)
            .build(app)
        )

sandbox_image = modal.Image.debian_slim(python_version="3.12").pip_install(
    "anthropic==0.121.0"
)

Start the Sandbox

The Sandbox is told where the proxy is but is given no Secrets. An empty outbound_cidr_allowlist cuts off its access to the public internet while leaving the private network to its Sidecars intact, so the proxy becomes the Sandbox's only way out.

We pass no command, so the Sandbox stays alive waiting for us to send it work.

with modal.enable_output():
    sandbox = modal.Sandbox.create(
        app=app,
        image=sandbox_image,
        env={"ANTHROPIC_BASE_URL": PROXY_URL},
        outbound_cidr_allowlist=[],
        timeout=5 * MINUTES,
    )
print(f"Sandbox ID: {sandbox.object_id}")

Start the proxy Sidecar

Now we start Caddy alongside it, with the Anthropic Secret mounted here and only here.

sidecar = sandbox._experimental_sidecars.create(
    name=SIDECAR_NAME,
    image=sidecar_image,
    secrets=[
        modal.Secret.from_name("anthropic-secret", required_keys=["ANTHROPIC_API_KEY"])
    ],
)
print(f"Sidecar ID: {sidecar.object_id}")

Creating a Sidecar returns as soon as its container starts, which is before Caddy is listening, so we wait for the port to start accepting connections.

sandbox.exec(
    "bash",
    "-c",
    f"until (echo > /dev/tcp/{SIDECAR_NAME}/{SIDECAR_PORT}) 2>/dev/null; do sleep 0.1; done",
    timeout=1 * MINUTES,
).wait()

Call the API from the Sandbox

The code below runs inside the Sandbox. It first shows what it has to work with — no key, and no direct route to Anthropic — then calls the API with an invalid key, which the proxy swaps out for the real one on the way through.

Note that the call itself is unmodified from what it would be without a proxy: the Anthropic SDK, like most SDKs and agent harnesses, picks up its base URL from the environment.

UNTRUSTED_CODE = """
import os
import socket

import anthropic

print(f"ANTHROPIC_API_KEY in Sandbox env: {'ANTHROPIC_API_KEY' in os.environ}")

try:
    socket.create_connection(("api.anthropic.com", 443), timeout=10).close()
    print("Direct connection to api.anthropic.com: succeeded")
except OSError as exc:
    print(f"Direct connection to api.anthropic.com: blocked ({type(exc).__name__})")

print(f"Calling Anthropic through {os.environ['ANTHROPIC_BASE_URL']} with an invalid key")
client = anthropic.Anthropic(api_key="not-a-real-key")
message = client.messages.create(
    model="claude-haiku-4-5-20251001",
    max_tokens=256,
    messages=[{"role": "user", "content": "Say hello in one sentence."}],
)
print(f"Response: {message.content[0].text}")
"""

process = sandbox.exec("python", "-c", UNTRUSTED_CODE)
for line in process.stdout:
    print(line, end="")
if process.wait() != 0:
    raise RuntimeError(process.stderr.read())

The output should look something like

ANTHROPIC_API_KEY in Sandbox env: False
Direct connection to api.anthropic.com: blocked (OSError)
Calling Anthropic through http://egress-proxy:8080 with an invalid key
Response: Hello! It's nice to meet you.

Terminating the Sandbox tears down its Sidecars along with it.

sandbox.terminate()

It's worth knowing where this boundary ends: the Sandbox never sees the key, but it can still use it, and can make as many Anthropic calls as it likes. If that matters for your workload, the proxy is also the natural place to add rate limits or to restrict which paths it forwards.

About

A recipe for injecting credentials into requests from sandboxes, exposing only a short-lived JWT.

Resources

Stars

8 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages