Sandcat is a dev container setup for running AI agents (or any code) in a sandboxed environment with controlled network access and transparent secret substitution — while retaining the convenience of working in an IDE like VS Code.
All container traffic is routed through a transparent mitmproxy via WireGuard, capturing HTTP/S, DNS, and all other TCP/UDP traffic without per-tool proxy configuration. A network policy engine controls which requests are allowed, and a secret substitution system injects credentials at the proxy level so the container never sees real values.
Sandcat is mainly inspired by Matchlock, which provides similar network isolation and secret substitution, however in the form of a dedicated command line tool. While Matchlock VMs offer greater isolation and security, they also lack the convenience of a dev containers setup, and integration with an IDE.
agent-sandbox implements a proxy
that runs alongside the container, however without secret substitution.
Moreover, the proxy is not transparent, instead relying on the more traditional
method of setting the PROXY environment variable.
Finally, Sandcat builds on the Docker+mitmxproxy in WireGuard mode integration implemented in mitm_wg.
Create a settings file with your secrets and network rules:
mkdir -p ~/.config/sandcat
cp settings.example.json ~/.config/sandcat/settings.json
# Edit with your real valuesThen start the built-in test container to verify everything works:
docker compose -f .devcontainer/compose.yml --profile test run --rm test bashInside the container:
# Should return 200 (mitmproxy CA is trusted)
curl -s -o /dev/null -w '%{http_code}\n' https://example.com
# Check secret substitution (if you configured a GitHub token)
gh auth statusSee Testing the proxy for more verification steps.
Add sandcat as a git submodule inside .devcontainer/:
git submodule add <url> .devcontainer/sandcatYour .devcontainer/ directory should end up looking like this:
.devcontainer/
├── sandcat/ # the submodule
│ ├── compose.yml # mitmproxy + wg-client services
│ ├── scripts/
│ │ ├── sandcat-init.sh # entrypoint for app containers
│ │ ├── sandcat_addon.py # mitmproxy addon (network rules + secret substitution)
│ │ └── start-wireguard.sh # wg-client entrypoint
│ └── settings.example.json
├── compose.yml # your project's compose file (includes sandcat)
├── Dockerfile # your app container image
└── devcontainer.json
In your .devcontainer/compose.yml, include sandcat's compose file and
add your app service:
include:
- path: sandcat/compose.yml
services:
app:
build:
context: .
dockerfile: Dockerfile
network_mode: "service:wg-client"
volumes:
- ..:/workspaces/project:cached
- mitmproxy-config:/mitmproxy-config:ro
command: sleep infinity
depends_on:
wg-client:
condition: service_healthyThe key parts: network_mode: "service:wg-client" routes all traffic
through the WireGuard tunnel, and the mitmproxy-config volume gives
your container access to the CA cert and placeholder env vars.
In your .devcontainer/Dockerfile, copy and use sandcat-init.sh as
the entrypoint:
FROM mcr.microsoft.com/devcontainers/javascript-node:22
COPY sandcat/scripts/sandcat-init.sh /usr/local/bin/sandcat-init.sh
RUN chmod +x /usr/local/bin/sandcat-init.sh
ENTRYPOINT ["sandcat-init.sh"]The entrypoint installs the mitmproxy CA certificate into the system trust store and loads placeholder environment variables for secret substitution before handing off to the container's main command.
~/.config/sandcat/settings.json:
{
"secrets": {
"ANTHROPIC_API_KEY": {
"value": "sk-ant-real-key-here",
"hosts": ["api.anthropic.com"]
}
},
"network": [
{"action": "allow", "host": "*", "method": "GET"},
{"action": "allow", "host": "*.github.com", "method": "POST"},
{"action": "allow", "host": "*.anthropic.com"},
{"action": "allow", "host": "*.claude.com"}
]
}Warning: allowing all GET-traffic, all traffic from GitHub or in fact any not-fully-trusted/controlled site,
leaves the possibility of a prompt injection attack. Blocking POST-traffic might prevent code from being
exfiltrated, but malicious code might still be generated as part of the project.
The network array defines ordered access rules evaluated top-to-bottom.
First matching rule wins (like iptables). If no rule matches, the request
is denied.
Each rule has:
action—"allow"or"deny"(required)host— glob pattern via fnmatch (required)method— HTTP method to match; omit to match any method (optional)
With the rules above:
GETto any host → allowed (rule 1)POSTtoapi.github.com→ allowed (rule 2)POSTtoapi.anthropic.com→ allowed (rule 3)POSTtoexample.com→ deniedPUTtoexample.com→ denied- Empty network list → all requests denied (default deny)
Dev containers never see real secret values. Instead, environment
variables contain deterministic placeholders
(SANDCAT_PLACEHOLDER_<NAME>), and the mitmproxy addon replaces them
with real values when requests pass through the proxy.
Inside the container, echo $ANTHROPIC_API_KEY prints
SANDCAT_PLACEHOLDER_ANTHROPIC_API_KEY. When a request containing that
placeholder reaches mitmproxy, it's replaced with the real key — but only
if the destination host matches the hosts allowlist.
The hosts field accepts glob patterns via fnmatch:
"api.anthropic.com"— exact match"*.anthropic.com"— any subdomain"*"— allow all hosts (use with caution)
If a placeholder appears in a request to a host not in the allowlist, mitmproxy blocks the request with HTTP 403 and logs a warning. This prevents accidental secret leakage to unintended services.
- The mitmproxy container mounts
~/.config/sandcat/settings.json(read-only) and thesandcat_addon.pyaddon script. - On startup, the addon reads
settings.jsonand writesplaceholders.envto themitmproxy-configshared volume (/home/mitmproxy/.mitmproxy/placeholders.env). This file contains lines likeexport ANTHROPIC_API_KEY="SANDCAT_PLACEHOLDER_ANTHROPIC_API_KEY". - App containers mount
mitmproxy-configread-only at/mitmproxy-config/. The shared entrypoint (sandcat-init.sh) sourcesplaceholders.envafter installing the CA cert, so every process gets the placeholder values as env vars. - On each request, the addon first checks network access rules. If denied, the request is blocked with 403.
- If allowed, the addon checks for secret placeholders in the request, verifies the destination host against the secret's allowlist, and either substitutes the real value or blocks the request with 403 (leak detection).
Real secrets never leave the mitmproxy container.
Delete or rename ~/.config/sandcat/settings.json. If the file is
absent, the addon disables itself — no network rules are enforced and no
placeholder env vars are set.
Claude Code ignores ANTHROPIC_API_KEY until onboarding is complete.
Without {"hasCompletedOnboarding": true} in ~/.claude.json, it
prompts for browser-based login instead of using the key. The dev
container automatically sets this on startup (via
scripts/post-create.sh) if not already present, so Claude Code picks
up the API key from secret substitution without manual setup.
network_mode
┌──────────────┐ shares net ┌──────────────┐ WG tunnel ┌──────────────┐
│ app │ ──────────── │ wg-client │ ─────────── │ mitmproxy │ ── internet
│ (no NET_ADMIN) │ (NET_ADMIN) │ │ (mitmweb) │
└──────────────┘ └──────────────┘ └──────────────┘
pw: mitmproxy
- mitmproxy runs
mitmweb --mode wireguard, creating a WireGuard server and storing key pairs inwireguard.conf. - wg-client is a dedicated networking container that derives a
WireGuard client config from those keys, sets up the tunnel with
wgandipcommands, and adds iptables kill-switch rules. Only this container hasNET_ADMIN. No user code runs here. - App containers share
wg-client's network namespace vianetwork_mode. They inherit the tunnel and firewall rules but cannot modify them (noNET_ADMIN). They install the mitmproxy CA cert into the system trust store at startup so TLS interception works. - The mitmproxy web UI is exposed on a dynamic host port (see below)
to avoid conflicts when multiple projects include sandcat. Password:
mitmproxy.
Once inside the test container (see Quick start: try it out), you can inspect traffic in the mitmproxy web UI. The host port is assigned dynamically — look it up from a host terminal with:
docker compose -f .devcontainer/compose.yml port mitmproxy 8081Or using Docker's UI. Log in with password mitmproxy.
To verify the kill switch blocks direct traffic:
# Should fail — iptables blocks direct eth0 access
curl --max-time 3 --interface eth0 http://1.1.1.1
# Should fail — no NET_ADMIN to modify firewall
iptables -F OUTPUTTo verify secret substitution for the github token:
gh auth statuscd scripts && pytest test_sandcat_addon.py -vwg-quick calls sysctl -w net.ipv4.conf.all.src_valid_mark=1, which
fails in Docker because /proc/sys is read-only. The equivalent sysctl
is set via the sysctls option in compose.yml, and the entrypoint
script handles interface, routing, and firewall setup manually.
Node.js bundles its own CA certificates and ignores the system trust
store. The sandcat-init.sh entrypoint sets NODE_EXTRA_CA_CERTS to
the mitmproxy CA automatically. If you write a custom entrypoint, make
sure to include this or Node-based tools will fail TLS verification.
Rust programs using rustls with the webpki-roots crate bundle CA
certificates at compile time and will not trust the mitmproxy CA. Use
rustls-tls-native-roots in reqwest so it reads the system CA store at
runtime instead.