12 Stars 🍴 1 Forks 👀 12 Watchers Python apache-2.0
GitHub 链接https://github.com/softwaremill/sandcat
项目简介A dev container setup that routes all container traffic through a transparent mitmproxy via WireGuard, enforcing network access rules and injecting secrets at the proxy level
创建时间2026-02-11
更新时间2026-02-12
📖 README English
# Sandcat Sandcat is a [dev container](https://containers.dev) 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](https://mitmproxy.org/) 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. ## Inspiration Sandcat is mainly inspired by [Matchlock](https://github.com/jingkaihe/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](https://github.com/mattolson/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](https://github.com/Srikanth0824/side-projects/tree/main/mitm_wg). ## Quick start: try it out Create a settings file with your secrets and network rules: ```sh mkdir -p ~/.config/sandcat cp settings.example.json ~/.config/sandcat/settings.json # Edit with your real values ``` Then start the built-in test container to verify everything works: ```sh docker compose -f .devcontainer/compose.yml --profile test run --rm test bash ``` Inside the container: ```sh # 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 status ``` See [Testing the proxy](#testing-the-proxy) for more verification steps. ## Quick start: add to your project Add sandcat as a git submodule inside `.devcontainer/`: ```sh git submodule add <url> .devcontainer/sandcat ``` Your `.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: ```yaml 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_healthy ``` The 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: ```dockerfile 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. ## Settings format `~/.config/sandcat/settings.json`: ```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. ## Network access rules 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) ### Examples With the rules above: - `GET` to any host → **allowed** (rule 1) - `POST` to `api.github.com` → **allowed** (rule 2) - `POST` to `api.anthropic.com` → **allowed** (rule 3) - `POST` to `example.com` → **denied** - `PUT` to `example.com` → **denied** - Empty network list → all requests **denied** (default deny) ## Secret substitution 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. ### Host patterns The `hosts` field accepts glob patterns via `fnmatch`: - `"api.anthropic.com"` — exact match - `"*.anthropic.com"` — any subdomain - `"*"` — allow all hosts (use with caution) ### Leak detection 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. ### How it works internally 1. The mitmproxy container mounts `~/.config/sandcat/settings.json` (read-only) and the `sandcat_addon.py` addon script. 2. On startup, the addon reads `settings.json` and writes `placeholders.env` to the `mitmproxy-config` shared volume (`/home/mitmproxy/.mitmproxy/placeholders.env`). This file contains lines like `export ANTHROPIC_API_KEY="SANDCAT_PLACEHOLDER_ANTHROPIC_API_KEY"`. 3. App containers mount `mitmproxy-config` read-only at `/mitmproxy-config/`. The shared entrypoint (`sandcat-init.sh`) sources `placeholders.env` after installing the CA cert, so every process gets the placeholder values as env vars. 4. On each request, the addon first checks network access rules. If denied, the request is blocked with 403. 5. 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. ### Disabling 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 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. ## Architecture ``` 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 in `wireguard.conf`. - **wg-client** is a dedicated networking container that derives a WireGuard client config from those keys, sets up the tunnel with `wg` and `ip` commands, and adds iptables kill-switch rules. Only this container has `NET_ADMIN`. No user code runs here. - **App containers** share `wg-client`'s network namespace via `network_mode`. They inherit the tunnel and firewall rules but cannot modify them (no `NET_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`. ## Testing the proxy Once inside the test container (see [Quick start: try it out](#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: ```sh docker compose -f .devcontainer/compose.yml port mitmproxy 8081 ``` Or using Docker's UI. Log in with password `mitmproxy`. To verify the kill switch blocks direct traffic: ```sh # 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 OUTPUT ``` To verify secret substitution for the github token: ```sh gh auth status ``` ## Unit tests ```sh cd scripts && pytest test_sandcat_addon.py -v ``` ## Notes ### Why not wg-quick? `wg-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 TLS 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 TLS 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.