Sandcat is a Docker & dev container setup for securely running AI agents. The environment is sandboxed, with controlled network access and transparent secret substitution. All of this is done 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 straightforward allow/deny list-based engine controls which network requests go through, and a secret substitution system injects credentials at the proxy level so the container never sees real values.
This repository contains:
- reusable proxy definitions:
Dockerfile.wg-client,compose-proxy.yml, andscripts - template application and dev container configuration:
Dockerfile.app,compose-all.yml,devcontainer.json. This should be fine-tuned for each project and specific development stack, to install required tools and dependencies.
Sandcat can be used as a devcontainer setup, or standalone, providing a shell for secure development.
The CLI is a helper script and thin wrapper around docker-compose that simplifies the process of initializing and starting the sandbox.
# Pull the image to local docker
docker pull ghcr.io/virtuslab/sandcat
# Add to your .bashrc or .zshrc
alias sandcat='docker run --rm -it -v "/var/run/docker.sock:/var/run/docker.sock" -v"$PWD:$PWD" -w"$PWD" -e TERM -e HOME --network none ghcr.io/virtuslab/sandcat'Using the Docker image disables the editor integration (vi installed in the image will be used instead of your host editor).
The host environment variables will not be available inside the container unless you forward them explicitly.
This is important because Docker Compose runs, and resolves placeholders inside the container.
HOME is already forwarded to handle common use cases.
The image runs as root, to avoid permission issues with the host Docker socket. On Colima file ownership is mapped
automatically, on Linux you should add --user parameter accordingly.
# Clone the repo
git clone https://github.com/VirtusLab/sandcat.git
# Add the sandcat bin directory to your path (add this to your .bashrc or .zshrc)
export PATH="$PWD/sandcat/cli/bin:$PATH"yq is required to edit compose files.
sandcat initThis prompts you to select the agent type and IDE (for devcontainer mode), then sets up the necessary configuration files and network settings. You can also pass flags to skip prompts:
sandcat init --agent claude --ide vscodeOptional volume mounts (Claude config, shell customizations, dotfiles, .git, .idea, .vscode) are included as commented-out entries in the generated compose file. Uncomment them as needed, or set SANDCAT_* environment variables for scripted usage. See the CLI README for the full list of flags and environment variables.
CLI mode:
# Open a shell in the agent container
sandcat run
# Start your agent cli (e.g. claude). Because you're in a sandbox, you can use yolo mode!
yolo-claudecompose-all.yml — network_mode: "service:wg-client" routes all traffic
through the WireGuard tunnel. The mitmproxy-config volume gives your container
access to the CA cert, env vars, and secret placeholders. The ~/.claude/*
bind-mounts forward host Claude Code customizations — remove any mount whose
source does not exist on your host.
Dockerfile.app — uses mise to manage language
toolchains. Look for the CUSTOMIZE marker and add mise use -g lines for
your stack:
| Stack | mise command | CA trust notes |
|---|---|---|
| TypeScript / Node.js | mise use -g node@lts (already installed) |
Handled automatically by app-init.sh |
| Python | mise use -g python@3.13 |
Uses system store — works out of the box |
| Rust | mise use -g rust@latest |
Use rustls-tls-native-roots in reqwest |
| Java | mise use -g java@21 |
Handled automatically by app-user-init.sh |
Some runtimes need extra configuration to trust the mitmproxy CA — see TLS and CA certificates.
devcontainer.json — includes VS Code hardening settings (credential
socket cleanup, workspace trust, disabled local terminal). See
Hardening the VS Code setup for details.
Settings are loaded from up to three files (highest to lowest precedence):
| File | Scope | Git |
|---|---|---|
.sandcat/settings.local.json |
Per-project overrides | Ignored (add to .gitignore) |
.sandcat/settings.json |
Per-project defaults | Committed |
~/.config/sandcat/settings.json |
User-wide defaults | N/A |
All three files use the same JSON format. Missing files are silently skipped. If no files exist, the addon disables itself.
Merge rules:
env— merged; higher-precedence values overwrite lower ones.secrets— merged; higher-precedence entries overwrite lower ones.network— concatenated; highest-precedence rules come first. Since rules are evaluated top-to-bottom with first-match-wins, this means local rules take priority over project rules, which take priority over user rules.
A typical setup keeps user-specific settings (git identity, API keys) in the user file, project-wide network rules in the project file, and developer overrides in the local file:
~/.config/sandcat/settings.json (user):
{
"env": {
"GIT_USER_NAME": "Your Name",
"GIT_USER_EMAIL": "you@example.com"
},
"secrets": {
"ANTHROPIC_API_KEY": {
"value": "sk-ant-real-key-here",
"hosts": ["api.anthropic.com"]
}
}
}.sandcat/settings.json (project, committed):
{
"network": [
{"action": "allow", "host": "*", "method": "GET"},
{"action": "allow", "host": "*.github.com"},
{"action": "allow", "host": "*.anthropic.com"},
{"action": "allow", "host": "*.claude.com"}
]
}.sandcat/settings.local.json (project, git-ignored):
{
"network": [
{"action": "allow", "host": "internal.corp.dev"}
]
}With these files, the merged network rules are (local first, then project, then
user): allow internal.corp.dev, then the project rules. Env and secrets come
from the user file since neither project file defines them.
Warning: the liberal template allows all GET traffic, which means the agent can read arbitrary web content — a vector for prompt injection. The strict template narrows this to known service domains, but both templates allow full access to GitHub, which can be used to read untrusted content (prompt injection) or push data out (exfiltration). Malicious code might also be generated as part of the project itself.
Mitmproxy reads settings files only at startup (no hot-reload), and the app
container sources sandcat.env only during its entrypoint. After editing any
settings file, you need to restart services for changes to take effect.
You can use the CLI helper command:
sandcat edit settingsThis opens the network settings file in your editor. If you save changes, the proxy service will automatically restart to apply the new settings.
Note that VS Code's Rebuild Container only
rebuilds the app service — it does not restart mitmproxy or wg-client.
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)
Sandcat ships two example configurations. Copy one to get started and adjust to your needs:
settings.liberal.example.json — allows all HTTP GET requests to any host,
plus full access (all methods) to GitHub and Anthropic/Claude. Convenient for
development but means the agent can read arbitrary web content, which is a
prompt injection vector:
cp settings.liberal.example.json ~/.config/sandcat/settings.jsonsettings.strict.example.json — allows only listed service domains. Full
access (all methods) is limited to GitHub and Anthropic/Claude, which need POST
for pushing code and API calls. All other domains are GET-only (downloads,
package installs). You may need to add domains for your specific stack:
cp settings.strict.example.json ~/.config/sandcat/settings.jsonThe strict template narrows the attack surface compared to the liberal one, but does not eliminate it: the agent can still read arbitrary content from GitHub (issues, PRs, repository files) and write to it (commits, comments), which remains a prompt injection and data exfiltration vector.
The strict template includes:
| Service | Domains | Methods |
|---|---|---|
| GitHub | github.com, *.github.com, *.githubusercontent.com |
all |
| Claude / Anthropic | *.anthropic.com, *.claude.ai, *.claude.com |
all |
| VS Code | update.code.visualstudio.com, marketplace.visualstudio.com, *.vsassets.io, main.vscode-cdn.net |
GET |
| npm | registry.npmjs.org |
GET |
| PyPI | pypi.org, files.pythonhosted.org |
GET |
Common additions for other stacks:
| Stack | Domains |
|---|---|
| Rust / Cargo | crates.io, static.crates.io |
| Java / Maven | repo.maven.apache.org, repo1.maven.org |
| JetBrains | plugins.jetbrains.com, downloads.marketplace.jetbrains.com |
DNS queries are checked against the same network rules as HTTP requests. If a hostname is not allowed by any rule, the DNS lookup is refused — the container never learns the IP address. This prevents DNS-based exfiltration even when HTTP to that host would be blocked.
Because DNS has no HTTP method, method-specific rules are matched on host only.
A rule like {"action": "allow", "host": "*", "method": "GET"} will also allow
DNS resolution for any host. Rule ordering matters: a method-specific deny rule
will block DNS for that host even if a later rule would allow other methods.
With the liberal template rules:
GETto any host → allowed (rule 1)- DNS lookup for any host → allowed (rule 1 matches on host)
POSTtoapi.github.com→ allowed (rule 2)POSTtoapi.anthropic.com→ allowed (rule 4)POSTtoexample.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 the project's.sandcat/directory (read-only) alongside themitmproxy_addon.pyaddon script. - On startup, the addon reads all available settings files (user, project,
local), merges them according to the precedence rules above, and writes
sandcat.envto themitmproxy-configshared volume (/home/mitmproxy/.mitmproxy/sandcat.env). This file contains plain env vars (e.g.export GIT_USER_NAME="Your Name") and secret placeholders (e.g.export ANTHROPIC_API_KEY="SANDCAT_PLACEHOLDER_ANTHROPIC_API_KEY"). - App containers mount
mitmproxy-configread-only at/mitmproxy-config/. The shared entrypoint (app-init.sh) sourcessandcat.envafter installing the CA cert, so every process gets the env vars and placeholder values. - 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.
Remove all settings files. If no settings file exists at any layer, the addon
disables itself — no network rules are enforced and sandcat.env is not
written.
Claude Code supports two authentication methods inside the container:
- API key — add an
ANTHROPIC_API_KEYsecret tosettings.json. The entrypoint detects the key and seeds~/.claude.jsonwith{"hasCompletedOnboarding": true}so Claude Code uses it without interactive setup. - Subscription (browser login) — omit
ANTHROPIC_API_KEYfromsettings.json. On first run Claude Code will display a URL and a code. Open the URL in a browser on your host machine, enter the code, and authenticate there — the container itself cannot open a browser.
Autonomous mode. The bundled devcontainer.json enables
claudeCode.allowDangerouslySkipPermissions and sets
claudeCode.initialPermissionMode to bypassPermissions. This lets Claude Code
run without interactive permission prompts inside the container. The trade-off:
sandcat already provides the security boundary (network isolation, secret
substitution, iptables kill-switch), so the in-container prompts add friction
without meaningful security benefit. Remove these settings if you prefer
interactive approval. See Secure & Dangerous Claude Code + VS Code
Setup for
background on this approach.
Host customizations. The example compose-all.yml bind-mounts
~/.claude/CLAUDE.md, ~/.claude/agents, and ~/.claude/commands from the
host (read-only) so your personal instructions, custom agents, and slash
commands are available inside the container. Remove any mount whose source does
not exist on your host — Docker will otherwise create an empty directory in its
place.
flowchart LR
app["<b>app</b><br/><i>no NET_ADMIN</i><br/>your code runs here"]
wg["<b>wg-client</b><br/><i>NET_ADMIN</i><br/>WireGuard + iptables"]
mitm["<b>mitmproxy</b><br/><i>mitmweb</i><br/>network rules &<br/>secret substitution"]
inet(("internet"))
app -- "network_mode:<br/>shares net namespace" --- wg
wg -- "WireGuard<br/>tunnel" --> mitm
mitm -- "allowed<br/>requests" --> inet
style app fill:#e8f4fd,stroke:#4a90d9
style wg fill:#fdf2e8,stroke:#d9904a
style mitm fill:#e8fde8,stroke:#4ad94a
- 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.
The containers communicate through two shared volumes and several bind-mounts from the host:
flowchart TB
subgraph volumes["Shared volumes"]
mc["<b>mitmproxy-config</b><br/><i>wireguard.conf</i><br/><i>mitmproxy-ca-cert.pem</i><br/><i>sandcat.env</i>"]
ah["<b>app-home</b><br/><i>/home/vscode</i><br/>persists Claude Code state,<br/>shell history across rebuilds"]
end
subgraph host["Host bind-mounts (read-only)"]
settings["~/.config/sandcat/<br/>settings.json"]
projsettings[".sandcat/<br/>settings.json,<br/>settings.local.json"]
claude["~/.claude/<br/>CLAUDE.md, agents/, commands/"]
end
mitm["mitmproxy"] -- "read-write" --> mc
wg["wg-client"] -- "read-only" --> mc
app["app"] -- "read-only" --> mc
app -- "read-write" --> ah
settings -. "bind-mount" .-> mitm
projsettings -. "bind-mount" .-> mitm
claude -. "bind-mount" .-> app
style mc fill:#f0e8fd,stroke:#904ad9
style ah fill:#f0e8fd,stroke:#904ad9
style settings fill:#fde8e8,stroke:#d94a4a
style projsettings fill:#fde8e8,stroke:#d94a4a
style claude fill:#fde8e8,stroke:#d94a4a
mitmproxy-configis the key shared volume. Mitmproxy writes to it (WireGuard keys, CA cert,sandcat.envwith env vars and secret placeholders); all other containers mount it read-only.app-homepersists the vscode user's home directory across container rebuilds (Claude Code auth, shell history, git config).- Settings files are bind-mounted from the host into mitmproxy only — app
containers never see real secrets. The user settings file
(
~/.config/sandcat/settings.json) and the project settings directory (.sandcat/) are both mounted read-only. - Claude Code customizations (
CLAUDE.md,agents/,commands/) are bind-mounted from the host into the app container read-only.
The containers start in dependency order. Each step writes data to the shared
mitmproxy-config volume that the next step reads:
sequenceDiagram
participant M as mitmproxy
participant W as wg-client
participant A as app
Note over M: starts first (no dependencies)
M->>M: Start WireGuard server
M->>M: Generate wireguard.conf (key pairs)
M->>M: Read + merge settings (user, project, local)
M->>M: Write sandcat.env (env vars + secret placeholders)
M->>M: Write mitmproxy-ca-cert.pem
Note over M: healthcheck passes<br/>(wireguard.conf exists)
Note over W: starts after mitmproxy is healthy
W->>W: Read wireguard.conf from shared volume
W->>W: Derive WireGuard client keys
W->>W: Create wg0 interface + routing
W->>W: Set up iptables kill switch
W->>W: Configure DNS via tunnel
Note over W: healthcheck passes<br/>(/tmp/wg-ready exists)
Note over A: starts after wg-client is healthy
A->>A: Read CA cert from shared volume
A->>A: Install CA into system trust store
A->>A: Set NODE_EXTRA_CA_CERTS
A->>A: Source sandcat.env (env vars + secret placeholders)
A->>A: Run app-user-init.sh (git identity, etc.)
A->>A: Drop to vscode user, exec main command
Note over A: ready for use
Sandcat secures the network path out of the container, but VS Code's dev container integration introduces a separate trust boundary. The VS Code remote architecture gives container-side extensions access to host resources (terminals, credentials, clipboard) through the IDE channel, bypassing network-level controls entirely.
For background on these attack vectors see Leveraging VS Code Internals to Escape Containers.
The included devcontainer.json applies the following mitigations out of the
box:
- Clears forwarded credential sockets (
SSH_AUTH_SOCK,GPG_AGENT_INFO,GIT_ASKPASS) viaremoteEnvso container code cannot piggyback on host SSH keys, GPG signing, or VS Code's git credential helpers. Clearing env vars alone only hides the path — the socket file in/tmpcan still be discovered by scanning. - Removes credential sockets via a
postStartCommandscript that deletesvscode-ssh-auth-*.sockandvscode-git-*.sockfrom/tmpafter VS Code connects. This is a best-effort measure — the socket path patterns could change in future VS Code versions. - Disables git config copying (
dev.containers.copyGitConfig: false) to prevent leaking host credential helpers and signing key references into the container. - Enables workspace trust (
security.workspace.trust.enabled: true) so VS Code prompts before applying workspace settings that container code could have modified via the bind-mounted project folder. - Blocks local terminal creation (
terminal.integrated.allowLocalTerminal: false) so container extensions cannot callworkbench.action.terminal.newLocalto open a shell on the host, which would bypass the WireGuard tunnel entirely. For maximum protection, also set this in your host user settings (workspace settings could theoretically override it). - Read-only
.devcontaineroverlay —compose-all.ymlmounts the.devcontainerdirectory as a separate read-only bind mount on top of the writable project mount. This prevents the agent from modifying its own sandbox configuration (entrypoint scripts, Dockerfile, compose files, devcontainer.json).
Disabling credential forwarding and git config copying improves isolation but requires a few adjustments.
Git identity. With dev.containers.copyGitConfig set to false, git inside
the container has no user.name or user.email. Add them to the env section
of your settings.json:
"env": {
"GIT_USER_NAME": "Your Name",
"GIT_USER_EMAIL": "you@example.com"
}The mitmproxy addon writes env entries to the shared env file (alongside
secret placeholders), and app-user-init.sh applies
GIT_USER_NAME/GIT_USER_EMAIL via git config --global at container startup.
HTTPS remotes only. SSH-based git operations won't work — SSH_AUTH_SOCK is
cleared and credential sockets are removed, so no SSH keys are available. The
entrypoint automatically rewrites GitHub SSH URLs to HTTPS via git config url.*.insteadOf, so existing git@github.com: remotes work without manual
changes. Sandcat's secret substitution handles GitHub token authentication over
HTTPS transparently.
Once inside the container, you can inspect traffic in the mitmproxy web UI. The host port is assigned dynamically — look it up from a host terminal with:
sandcat compose 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 Docker-internal traffic works (e.g. a database or app service added to the compose file):
# Should succeed — Docker network traffic is allowed
curl --max-time 3 http://my-service:8080To verify host access is blocked:
# Should fail — gateway (host) is blocked
docker_gateway=$(ip -4 route show default dev eth0 | awk '{print $3}')
curl --max-time 3 "http://$docker_gateway"To verify direct mitmproxy access is blocked:
# Should fail — mitmproxy container is only reachable via WireGuard
mitmproxy_ip=$(getent hosts mitmproxy | awk '{print $1}')
curl --max-time 3 "http://$mitmproxy_ip:8081"To verify secret substitution for the GitHub token:
gh auth statusPython tests (mitmproxy addon):
cd cli/templates/claude/devcontainer/sandcat/scripts && pytest test_mitmproxy_addon.py -vBATS tests (CLI):
cd cli && ./test/run.shSandcat 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+mitmproxy in WireGuard mode integration implemented in mitm_wg.
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-proxy.yml, and the entrypoint script handles
interface, routing, and firewall setup manually.
Sandcat's mitmproxy intercepts TLS traffic, so the app container must trust the
mitmproxy CA. app-init.sh installs it into the system trust store, which is
enough for most tools — but some runtimes bring their own CA handling:
- Node.js bundles its own CA certificates and ignores the system store.
app-init.shsetsNODE_EXTRA_CA_CERTSautomatically. If you write a custom entrypoint, make sure to include this or Node-based tools will fail TLS verification. - Rust programs using
rustlswith thewebpki-rootscrate bundle CA certificates at compile time and will not trust the mitmproxy CA. Userustls-tls-native-rootsin reqwest so it reads the system CA store at runtime instead. - Java uses its own trust store (
cacerts) and ignores the system CA. TheDockerfile.appbuild step creates a version-independentJAVA_HOMEsymlink, copies the defaultcacerts, and writesJAVA_HOMEandJAVA_TOOL_OPTIONS(with-Djavax.net.ssl.trustStore) to.bashrcso VS Code'suserEnvProbepicks them up immediately. At container startup,app-user-init.shimports the mitmproxy CA into thecacertscopy at~/.local/share/sandcat/cacertsand updates the symlink target if the Java version changed. GraalVM native binaries (e.g.scala-cli) ignoreJAVA_TOOL_OPTIONSandJAVA_HOMEfor trust store resolution.app-user-init.shpre-creates thescala-cliconfig file with the trust store path so it works even before scala-cli is installed. Other native tools may need similar tool-specific configuration. - Python uses the system CA store — works out of the box.
Start the container from the command line:
sandcat compose runTear down all containers and volumes (resets persisted home directory):
sandcat compose down -vWe offer commercial services around AI-assisted software development. Contact us to learn more about our offer!
Copyright (C) 2026 VirtusLab https://virtuslab.com.