Agent sandbox with integrated credential injection proxy. Allows unmodified code to hit e.g. https://api.stripe.com and have API credentials injected transparently via an Envoy proxy with TLS termination.
This project addresses two security concerns when running AI agents with API access:
When an AI agent makes API calls, the credentials are visible to the LLM provider in the conversation context. Even if the agent runs locally, tool outputs containing Authorization: Bearer sk_live_... headers get sent back to the model. This proxy keeps real credentials out of the agent's context entirely — the agent only sees an opaque macaroon token, while real API keys are injected server-side.
A misbehaving or compromised agent with full API access can do significant damage. Macaroon tokens with caveats provide fine-grained restrictions:
- Host restrictions: Token only works for specific APIs (e.g.,
api.stripe.combut notapi.openai.com) - Method restrictions: Limit to read-only operations (
GETonly) - Path restrictions: Scope access to specific resources (
/v1/customers/*but not/v1/transfers/*) - Time restrictions: Tokens expire automatically (default: 24 hours)
This turns "full API access" into precisely scoped capabilities that match the agent's intended task.
┌─────────────────────────────────────────────────────────────┐
│ Docker network │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ sandbox │ │ envoy │ │ vault │ │
│ │ (your app) │─────▶│ (TLS term) │─────▶│ (tokens) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ iptables NAT: │ │
│ all TCP → envoy │ │
│ ▼ │
└───────────────────────────┼─────────────────────────────────┘
│ HTTPS
▼
api.stripe.com (real)
- sandbox: Container running your code, with iptables redirecting all outbound TCP to envoy
- envoy: Terminates TLS with runtime-generated certs, calls vault for token validation
- vault: Validates macaroon tokens, injects real API keys into requests
- Docker and Docker Compose
- Go 1.21+ (for building tools)
- gVisor runsc (default sandbox runtime): https://gvisor.dev/docs/user_guide/install/
- To use docker without gVisor set
runtime = "runc"inagent-creds.toml
- To use docker without gVisor set
# Create project config (or copy agent-creds.example.toml)
cat > agent-creds.toml << 'EOF'
[sandbox]
name = "myproject"
[upstream."api.anthropic.com"]
[upstream."claude.ai"]
[upstream."platform.claude.com"]
[[browser_target]]
url = "https://claude.ai/oauth/authorize*"
[[browser_target]]
url = "http://localhost:*"
EOF
# Set environment variables for vault
export MACAROON_SIGNING_KEY=$(openssl rand -base64 32)
export STRIPE_API_KEY=sk_live_xxx
# Start vault service (once)
make up
# Launch sandbox
adev consoleadev is the main development tool. It launches sandboxed environments where your code runs with transparent API proxying.
adev # Show running instances (interactive TUI)
adev console # Start or attach to sandbox for current directory
adev console foo # Start or attach to sandbox named "foo"
adev stop # Stop sandbox for current directory
adev stop foo # Stop sandbox named "foo"What adev does:
- Mounts your current directory at
/workspaceinside the container - Blocks all network traffic by default — the sandbox has no internet access except through the proxy
- Routes only the domains listed in
agent-creds.tomlthrough envoy - Hot-reloads when you edit
agent-creds.toml(add/remove upstreams without restart) - Starts vault service if not running
- Attaches to existing instances instead of creating duplicates
Multiple named sandboxes can run concurrently.
actl is a control utility for monitoring and managing sandbox instances.
actl # Interactive TUI showing all instances
actl status # Show status for current project (containers, vault connectivity)The TUI shows all running adev instances with their status (running/partial/stopped).
Inside the sandbox, only configured domains are reachable:
# Works - domain is in agent-creds.toml
curl https://api.stripe.com/v1/customers \
-H "Authorization: Bearer $STRIPE_TOKEN"
# Blocked - domain not configured
curl https://example.com # connection refusedEnvoy replaces macaroons for the real API key before forwarding to the upstream.
# 1. Mint a token (on host, before launching sandbox)
export STRIPE_TOKEN=$(bin/mint --hosts api.stripe.com --valid-for 1h)
# 2. Launch sandbox with token as env var
STRIPE_TOKEN=$STRIPE_TOKEN adev console
# 3. Inside sandbox, use the token normally
curl https://api.stripe.com/v1/customers \
-H "Authorization: Bearer $STRIPE_TOKEN"The sandbox never sees sk_live_... — only the macaroon token. The vault service validates the token and injects the real Stripe API key before the request reaches Stripe.
Tokens can include caveats that restrict what the token can do:
# Full access to configured APIs
bin/mint
# Read-only access to Stripe customers endpoint for 1 hour
bin/mint --hosts api.stripe.com --methods GET --paths "/v1/customers/*" --valid-for 1h| Flag | Description | Example |
|---|---|---|
--hosts |
Allowed API hosts | api.stripe.com,api.openai.com |
--methods |
Allowed HTTP methods | GET,POST |
--paths |
Allowed path patterns (* = segment, ** = multiple) |
/v1/customers/* |
--valid-for |
Token expiration | 1h, 24h, 7d |
--not-before |
Validity start time (RFC3339) | 2024-01-01T00:00:00Z |
--show-caveats |
Print caveats to stderr |
Tokens without restrictions (no --hosts, --methods, --paths) have full access to all configured APIs.
Not all requests need credential injection. The vault checks the Authorization header:
- Macaroon tokens (prefix
acm_): Validated and swapped for real credentials - Other tokens / no auth: Passed through unchanged to the upstream
Passthrough allows the proxy to handle APIs that don't require credentials, or that use different auth schemes. To require macaroons for all requests, set STRICT_MODE=true on the vault service.
When token validation fails, the vault returns specific HTTP errors:
| Status | Cause | Example |
|---|---|---|
| 401 Unauthorized | Invalid/expired macaroon, bad signature | Unauthorized: token expired |
| 403 Forbidden | Valid token but caveat violation | Unauthorized: host not allowed |
| 403 Forbidden | No credentials configured for host | No credentials configured for this host |
The response body includes a message explaining the failure. Check vault logs for detailed diagnostics.
Controls the sandbox configuration, and which domains are routed through the proxy:
[sandbox]
name = "myproject"
# runtime = "runc" # default is gVisor; use runc if runsc not installed
# memory = "8g" # docker --memory limit (e.g., "8g", "512m")
# cpus = "4" # docker --cpus limit (e.g., "4", "1.5")
[upstream."api.stripe.com"]
[upstream."pocketbase.example.com"]Configures credential injection for each domain:
[credentials."api.stripe.com"]
type = "bearer"
token = { provider = "env", name = "STRIPE_API_KEY" }
[credentials."pocketbase.example.com"]
type = "basic"
username = { provider = "env", name = "PB_USERNAME" }
password = { provider = "env", name = "PB_PASSWORD" }Credential types:
- bearer: Injects
Authorization: Bearer <token> - basic: Injects
Authorization: Basic <base64>(base64-encoded username:password)
Providers:
- env: Read from environment variable
MACAROON_SIGNING_KEY: Base64-encoded 32+ byte key for signing/verifying tokensTOKEN_PREFIX: Macaroon token prefix (default:acm_)STRICT_MODE: Set totrueto reject non-macaroon requests (disables passthrough)- Plus any env vars referenced in
vault.toml
# Primary workflow
adev # Interactive TUI showing running sandboxes
adev console # Start or attach to sandbox
# Vault service
make up # Start vault with docker-compose
make down # Stop vault
# Building
make build # Build sandbox Docker image
make binaries # Build all CLI tools to bin/
# Maintenance
make deploy # Deploy vault service to Fly.io
make clean-certs # Remove generated certs (forces regeneration).
├── agent-creds.toml # Project config (per-project)
├── docker-compose.yml # Vault service config
├── Makefile # Build/deploy commands
├── envoy-entrypoint.sh # Runtime cert generation for envoy
├── cmd/
│ ├── adev/ # Development orchestrator
│ ├── actl/ # Control utility for managing instances
│ ├── aenv/ # Environment variable helper
│ └── cdp-proxy/ # Chrome DevTools Protocol proxy
├── generated/ # Generated files (gitignored)
│ ├── certs/ # CA certificate (domain certs generated at runtime)
│ ├── envoy.json # Envoy config
│ └── domains.json # Domain config for runtime cert generation
├── vault/
│ ├── main.go # gRPC vault service
│ ├── macaroon/ # Macaroon token library
│ ├── cmd/mint/ # Token minting CLI
│ └── Dockerfile
└── bin/ # Built binaries (run make binaries)
- CA Generation:
adevcreates a CA cert once ingenerated/certs/ - Runtime Certs:
envoy-entrypoint.shgenerates domain certs at startup using the CA - Traffic Interception: iptables NAT rules redirect all outbound TCP to envoy
- TLS Termination: Envoy terminates TLS using SNI to select the right certificate, so
https://api.stripe.comworks with unmodified code - Token Verification: Vault verifies the macaroon token signature and checks caveats (host, method, path, validity)
- Credential Injection: On successful verification, vault injects the real API key before forwarding to upstream
Code inside the sandbox can open URLs in your host's default browser:
xdg-open https://accounts.google.com/oauth/authorize?...This enables OAuth flows where:
- Sandbox code calls
xdg-openwith auth URL → browser opens on host - User authenticates in host browser
- OAuth callback to
localhost:PORTroutes back into the sandbox
The callback routing works automatically—adev detects localhost URLs with ports and proxies incoming connections from the host back to the sandbox.
Configure in agent-creds.toml:
[sandbox]
use_host_browser = true # default
# URL allow-list (required - empty = all blocked)
[[browser_target]]
url = "*accounts.google.com/o/oauth*"
[[browser_target]]
url = "http://localhost:*"Only URLs matching a [[browser_target]] pattern will be opened. All others are blocked.
Control your host's Chrome browser from inside the sandbox. Playwright, Puppeteer, and other automation tools connect to localhost:9222 which forwards to Chrome on your host.
# Inside sandbox - controls host Chrome
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp("http://localhost:9222")
page = browser.new_page()
page.goto("https://example.com")Start Chrome on your host with remote debugging enabled:
google-chrome --remote-debugging-port=9222Configure in agent-creds.toml:
[sandbox]
use_host_browser_cdp = true # default
# Target allow-list (required - empty = all blocked)
[[cdp_target]]
type = "page"
title = "*My App*"
[[cdp_target]]
url = "*github.com*"Only browser tabs matching a [[cdp_target]] pattern will be accessible. This prevents agents from accessing sensitive tabs (email, banking, etc.).
CDP target fields (all optional, empty = match any):
type: Target type (page,background_page,service_worker, etc.)title: Glob pattern matching page titleurl: Glob pattern matching page URL
When multiple fields are specified, all must match.
